diff --git a/.ci/Jenkinsfile_flaky b/.ci/Jenkinsfile_flaky index 33204d739646..b9880c410fc6 100644 --- a/.ci/Jenkinsfile_flaky +++ b/.ci/Jenkinsfile_flaky @@ -29,16 +29,20 @@ kibanaPipeline(timeoutMinutes: 180) { catchErrors { print "Agent ${agentNumberInside} - ${agentExecutions} executions" - workers.functional('flaky-test-runner', { - if (!IS_XPACK) { - kibanaPipeline.buildOss() - if (CI_GROUP == '1') { - runbld("./test/scripts/jenkins_build_kbn_sample_panel_action.sh", "Build kbn tp sample panel action for ciGroup1") + withEnv([ + 'IGNORE_SHIP_CI_STATS_ERROR=true', + ]) { + workers.functional('flaky-test-runner', { + if (!IS_XPACK) { + kibanaPipeline.buildOss() + if (CI_GROUP == '1') { + runbld("./test/scripts/jenkins_build_kbn_sample_panel_action.sh", "Build kbn tp sample panel action for ciGroup1") + } + } else { + kibanaPipeline.buildXpack() } - } else { - kibanaPipeline.buildXpack() - } - }, getWorkerMap(agentNumberInside, agentExecutions, worker, workerFailures))() + }, getWorkerMap(agentNumberInside, agentExecutions, worker, workerFailures))() + } } } } diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index f64b9e95fbaa..238a21161b12 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -9,9 +9,14 @@ on: jobs: backport: name: Backport PR - if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'auto-backport') + if: | + github.event.pull_request.merged == true + && contains(github.event.pull_request.labels.*.name, 'auto-backport') + && ( + (github.event.action == 'labeled' && github.event.label.name == 'auto-backport') + || (github.event.action == 'closed') + ) runs-on: ubuntu-latest - steps: - name: 'Get backport config' run: | diff --git a/docs/dev-tools/searchprofiler/getting-started.asciidoc b/docs/dev-tools/searchprofiler/getting-started.asciidoc index 7cd54db5562b..ad73d03bcbfd 100644 --- a/docs/dev-tools/searchprofiler/getting-started.asciidoc +++ b/docs/dev-tools/searchprofiler/getting-started.asciidoc @@ -2,7 +2,7 @@ [[profiler-getting-started]] === Getting Started -The {searchprofiler} is automatically enabled in {kib}. Open the main menu, click *Dev Tools*, then click *Search Profiler* +The {searchprofiler} is automatically enabled in {kib}. Open the main menu, click *Dev Tools*, then click *{searchprofiler}* to get started. {searchprofiler} displays the names of the indices searched, the shards in each index, diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 263addc98ee6..613f2d0fbf20 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -318,10 +318,6 @@ Failure to have auth enabled in Kibana will make for a broken UI. UI-based error |The cloud plugin adds cloud specific features to Kibana. -|{kib-repo}blob/{branch}/x-pack/plugins/code[code] -|WARNING: Missing README. - - |{kib-repo}blob/{branch}/x-pack/plugins/console_extensions/README.md[consoleExtensions] |This plugin provides autocomplete definitions of licensed APIs to the OSS Console plugin. diff --git a/docs/setup/install/deb.asciidoc b/docs/setup/install/deb.asciidoc index 6012ae394c83..1ec73e8c3c7f 100644 --- a/docs/setup/install/deb.asciidoc +++ b/docs/setup/install/deb.asciidoc @@ -156,6 +156,7 @@ locations for a Debian-based system: | config | Configuration files including `kibana.yml` | /etc/kibana + | <> d| | data diff --git a/docs/setup/install/rpm.asciidoc b/docs/setup/install/rpm.asciidoc index 216ec849147b..a87d2f89b6dd 100644 --- a/docs/setup/install/rpm.asciidoc +++ b/docs/setup/install/rpm.asciidoc @@ -149,6 +149,7 @@ locations for an RPM-based system: | config | Configuration files including `kibana.yml` | /etc/kibana + | <> d| | data diff --git a/docs/setup/install/targz.asciidoc b/docs/setup/install/targz.asciidoc index bb51d98a4f92..f0a90723a8ed 100644 --- a/docs/setup/install/targz.asciidoc +++ b/docs/setup/install/targz.asciidoc @@ -134,6 +134,7 @@ important data later on. | config | Configuration files including `kibana.yml` | $KIBANA_HOME\config + | <> d| | data diff --git a/docs/setup/install/windows.asciidoc b/docs/setup/install/windows.asciidoc index b4204cc623f0..4138fc1886a6 100644 --- a/docs/setup/install/windows.asciidoc +++ b/docs/setup/install/windows.asciidoc @@ -81,6 +81,7 @@ important data later on. | config | Configuration files including `kibana.yml` | $KIBANA_HOME\config + | <> d| | data diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 9b9c26fd0e1d..b57152646dda 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -5,7 +5,12 @@ The {kib} server reads properties from the `kibana.yml` file on startup. The location of this file differs depending on how you installed {kib}. For example, if you installed {kib} from an archive distribution (`.tar.gz` or `.zip`), by default it is in `$KIBANA_HOME/config`. By default, with package distributions -(Debian or RPM), it is in `/etc/kibana`. +(Debian or RPM), it is in `/etc/kibana`. The config directory can be changed via the +`KBN_PATH_CONF` environment variable: + +``` +KBN_PATH_CONF=/home/kibana/config ./bin/kibana +``` The default host and port settings configure {kib} to run on `localhost:5601`. To change this behavior and allow remote users to connect, you'll need to update your `kibana.yml` file. You can also enable SSL and set a variety of other options. Finally, environment variables can be injected into diff --git a/docs/user/dev-tools.asciidoc b/docs/user/dev-tools.asciidoc index 0ee7fbc741e0..0c5bef489dd0 100644 --- a/docs/user/dev-tools.asciidoc +++ b/docs/user/dev-tools.asciidoc @@ -15,7 +15,7 @@ a| <> | Interact with the REST API of Elasticsearch, including sending requests and viewing API documentation. -a| <> +a| <> | Inspect and analyze your search queries. diff --git a/package.json b/package.json index aac576dbc356..0fa8ef31ab25 100644 --- a/package.json +++ b/package.json @@ -247,7 +247,7 @@ "moment": "^2.24.0", "moment-duration-format": "^2.3.2", "moment-timezone": "^0.5.27", - "monaco-editor": "^0.17.0", + "monaco-editor": "^0.22.3", "mustache": "^2.3.2", "ngreact": "^0.5.1", "nock": "12.0.3", @@ -772,7 +772,7 @@ "react-fast-compare": "^2.0.4", "react-grid-layout": "^0.16.2", "react-markdown": "^4.3.1", - "react-monaco-editor": "^0.27.0", + "react-monaco-editor": "^0.41.2", "react-popper-tooltip": "^2.10.1", "react-resize-detector": "^4.2.0", "react-reverse-portal": "^1.0.4", diff --git a/packages/kbn-monaco/src/register_globals.ts b/packages/kbn-monaco/src/register_globals.ts index 66a00fff089b..a07d979e2022 100644 --- a/packages/kbn-monaco/src/register_globals.ts +++ b/packages/kbn-monaco/src/register_globals.ts @@ -39,6 +39,8 @@ const mapLanguageIdToWorker: { [key: string]: any } = { // @ts-ignore window.MonacoEnvironment = { + // needed for functional tests so that we can get value from 'editor' + monaco, getWorker: (module: string, languageId: string) => { const workerSrc = mapLanguageIdToWorker[languageId] || defaultWorkerSrc; diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index d939e7b3000f..375ad634cbc1 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -48147,13 +48147,13 @@ async function installBazelTools(repoRootPath) { const bazeliskVersion = await readBazelToolsVersionFile(repoRootPath, '.bazeliskversion'); const bazelVersion = await readBazelToolsVersionFile(repoRootPath, '.bazelversion'); // Check what globals are installed - _log__WEBPACK_IMPORTED_MODULE_4__["log"].debug(`[bazel_tools] verify if bazelisk is installed`); // Test if bazelisk is already installed in the correct version + _log__WEBPACK_IMPORTED_MODULE_4__["log"].debug(`[bazel_tools] verify if bazelisk is installed`); // Check if we need to remove bazelisk from yarn - const isBazeliskPkgInstalled = await isBazeliskInstalled(bazeliskVersion); // Test if bazel bin is available + await tryRemoveBazeliskFromYarnGlobal(); // Test if bazelisk is already installed in the correct version - const isBazelBinAlreadyAvailable = await isBazelBinAvailable(); // Check if we need to remove bazelisk from yarn + const isBazeliskPkgInstalled = await isBazeliskInstalled(bazeliskVersion); // Test if bazel bin is available - await tryRemoveBazeliskFromYarnGlobal(); // Install bazelisk if not installed + const isBazelBinAlreadyAvailable = await isBazelBinAvailable(); // Install bazelisk if not installed if (!isBazeliskPkgInstalled || !isBazelBinAlreadyAvailable) { _log__WEBPACK_IMPORTED_MODULE_4__["log"].info(`[bazel_tools] installing Bazel tools`); diff --git a/packages/kbn-pm/src/utils/bazel/install_tools.ts b/packages/kbn-pm/src/utils/bazel/install_tools.ts index b547c2bc141b..93acbe09b4ea 100644 --- a/packages/kbn-pm/src/utils/bazel/install_tools.ts +++ b/packages/kbn-pm/src/utils/bazel/install_tools.ts @@ -83,15 +83,15 @@ export async function installBazelTools(repoRootPath: string) { // Check what globals are installed log.debug(`[bazel_tools] verify if bazelisk is installed`); + // Check if we need to remove bazelisk from yarn + await tryRemoveBazeliskFromYarnGlobal(); + // Test if bazelisk is already installed in the correct version const isBazeliskPkgInstalled = await isBazeliskInstalled(bazeliskVersion); // Test if bazel bin is available const isBazelBinAlreadyAvailable = await isBazelBinAvailable(); - // Check if we need to remove bazelisk from yarn - await tryRemoveBazeliskFromYarnGlobal(); - // Install bazelisk if not installed if (!isBazeliskPkgInstalled || !isBazelBinAlreadyAvailable) { log.info(`[bazel_tools] installing Bazel tools`); diff --git a/packages/kbn-test/jest-preset.js b/packages/kbn-test/jest-preset.js index 717be8f413b4..79fc3db86e06 100644 --- a/packages/kbn-test/jest-preset.js +++ b/packages/kbn-test/jest-preset.js @@ -93,9 +93,9 @@ module.exports = { // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation transformIgnorePatterns: [ - // ignore all node_modules except monaco-editor which requires babel transforms to handle dynamic import() + // ignore all node_modules except monaco-editor and react-monaco-editor which requires babel transforms to handle dynamic import() // since ESM modules are not natively supported in Jest yet (https://github.com/facebook/jest/issues/4842) - '[/\\\\]node_modules(?![\\/\\\\]monaco-editor)[/\\\\].+\\.js$', + '[/\\\\]node_modules(?![\\/\\\\](monaco-editor|react-monaco-editor))[/\\\\].+\\.js$', 'packages/kbn-pm/dist/index.js', ], diff --git a/packages/kbn-ui-shared-deps/webpack.config.js b/packages/kbn-ui-shared-deps/webpack.config.js index d04f6e30f316..7ff5978e1f2e 100644 --- a/packages/kbn-ui-shared-deps/webpack.config.js +++ b/packages/kbn-ui-shared-deps/webpack.config.js @@ -85,6 +85,13 @@ exports.getWebpackConfig = ({ dev = false } = {}) => ({ }, ], }, + { + test: /\.(ttf)(\?|$)/, + loader: 'url-loader', + options: { + limit: 8192, + }, + }, ], }, diff --git a/src/dev/build/tasks/copy_source_task.ts b/src/dev/build/tasks/copy_source_task.ts index 5571e0df2a96..06ec40e8fcfa 100644 --- a/src/dev/build/tasks/copy_source_task.ts +++ b/src/dev/build/tasks/copy_source_task.ts @@ -29,6 +29,7 @@ export const CopySource: Task = { '!src/cli/dev.js', '!src/functional_test_runner/**', '!src/dev/**', + '!src/plugins/telemetry/schema/**', // Skip telemetry schemas // this is the dev-only entry '!src/setup_node_env/index.js', '!**/public/**/*.{js,ts,tsx,json}', diff --git a/src/dev/code_coverage/ingest_coverage/integration_tests/fixtures/test_plugin/kibana.json b/src/dev/code_coverage/ingest_coverage/integration_tests/fixtures/test_plugin/kibana.json new file mode 100644 index 000000000000..cbb214b57570 --- /dev/null +++ b/src/dev/code_coverage/ingest_coverage/integration_tests/fixtures/test_plugin/kibana.json @@ -0,0 +1,6 @@ +{ + "id": "codeCoverageTestPlugin", + "version": "kibana", + "server": true, + "ui": false +} diff --git a/src/dev/code_coverage/ingest_coverage/integration_tests/fixtures/test_plugin/server/index.ts b/src/dev/code_coverage/ingest_coverage/integration_tests/fixtures/test_plugin/server/index.ts new file mode 100644 index 000000000000..5499a33fbf73 --- /dev/null +++ b/src/dev/code_coverage/ingest_coverage/integration_tests/fixtures/test_plugin/server/index.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Plugin } from './plugin'; + +export function plugin() { + return new Plugin(); +} diff --git a/src/dev/code_coverage/ingest_coverage/integration_tests/fixtures/test_plugin/server/plugin.ts b/src/dev/code_coverage/ingest_coverage/integration_tests/fixtures/test_plugin/server/plugin.ts new file mode 100644 index 000000000000..d4704ba05b59 --- /dev/null +++ b/src/dev/code_coverage/ingest_coverage/integration_tests/fixtures/test_plugin/server/plugin.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CoreSetup } from 'kibana/server'; + +export class Plugin { + constructor() {} + + public setup(core: CoreSetup) {} + + public start() { + // called after all plugins are set up + } + + public stop() { + // called when plugin is torn down during Kibana's shutdown sequence + } +} diff --git a/src/dev/code_coverage/ingest_coverage/integration_tests/mocks/CODEOWNERS b/src/dev/code_coverage/ingest_coverage/integration_tests/mocks/CODEOWNERS index 1822c3fd95e3..77b220282035 100644 --- a/src/dev/code_coverage/ingest_coverage/integration_tests/mocks/CODEOWNERS +++ b/src/dev/code_coverage/ingest_coverage/integration_tests/mocks/CODEOWNERS @@ -3,4 +3,4 @@ # For more info, see https://help.github.com/articles/about-codeowners/ # App -/x-pack/plugins/code/ @elastic/kibana-tre +/src/dev/code_coverage/ingest_coverage/integration_tests/fixtures/test_plugin @elastic/kibana-tre diff --git a/src/dev/code_coverage/ingest_coverage/integration_tests/team_assignment.test.js b/src/dev/code_coverage/ingest_coverage/integration_tests/team_assignment.test.js index 8fc34d29103b..8fe61ed76a92 100644 --- a/src/dev/code_coverage/ingest_coverage/integration_tests/team_assignment.test.js +++ b/src/dev/code_coverage/ingest_coverage/integration_tests/team_assignment.test.js @@ -39,11 +39,8 @@ describe('Team Assignment', () => { const { stdout } = await execa('grep', ['tre', teamAssignmentsPath], { cwd: ROOT_DIR }); const lines = stdout.split('\n').filter((line) => !line.includes('/target')); expect(lines).toEqual([ - 'x-pack/plugins/code/jest.config.js kibana-tre', - 'x-pack/plugins/code/server/config.ts kibana-tre', - 'x-pack/plugins/code/server/index.ts kibana-tre', - 'x-pack/plugins/code/server/plugin.test.ts kibana-tre', - 'x-pack/plugins/code/server/plugin.ts kibana-tre', + 'src/dev/code_coverage/ingest_coverage/integration_tests/fixtures/test_plugin/server/index.ts kibana-tre', + 'src/dev/code_coverage/ingest_coverage/integration_tests/fixtures/test_plugin/server/plugin.ts kibana-tre', ]); }); }); diff --git a/src/optimize/bundles_route/bundles_route.ts b/src/optimize/bundles_route/bundles_route.ts index 6debf4b47659..b88ca7e5c22b 100644 --- a/src/optimize/bundles_route/bundles_route.ts +++ b/src/optimize/bundles_route/bundles_route.ts @@ -10,6 +10,7 @@ import { extname, join } from 'path'; import Hapi from '@hapi/hapi'; import * as UiSharedDeps from '@kbn/ui-shared-deps'; +import agent from 'elastic-apm-node'; import { createDynamicAssetResponse } from './dynamic_asset_response'; import { FileHashCache } from './file_hash_cache'; @@ -101,6 +102,8 @@ function buildRouteForBundles({ method(request: Hapi.Request, h: Hapi.ResponseToolkit) { const ext = extname(request.params.path); + agent.setTransactionName('GET ?/bundles/?'); + if (ext !== '.js' && ext !== '.css') { return h.continue; } diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js index af63485507d0..3733e8669895 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -252,6 +252,12 @@ function discoverController($route, $scope, Promise) { (prop) => !_.isEqual(newStatePartial[prop], oldStatePartial[prop]) ); + if (oldStatePartial.hideChart && !newStatePartial.hideChart) { + // in case the histogram is hidden, no data is requested + // so when changing this state data needs to be fetched + changes.push(true); + } + if (changes.length) { refetch$.next(); } @@ -313,6 +319,8 @@ function discoverController($route, $scope, Promise) { setAppState, data, stateContainer, + searchSessionManager, + refetch$, }; const inspectorAdapters = ($scope.opts.inspectorAdapters = { @@ -412,6 +420,9 @@ function discoverController($route, $scope, Promise) { if (savedSearch.grid) { defaultState.grid = savedSearch.grid; } + if (savedSearch.hideChart) { + defaultState.hideChart = savedSearch.hideChart; + } return defaultState; } @@ -562,13 +573,6 @@ function discoverController($route, $scope, Promise) { }); }; - $scope.handleRefresh = function (_payload, isUpdate) { - if (isUpdate === false) { - searchSessionManager.removeSearchSessionIdFromURL({ replace: false }); - refetch$.next(); - } - }; - function getDimensions(aggs, timeRange) { const [metric, agg] = aggs; agg.params.timeRange = timeRange; @@ -601,7 +605,7 @@ function discoverController($route, $scope, Promise) { function onResults(resp) { inspectorRequest.stats(getResponseInspectorStats(resp, $scope.searchSource)).ok({ json: resp }); - if (getTimeField()) { + if (getTimeField() && !$scope.state.hideChart) { const tabifiedData = tabifyAggResponse($scope.opts.chartAggConfigs, resp); $scope.searchSource.rawResponse = resp; $scope.histogramData = discoverResponseHandler( @@ -704,7 +708,7 @@ function discoverController($route, $scope, Promise) { async function setupVisualization() { // If no timefield has been specified we don't create a histogram of messages - if (!getTimeField()) return; + if (!getTimeField() || $scope.state.hideChart) return; const { interval: histogramInterval } = $scope.state; const visStateAggs = [ diff --git a/src/plugins/discover/public/application/angular/discover_legacy.html b/src/plugins/discover/public/application/angular/discover_legacy.html index dc18b7929318..501496494106 100644 --- a/src/plugins/discover/public/application/angular/discover_legacy.html +++ b/src/plugins/discover/public/application/angular/discover_legacy.html @@ -17,7 +17,6 @@ state="state" time-range="timeRange" top-nav-menu="topNavMenu" - update-query="handleRefresh" use-new-fields-api="useNewFieldsApi" unmapped-fields-config="unmappedFieldsConfig" > diff --git a/src/plugins/discover/public/application/angular/discover_state.ts b/src/plugins/discover/public/application/angular/discover_state.ts index 5e93966d78d9..93fc49b65cbc 100644 --- a/src/plugins/discover/public/application/angular/discover_state.ts +++ b/src/plugins/discover/public/application/angular/discover_state.ts @@ -44,6 +44,10 @@ export interface AppState { * Data Grid related state */ grid?: DiscoverGridSettings; + /** + * Hide chart + */ + hideChart?: boolean; /** * id of the used index pattern */ diff --git a/src/plugins/discover/public/application/components/discover.test.tsx b/src/plugins/discover/public/application/components/discover.test.tsx index f0f11558abd6..00554196e11f 100644 --- a/src/plugins/discover/public/application/components/discover.test.tsx +++ b/src/plugins/discover/public/application/components/discover.test.tsx @@ -25,6 +25,8 @@ import { indexPatternWithTimefieldMock } from '../../__mocks__/index_pattern_wit import { calcFieldCounts } from '../helpers/calc_field_counts'; import { DiscoverProps } from './types'; import { RequestAdapter } from '../../../../inspector/common'; +import { Subject } from 'rxjs'; +import { DiscoverSearchSessionManager } from '../angular/discover_search_session'; const mockNavigation = navigationPluginMock.createStartContract(); @@ -73,8 +75,10 @@ function getProps(indexPattern: IndexPattern): DiscoverProps { indexPatternList: (indexPattern as unknown) as Array>, inspectorAdapters: { requests: {} as RequestAdapter }, navigateTo: jest.fn(), + refetch$: {} as Subject, sampleSize: 10, savedSearch: savedSearchMock, + searchSessionManager: {} as DiscoverSearchSessionManager, setHeaderActionMenu: jest.fn(), timefield: indexPattern.timeFieldName || '', setAppState: jest.fn(), @@ -86,7 +90,6 @@ function getProps(indexPattern: IndexPattern): DiscoverProps { rows: esHits, searchSource: searchSourceMock, state: { columns: [] }, - updateQuery: jest.fn(), }; } diff --git a/src/plugins/discover/public/application/components/discover.tsx b/src/plugins/discover/public/application/components/discover.tsx index 99baa30e18c7..71650a4a3847 100644 --- a/src/plugins/discover/public/application/components/discover.tsx +++ b/src/plugins/discover/public/application/components/discover.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ import './discover.scss'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useState, useRef, useMemo, useCallback } from 'react'; import { EuiButtonEmpty, EuiButtonIcon, @@ -66,7 +66,6 @@ export function Discover({ searchSource, state, timeRange, - updateQuery, unmappedFieldsConfig, }: DiscoverProps) { const [expandedDoc, setExpandedDoc] = useState(undefined); @@ -76,8 +75,11 @@ export function Discover({ // collapse icon isn't displayed in mobile view, use it to detect which view is displayed return collapseIcon && !collapseIcon.current; }; - - const [toggleOn, toggleChart] = useState(true); + const toggleHideChart = useCallback(() => { + const newState = { ...state, hideChart: !state.hideChart }; + opts.stateContainer.setAppState(newState); + }, [state, opts]); + const hideChart = useMemo(() => state.hideChart, [state]); const { savedSearch, indexPatternList, config, services, data, setAppState } = opts; const { trackUiMetric, capabilities, indexPatterns } = services; const [isSidebarClosed, setIsSidebarClosed] = useState(false); @@ -89,6 +91,15 @@ export function Discover({ const contentCentered = resultState === 'uninitialized'; const isLegacy = services.uiSettings.get('doc_table:legacy'); const useNewFieldsApi = !services.uiSettings.get(SEARCH_FIELDS_FROM_SOURCE); + const updateQuery = useCallback( + (_payload, isUpdate?: boolean) => { + if (isUpdate === false) { + opts.searchSessionManager.removeSearchSessionIdFromURL({ replace: false }); + opts.refetch$.next(); + } + }, + [opts] + ); const { onAddColumn, onRemoveColumn, onMoveColumn, onSetColumns } = useMemo( () => @@ -192,7 +203,8 @@ export function Discover({ indexPattern={indexPattern} opts={opts} onOpenInspector={onOpenInspector} - state={state} + query={state.query} + savedQuery={state.savedQuery} updateQuery={updateQuery} /> @@ -277,7 +289,7 @@ export function Discover({ onResetQuery={resetQuery} /> - {toggleOn && ( + {!hideChart && ( { - toggleChart(!toggleOn); + toggleHideChart(); }} data-test-subj="discoverChartToggle" > - {toggleOn + {!hideChart ? i18n.translate('discover.hideChart', { defaultMessage: 'Hide chart', }) @@ -312,7 +324,7 @@ export function Discover({ {isLegacy && } - {toggleOn && opts.timefield && ( + {!hideChart && opts.timefield && (
>, inspectorAdapters: { requests: {} as RequestAdapter }, navigateTo: jest.fn(), + refetch$: {} as Subject, sampleSize: 10, savedSearch: savedSearchMock, + searchSessionManager: {} as DiscoverSearchSessionManager, services, setAppState: jest.fn(), setHeaderActionMenu: jest.fn(), stateContainer: {} as GetStateReturn, timefield: indexPattern.timeFieldName || '', }, - state, + query: {} as Query, + savedQuery: '', updateQuery: jest.fn(), onOpenInspector: jest.fn(), }; diff --git a/src/plugins/discover/public/application/components/discover_topnav.tsx b/src/plugins/discover/public/application/components/discover_topnav.tsx index 69a1433b6505..fd2aba22aa41 100644 --- a/src/plugins/discover/public/application/components/discover_topnav.tsx +++ b/src/plugins/discover/public/application/components/discover_topnav.tsx @@ -8,17 +8,21 @@ import React, { useMemo } from 'react'; import { DiscoverProps } from './types'; import { getTopNavLinks } from './top_nav/get_top_nav_links'; +import { Query, TimeRange } from '../../../../data/common/query'; -export type DiscoverTopNavProps = Pick< - DiscoverProps, - 'indexPattern' | 'updateQuery' | 'state' | 'opts' -> & { onOpenInspector: () => void }; +export type DiscoverTopNavProps = Pick & { + onOpenInspector: () => void; + query?: Query; + savedQuery?: string; + updateQuery: (payload: { dateRange: TimeRange; query?: Query }, isUpdate?: boolean) => void; +}; export const DiscoverTopNav = ({ indexPattern, opts, onOpenInspector, - state, + query, + savedQuery, updateQuery, }: DiscoverTopNavProps) => { const showDatePicker = useMemo(() => indexPattern.isTimeBased(), [indexPattern]); @@ -58,9 +62,9 @@ export const DiscoverTopNav = ({ indexPatterns={[indexPattern]} onQuerySubmit={updateQuery} onSavedQueryIdChange={updateSavedQueryId} - query={state.query} + query={query} setMenuMountPoint={opts.setHeaderActionMenu} - savedQueryId={state.savedQuery} + savedQueryId={savedQuery} screenTitle={opts.savedSearch.title} showDatePicker={showDatePicker} showSaveQuery={!!opts.services.capabilities.discover.saveQuery} diff --git a/src/plugins/discover/public/application/components/types.ts b/src/plugins/discover/public/application/components/types.ts index ee06bcab6528..e276795f9ed7 100644 --- a/src/plugins/discover/public/application/components/types.ts +++ b/src/plugins/discover/public/application/components/types.ts @@ -7,6 +7,7 @@ */ import { IUiSettingsClient, MountPoint, SavedObject } from 'kibana/public'; +import { Subject } from 'rxjs'; import { Chart } from '../angular/helpers/point_series'; import { IndexPattern } from '../../../../data/common/index_patterns/index_patterns'; import { ElasticSearchHit } from '../doc_views/doc_views_types'; @@ -17,13 +18,12 @@ import { FilterManager, IndexPatternAttributes, ISearchSource, - Query, - TimeRange, } from '../../../../data/public'; import { SavedSearch } from '../../saved_searches'; import { AppState, GetStateReturn } from '../angular/discover_state'; import { RequestAdapter } from '../../../../inspector/common'; import { DiscoverServices } from '../../build_services'; +import { DiscoverSearchSessionManager } from '../angular/discover_search_session'; export interface DiscoverProps { /** @@ -97,10 +97,18 @@ export interface DiscoverProps { * List of available index patterns */ indexPatternList: Array>; + /** + * Refetch observable + */ + refetch$: Subject; /** * Kibana core services used by discover */ services: DiscoverServices; + /** + * Helps with state management of search session + */ + searchSessionManager: DiscoverSearchSessionManager; /** * The number of documents that can be displayed in the table/grid */ @@ -113,10 +121,6 @@ export interface DiscoverProps { * Function to set the header menu */ setHeaderActionMenu: (menuMount: MountPoint | undefined) => void; - /** - * Functions for retrieving/mutating state - */ - stateContainer: GetStateReturn; /** * Timefield of the currently used index pattern */ @@ -125,6 +129,10 @@ export interface DiscoverProps { * Function to set the current state */ setAppState: (state: Partial) => void; + /** + * State container providing globalState, appState and functions + */ + stateContainer: GetStateReturn; }; /** * Function to reset the current query @@ -150,10 +158,6 @@ export interface DiscoverProps { * Currently selected time range */ timeRange?: { from: string; to: string }; - /** - * Function to update the actual query - */ - updateQuery: (payload: { dateRange: TimeRange; query?: Query }, isUpdate?: boolean) => void; /** * An object containing properties for proper handling of unmapped fields in the UI */ diff --git a/src/plugins/discover/public/application/helpers/persist_saved_search.ts b/src/plugins/discover/public/application/helpers/persist_saved_search.ts index 1bebf60c0a80..06e90c93bc77 100644 --- a/src/plugins/discover/public/application/helpers/persist_saved_search.ts +++ b/src/plugins/discover/public/application/helpers/persist_saved_search.ts @@ -48,6 +48,9 @@ export async function persistSavedSearch( if (state.grid) { savedSearch.grid = state.grid; } + if (state.hideChart) { + savedSearch.hideChart = state.hideChart; + } try { const id = await savedSearch.save(saveOptions); diff --git a/src/plugins/discover/public/saved_searches/_saved_search.ts b/src/plugins/discover/public/saved_searches/_saved_search.ts index d5bd3ea4011b..a7b6ef49cacd 100644 --- a/src/plugins/discover/public/saved_searches/_saved_search.ts +++ b/src/plugins/discover/public/saved_searches/_saved_search.ts @@ -14,6 +14,7 @@ export function createSavedSearchClass(savedObjects: SavedObjectsStart) { public static mapping = { title: 'text', description: 'text', + hideChart: 'boolean', hits: 'integer', columns: 'keyword', grid: 'object', @@ -35,6 +36,7 @@ export function createSavedSearchClass(savedObjects: SavedObjectsStart) { mapping: { title: 'text', description: 'text', + hideChart: 'boolean', hits: 'integer', columns: 'keyword', grid: 'object', diff --git a/src/plugins/discover/public/saved_searches/types.ts b/src/plugins/discover/public/saved_searches/types.ts index 24fbbcb61cb4..4646744ee0ef 100644 --- a/src/plugins/discover/public/saved_searches/types.ts +++ b/src/plugins/discover/public/saved_searches/types.ts @@ -24,6 +24,7 @@ export interface SavedSearch { lastSavedTitle?: string; copyOnSave?: boolean; pre712?: boolean; + hideChart?: boolean; } export interface SavedSearchLoader { get: (id: string) => Promise; diff --git a/src/plugins/discover/server/saved_objects/search.ts b/src/plugins/discover/server/saved_objects/search.ts index 43f107399ac3..de3a2197fe0a 100644 --- a/src/plugins/discover/server/saved_objects/search.ts +++ b/src/plugins/discover/server/saved_objects/search.ts @@ -34,6 +34,7 @@ export const searchSavedObjectType: SavedObjectsType = { properties: { columns: { type: 'keyword', index: false, doc_values: false }, description: { type: 'text' }, + hideChart: { type: 'boolean', index: false, doc_values: false }, hits: { type: 'integer', index: false, doc_values: false }, kibanaSavedObjectMeta: { properties: { diff --git a/src/plugins/kibana_react/public/code_editor/code_editor.stories.tsx b/src/plugins/kibana_react/public/code_editor/code_editor.stories.tsx index 57fcdef86179..a5fdfe773a2f 100644 --- a/src/plugins/kibana_react/public/code_editor/code_editor.stories.tsx +++ b/src/plugins/kibana_react/public/code_editor/code_editor.stories.tsx @@ -165,7 +165,6 @@ storiesOf('CodeEditor', module) provideCompletionItems: provideSuggestions, }} options={{ - wordBasedSuggestions: false, quickSuggestions: true, }} /> diff --git a/src/plugins/kibana_react/public/code_editor/code_editor.test.tsx b/src/plugins/kibana_react/public/code_editor/code_editor.test.tsx index c3d465d4f09e..33f0f311d3a4 100644 --- a/src/plugins/kibana_react/public/code_editor/code_editor.test.tsx +++ b/src/plugins/kibana_react/public/code_editor/code_editor.test.tsx @@ -50,9 +50,6 @@ test('editor mount setup', () => { suggestions: [], }), }; - const signatureProvider = { - provideSignatureHelp: () => ({ signatures: [], activeParameter: 0, activeSignature: 0 }), - }; const hoverProvider = { provideHover: (model: monaco.editor.ITextModel, position: monaco.Position) => ({ contents: [], @@ -82,7 +79,6 @@ test('editor mount setup', () => { onChange={() => {}} editorWillMount={editorWillMount} suggestionProvider={suggestionProvider} - signatureProvider={signatureProvider} hoverProvider={hoverProvider} /> ); @@ -99,6 +95,5 @@ test('editor mount setup', () => { // Verify our language features have been registered expect((monaco.languages.onLanguage as jest.Mock).mock.calls.length).toBe(1); expect((monaco.languages.registerCompletionItemProvider as jest.Mock).mock.calls.length).toBe(1); - expect((monaco.languages.registerSignatureHelpProvider as jest.Mock).mock.calls.length).toBe(1); expect((monaco.languages.registerHoverProvider as jest.Mock).mock.calls.length).toBe(1); }); diff --git a/src/plugins/presentation_util/public/components/dashboard_picker.tsx b/src/plugins/presentation_util/public/components/dashboard_picker.tsx index 6667280d0a23..83ccabe46cdc 100644 --- a/src/plugins/presentation_util/public/components/dashboard_picker.tsx +++ b/src/plugins/presentation_util/public/components/dashboard_picker.tsx @@ -64,6 +64,7 @@ export function DashboardPicker(props: DashboardPickerProps) { return ( ( documentId || disableDashboardOptions ? null : 'existing' diff --git a/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard_selector.tsx b/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard_selector.tsx index 9f6fd5eabf5c..c2b5eac4dbb8 100644 --- a/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard_selector.tsx +++ b/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard_selector.tsx @@ -21,7 +21,6 @@ import { EuiSpacer, } from '@elastic/eui'; -import { pluginServices } from '../services'; import { DashboardPicker, DashboardPickerProps } from './dashboard_picker'; import './saved_object_save_modal_dashboard.scss'; @@ -37,9 +36,6 @@ export interface SaveModalDashboardSelectorProps { export function SaveModalDashboardSelector(props: SaveModalDashboardSelectorProps) { const { documentId, onSelectDashboard, dashboardOption, onChange, copyOnSave } = props; - const { capabilities } = pluginServices.getHooks(); - const { canCreateNewDashboards, canEditDashboards } = capabilities.useService(); - const isDisabled = !copyOnSave && !!documentId; return ( @@ -70,50 +66,44 @@ export function SaveModalDashboardSelector(props: SaveModalDashboardSelectorProp >
- {canEditDashboards() && ( - <> - {' '} - onChange('existing')} - disabled={isDisabled} - /> -
- -
- - - )} - {canCreateNewDashboards() && ( - <> - {' '} - onChange('new')} - disabled={isDisabled} + <> + onChange('existing')} + disabled={isDisabled} + /> +
+ - - - )} +
+ + + <> + onChange('new')} + disabled={isDisabled} + /> + + boolean; canCreateNewDashboards: () => boolean; - canEditDashboards: () => boolean; } export interface PresentationUtilServices { diff --git a/src/plugins/presentation_util/public/services/kibana/capabilities.ts b/src/plugins/presentation_util/public/services/kibana/capabilities.ts index a191e970591f..546281d083f2 100644 --- a/src/plugins/presentation_util/public/services/kibana/capabilities.ts +++ b/src/plugins/presentation_util/public/services/kibana/capabilities.ts @@ -21,6 +21,5 @@ export const capabilitiesServiceFactory: CapabilitiesServiceFactory = ({ coreSta return { canAccessDashboards: () => Boolean(dashboard.show), canCreateNewDashboards: () => Boolean(dashboard.createNew), - canEditDashboards: () => !Boolean(dashboard.hideWriteControls), }; }; diff --git a/src/plugins/telemetry_collection_manager/server/encryption/encrypt.test.ts b/src/plugins/telemetry_collection_manager/server/encryption/encrypt.test.ts index 9d4443d6e703..c1a1a32e3c7f 100644 --- a/src/plugins/telemetry_collection_manager/server/encryption/encrypt.test.ts +++ b/src/plugins/telemetry_collection_manager/server/encryption/encrypt.test.ts @@ -14,13 +14,13 @@ describe('getKID', () => { it(`returns 'kibana_dev' kid for development`, async () => { const useProdKey = false; const kid = getKID(useProdKey); - expect(kid).toBe('kibana_dev'); + expect(kid).toBe('kibana_dev1'); }); - it(`returns 'kibana_prod' kid for development`, async () => { + it(`returns 'kibana_1' kid for production`, async () => { const useProdKey = true; const kid = getKID(useProdKey); - expect(kid).toBe('kibana'); + expect(kid).toBe('kibana1'); }); }); @@ -35,15 +35,15 @@ describe('encryptTelemetry', () => { expect(createRequestEncryptor).toBeCalledWith(telemetryJWKS); }); - it('uses kibana kid on { useProdKey: true }', async () => { + it('uses kibana1 kid on { useProdKey: true }', async () => { const payload = { some: 'value' }; await encryptTelemetry(payload, { useProdKey: true }); - expect(mockEncrypt).toBeCalledWith('kibana', payload); + expect(mockEncrypt).toBeCalledWith('kibana1', payload); }); - it('uses kibana_dev kid on { useProdKey: false }', async () => { + it('uses kibana_dev1 kid on { useProdKey: false }', async () => { const payload = { some: 'value' }; await encryptTelemetry(payload, { useProdKey: false }); - expect(mockEncrypt).toBeCalledWith('kibana_dev', payload); + expect(mockEncrypt).toBeCalledWith('kibana_dev1', payload); }); }); diff --git a/src/plugins/telemetry_collection_manager/server/encryption/encrypt.ts b/src/plugins/telemetry_collection_manager/server/encryption/encrypt.ts index 331aedce8cbc..a2c24627f6fd 100644 --- a/src/plugins/telemetry_collection_manager/server/encryption/encrypt.ts +++ b/src/plugins/telemetry_collection_manager/server/encryption/encrypt.ts @@ -10,7 +10,7 @@ import { createRequestEncryptor } from '@elastic/request-crypto'; import { telemetryJWKS } from './telemetry_jwks'; export function getKID(useProdKey = false): string { - return useProdKey ? 'kibana' : 'kibana_dev'; + return useProdKey ? 'kibana1' : 'kibana_dev1'; } export async function encryptTelemetry( diff --git a/src/plugins/telemetry_collection_manager/server/encryption/telemetry_jwks.ts b/src/plugins/telemetry_collection_manager/server/encryption/telemetry_jwks.ts index c3ac71969e44..bf4c2a952c43 100644 --- a/src/plugins/telemetry_collection_manager/server/encryption/telemetry_jwks.ts +++ b/src/plugins/telemetry_collection_manager/server/encryption/telemetry_jwks.ts @@ -12,21 +12,21 @@ export const telemetryJWKS: PublicJWKS = { keys: [ { kty: 'RSA', - kid: 'kibana', + kid: 'kibana1', use: 'enc', alg: 'RSA-OAEP', e: 'AQAB', n: - 'xYYa5XzvENaAzElCxQurloQM2KEQ058YSjZqmOwa-IN-EZMSUaYPY3qfYCG78ioRaKTHq4mgnkyrDKgjY_1pWKytiRD61FG2ZUeOCwzydnqO8Qpz2vFnibEHkZBRsKkLHgm90RgGpcXfz8vwxkz_nu59aWy5Qr7Ct99H0pEV1HoiCvy5Yw3QfWSAeV-3DWmq_0kX49tqk5yZE-vKnUhNMgqM22lMFTE5-vlaeHgv4ZcvCQx_HrOeea8LyZa5YOdqN-9st0g0G-aWp3CNI2-KJlMUTBAfIAtjwmJ-8QlgeIB1aA7OI2Ceh3kd4dNLesGdLvZ0y4f8IMOsO1dsRWSEsQ', + 'gjwVNVkOqbTZ6QrxdeYbKDnBzhCZGXM97Iq0dXJlpa-7UegcBemI1ALZkbX6AaDrCmqzetsnxJbVz2gr-uzkc97zzjTvPAn-jM-9cfjfsb-nd70qLY7ru3qdyOLb5-ho8cjmjnAp7VaEPuiNOjZ6V6tXq8Cn5LHH8G6K8oZLU1N4RWqkcAvEIlfaLusfMnl15fe7aZkYaKfVFjD-pti_2JGRV9XZ0knRI2oIRMaroBYpfYJxbpR0NLhR7ND6U5WlvxfnaVvRK4c_plVLOtcROqZVn98Z8yZ6GU14vCcvkIBox2D_xd1gSkpMammTQ3tVAEAvoq_wEn_qEbls1Uucgw', }, { kty: 'RSA', - kid: 'kibana_dev', + kid: 'kibana_dev1', use: 'enc', alg: 'RSA-OAEP', e: 'AQAB', n: - 'juVHivsYFznjrDC449oL3xKVTvux_7dEgBGOgJdfzA2R2GspEAOzupT-VkBnqrJnRP_lznM8bQIvvst1f_DNQ1me_Lr9u9cwL5Vq6SWlmw_u9ur_-ewkShU4tBoJDArksOS-ciTaUJoMaxanb7jWexp0pCDlrLrQyAOCnKQL701mD1gdT4rIw7F-jkb5fLUNUVzOGaGyVy6DHAHZx7Tnyw8rswhyRVvuS73imbRp9XcdOFhBDOeSbrSuZGqrVCjoIlWw-UsiW2ueRd8brBoOIHSmTOMIrIMjpPmzMFRKyCvvhnbjrw8j3fQtFII8urhXCVAw8aIHZhiBc5t9ZuwbJw', + 'rEi54h-9hCbqy9Mj_tJmx-dJdtrMmMzkhX5Wd63Pp3dABHpnLJSy--y8QoEa9K9ACaRfExSxgYQ-3K17Yy-UYj3ChAl3hrqZcP2AT3O18Lr2BN7EBjy88lTM0oeck9KLL_iGf8wz8_jeqQFIo3AWrBBuR3VFE0_k-_N1KCenSVm_fE3Nk_ZXm1ByFbgxWUFrYgLfEQn2v0FQYVpfTlbV_awtqoZLYGtuHmaLZhErzJFh6W8zrx8oSpGn8VlVLjF-AR3ugfw2F_HM8ZR8zY1dHVxvoLGz13F5aY8DHn0_ao9t0Yz2Y_SUNviyxMx0eIEJeo2njM2vMzYQNaT1Ghgc-w', }, ], }; diff --git a/src/plugins/vis_type_timelion/public/components/timelion_expression_input.tsx b/src/plugins/vis_type_timelion/public/components/timelion_expression_input.tsx index 2cb244a4d270..d518d9718d5e 100644 --- a/src/plugins/vis_type_timelion/public/components/timelion_expression_input.tsx +++ b/src/plugins/vis_type_timelion/public/components/timelion_expression_input.tsx @@ -115,7 +115,6 @@ function TimelionExpressionInput({ value, setValue }: TimelionExpressionInputPro minimap: { enabled: false, }, - wordBasedSuggestions: false, wordWrap: 'on', wrappingIndent: 'indent', }} diff --git a/src/plugins/visualize/public/application/components/visualize_listing.tsx b/src/plugins/visualize/public/application/components/visualize_listing.tsx index bc766d63db5a..1f1f8c0b5ac8 100644 --- a/src/plugins/visualize/public/application/components/visualize_listing.tsx +++ b/src/plugins/visualize/public/application/components/visualize_listing.tsx @@ -149,6 +149,7 @@ export const VisualizeListing = () => { const calloutMessage = ( <> { @@ -35,11 +37,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await security.testUser.restoreDefaults(); }); - async function prepareTest(fromTime: string, toTime: string, interval: string) { + async function prepareTest(fromTime: string, toTime: string, interval?: string) { await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); await PageObjects.discover.waitUntilSearchingHasFinished(); - await PageObjects.discover.setChartInterval(interval); - await PageObjects.header.waitUntilLoadingHasFinished(); + if (interval) { + await PageObjects.discover.setChartInterval(interval); + await PageObjects.header.waitUntilLoadingHasFinished(); + } } it('should visualize monthly data with different day intervals', async () => { @@ -65,5 +69,43 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const chartIntervalIconTip = await PageObjects.discover.getChartIntervalWarningIcon(); expect(chartIntervalIconTip).to.be(true); }); + it('should allow hide/show histogram, persisted in url state', async () => { + const fromTime = 'Jan 01, 2010 @ 00:00:00.000'; + const toTime = 'Mar 21, 2019 @ 00:00:00.000'; + await prepareTest(fromTime, toTime); + let canvasExists = await elasticChart.canvasExists(); + expect(canvasExists).to.be(true); + await testSubjects.click('discoverChartToggle'); + canvasExists = await elasticChart.canvasExists(); + expect(canvasExists).to.be(false); + // histogram is hidden, when reloading the page it should remain hidden + await browser.refresh(); + canvasExists = await elasticChart.canvasExists(); + expect(canvasExists).to.be(false); + await testSubjects.click('discoverChartToggle'); + await PageObjects.header.waitUntilLoadingHasFinished(); + canvasExists = await elasticChart.canvasExists(); + expect(canvasExists).to.be(true); + }); + it('should allow hiding the histogram, persisted in saved search', async () => { + const fromTime = 'Jan 01, 2010 @ 00:00:00.000'; + const toTime = 'Mar 21, 2019 @ 00:00:00.000'; + const savedSearch = 'persisted hidden histogram'; + await prepareTest(fromTime, toTime); + await testSubjects.click('discoverChartToggle'); + let canvasExists = await elasticChart.canvasExists(); + expect(canvasExists).to.be(false); + await PageObjects.discover.saveSearch(savedSearch); + await PageObjects.header.waitUntilLoadingHasFinished(); + canvasExists = await elasticChart.canvasExists(); + expect(canvasExists).to.be(false); + await testSubjects.click('discoverChartToggle'); + canvasExists = await elasticChart.canvasExists(); + expect(canvasExists).to.be(true); + await PageObjects.discover.clickResetSavedSearchButton(); + await PageObjects.header.waitUntilLoadingHasFinished(); + canvasExists = await elasticChart.canvasExists(); + expect(canvasExists).to.be(false); + }); }); } diff --git a/test/functional/apps/visualize/_add_to_dashboard.ts b/test/functional/apps/visualize/_add_to_dashboard.ts new file mode 100644 index 000000000000..1d1bd62988f4 --- /dev/null +++ b/test/functional/apps/visualize/_add_to_dashboard.ts @@ -0,0 +1,147 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FtrProviderContext } from 'test/functional/ftr_provider_context'; +import expect from '@kbn/expect'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const dashboardExpect = getService('dashboardExpect'); + const testSubjects = getService('testSubjects'); + const listingTable = getService('listingTable'); + + const PageObjects = getPageObjects([ + 'common', + 'dashboard', + 'header', + 'visualize', + 'visEditor', + 'discover', + 'timePicker', + 'timeToVisualize', + ]); + + describe('Add to Dashboard', function describeIndexTests() { + it('adding a new metric to a new dashboard', async function () { + await PageObjects.visualize.navigateToNewAggBasedVisualization(); + await PageObjects.visualize.clickMetric(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + + await testSubjects.click('visualizeSaveButton'); + + await PageObjects.timeToVisualize.saveFromModal('My New Vis 1', { + addToDashboard: 'new', + }); + + await PageObjects.dashboard.waitForRenderComplete(); + await dashboardExpect.metricValuesExist(['14,004']); + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(1); + + await PageObjects.timeToVisualize.resetNewDashboard(); + }); + + it('adding a existing metric to a new dashboard', async function () { + await PageObjects.visualize.navigateToNewAggBasedVisualization(); + await PageObjects.visualize.clickMetric(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + + await testSubjects.click('visualizeSaveButton'); + + // Save this new viz to library + await PageObjects.timeToVisualize.saveFromModal('My New Vis 1', { + addToDashboard: null, + }); + + await testSubjects.click('visualizeSaveButton'); + + // All the options should be disabled + await PageObjects.timeToVisualize.ensureDashboardOptionsAreDisabled(); + + // Save a new copy of this viz to a new dashboard + await PageObjects.timeToVisualize.saveFromModal('My New Vis 1 Copy', { + addToDashboard: 'new', + saveAsNew: true, + }); + + await PageObjects.dashboard.waitForRenderComplete(); + await dashboardExpect.metricValuesExist(['14,004']); + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(1); + + await PageObjects.timeToVisualize.resetNewDashboard(); + }); + + it('adding a new metric to an existing dashboard', async function () { + await PageObjects.common.navigateToApp('dashboard'); + + await PageObjects.dashboard.clickNewDashboard(); + await PageObjects.dashboard.addVisualizations(['Visualization AreaChart']); + await PageObjects.dashboard.saveDashboard('My Wonderful Dashboard'); + await PageObjects.dashboard.gotoDashboardLandingPage(); + await listingTable.searchAndExpectItemsCount('dashboard', 'My Wonderful Dashboard', 1); + + await PageObjects.visualize.navigateToNewAggBasedVisualization(); + await PageObjects.visualize.clickMetric(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + + await testSubjects.click('visualizeSaveButton'); + + await PageObjects.timeToVisualize.saveFromModal('My New Vis 2', { + addToDashboard: 'existing', + dashboardId: 'My Wonderful Dashboard', + }); + + await PageObjects.dashboard.waitForRenderComplete(); + await dashboardExpect.metricValuesExist(['14,004']); + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(2); + }); + + it('adding a existing metric to an existing dashboard', async function () { + await PageObjects.common.navigateToApp('dashboard'); + + await PageObjects.dashboard.clickNewDashboard(); + await PageObjects.dashboard.addVisualizations(['Visualization AreaChart']); + await PageObjects.dashboard.saveDashboard('My Very Cool Dashboard'); + await PageObjects.dashboard.gotoDashboardLandingPage(); + await listingTable.searchAndExpectItemsCount('dashboard', 'My Very Cool Dashboard', 1); + + await PageObjects.visualize.navigateToNewAggBasedVisualization(); + await PageObjects.visualize.clickMetric(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + + await testSubjects.click('visualizeSaveButton'); + + // Save this new viz to library + await PageObjects.timeToVisualize.saveFromModal('My New Vis 2', { + addToDashboard: null, + }); + + await testSubjects.click('visualizeSaveButton'); + + // All the options should be disabled + await PageObjects.timeToVisualize.ensureDashboardOptionsAreDisabled(); + + // Save a new copy of this viz to an existing dashboard + await PageObjects.timeToVisualize.saveFromModal('My New Vis 2 Copy', { + addToDashboard: 'existing', + dashboardId: 'My Very Cool Dashboard', + saveAsNew: true, + }); + + await PageObjects.dashboard.waitForRenderComplete(); + await dashboardExpect.metricValuesExist(['14,004']); + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(2); + }); + }); +} diff --git a/test/functional/apps/visualize/index.ts b/test/functional/apps/visualize/index.ts index 63962136fd4e..0a3632e4aaa8 100644 --- a/test/functional/apps/visualize/index.ts +++ b/test/functional/apps/visualize/index.ts @@ -102,6 +102,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./_lab_mode')); loadTestFile(require.resolve('./_linked_saved_searches')); loadTestFile(require.resolve('./_visualize_listing')); + loadTestFile(require.resolve('./_add_to_dashboard.ts')); if (isOss) { loadTestFile(require.resolve('./_tile_map')); diff --git a/test/functional/page_objects/index.ts b/test/functional/page_objects/index.ts index f57b519ca0b4..413e0aef1444 100644 --- a/test/functional/page_objects/index.ts +++ b/test/functional/page_objects/index.ts @@ -25,6 +25,7 @@ import { VisualizePageProvider } from './visualize_page'; import { VisualizeEditorPageProvider } from './visualize_editor_page'; import { VisualizeChartPageProvider } from './visualize_chart_page'; import { TileMapPageProvider } from './tile_map_page'; +import { TimeToVisualizePageProvider } from './time_to_visualize_page'; import { TagCloudPageProvider } from './tag_cloud_page'; import { VegaChartPageProvider } from './vega_chart_page'; import { SavedObjectsPageProvider } from './management/saved_objects_page'; @@ -51,6 +52,7 @@ export const pageObjects = { visEditor: VisualizeEditorPageProvider, visChart: VisualizeChartPageProvider, tileMap: TileMapPageProvider, + timeToVisualize: TimeToVisualizePageProvider, tagCloud: TagCloudPageProvider, vegaChart: VegaChartPageProvider, savedObjects: SavedObjectsPageProvider, diff --git a/test/functional/page_objects/time_to_visualize_page.ts b/test/functional/page_objects/time_to_visualize_page.ts new file mode 100644 index 000000000000..560f73cbcdbd --- /dev/null +++ b/test/functional/page_objects/time_to_visualize_page.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; + +interface SaveModalArgs { + addToDashboard?: 'new' | 'existing' | null; + dashboardId?: string; + saveAsNew?: boolean; + redirectToOrigin?: boolean; +} + +type DashboardPickerOption = + | 'add-to-library-option' + | 'existing-dashboard-option' + | 'new-dashboard-option'; + +export function TimeToVisualizePageProvider({ getService, getPageObjects }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const log = getService('log'); + const find = getService('find'); + const { common, dashboard } = getPageObjects(['common', 'dashboard']); + + class TimeToVisualizePage { + public async ensureSaveModalIsOpen() { + await testSubjects.exists('savedObjectSaveModal', { timeout: 5000 }); + } + + public async ensureDashboardOptionsAreDisabled() { + const dashboardSelector = await testSubjects.find('add-to-dashboard-options'); + await dashboardSelector.findByCssSelector(`input[id="new-dashboard-option"]:disabled`); + await dashboardSelector.findByCssSelector(`input[id="existing-dashboard-option"]:disabled`); + await dashboardSelector.findByCssSelector(`input[id="add-to-library-option"]:disabled`); + } + + public async resetNewDashboard() { + await common.navigateToApp('dashboard'); + await dashboard.gotoDashboardLandingPage(true); + await dashboard.clickNewDashboard(false); + } + + public async setSaveModalValues( + vizName: string, + { saveAsNew, redirectToOrigin, addToDashboard, dashboardId }: SaveModalArgs = {} + ) { + await testSubjects.setValue('savedObjectTitle', vizName); + + const hasSaveAsNew = await testSubjects.exists('saveAsNewCheckbox'); + if (hasSaveAsNew && saveAsNew !== undefined) { + const state = saveAsNew ? 'check' : 'uncheck'; + log.debug('save as new checkbox exists. Setting its state to', state); + await testSubjects.setEuiSwitch('saveAsNewCheckbox', state); + } + + const hasRedirectToOrigin = await testSubjects.exists('returnToOriginModeSwitch'); + if (hasRedirectToOrigin && redirectToOrigin !== undefined) { + const state = redirectToOrigin ? 'check' : 'uncheck'; + log.debug('redirect to origin checkbox exists. Setting its state to', state); + await testSubjects.setEuiSwitch('returnToOriginModeSwitch', state); + } + + const hasDashboardSelector = await testSubjects.exists('add-to-dashboard-options'); + if (hasDashboardSelector && addToDashboard !== undefined) { + let option: DashboardPickerOption = 'add-to-library-option'; + if (addToDashboard) { + option = dashboardId ? 'existing-dashboard-option' : 'new-dashboard-option'; + } + log.debug('save modal dashboard selector, choosing option:', option); + const dashboardSelector = await testSubjects.find('add-to-dashboard-options'); + const label = await dashboardSelector.findByCssSelector(`label[for="${option}"]`); + await label.click(); + + if (dashboardId) { + await testSubjects.setValue('dashboardPickerInput', dashboardId); + await find.clickByButtonText(dashboardId); + } + } + } + + public async saveFromModal( + vizName: string, + saveModalArgs: SaveModalArgs = { addToDashboard: null } + ) { + await this.ensureSaveModalIsOpen(); + + await this.setSaveModalValues(vizName, saveModalArgs); + log.debug('Click Save Visualization button'); + + await testSubjects.click('confirmSaveSavedObjectButton'); + + await common.waitForSaveModalToClose(); + } + } + + return new TimeToVisualizePage(); +} diff --git a/test/functional/page_objects/visualize_page.ts b/test/functional/page_objects/visualize_page.ts index c8d04e3f78b8..1ccea8690543 100644 --- a/test/functional/page_objects/visualize_page.ts +++ b/test/functional/page_objects/visualize_page.ts @@ -9,6 +9,7 @@ import { FtrProviderContext } from '../ftr_provider_context'; import { VisualizeConstants } from '../../../src/plugins/visualize/public/application/visualize_constants'; +// TODO: Remove & Refactor to use the TTV page objects interface VisualizeSaveModalArgs { saveAsNew?: boolean; redirectToOrigin?: boolean; diff --git a/test/functional/services/inspector.ts b/test/functional/services/inspector.ts index fc4f20504da5..4dc248116ccf 100644 --- a/test/functional/services/inspector.ts +++ b/test/functional/services/inspector.ts @@ -241,7 +241,7 @@ export function InspectorProvider({ getService }: FtrProviderContext) { await retry.try(async () => { request = await browser.execute( - () => (window as any).monaco.editor.getModels()[0].getValue() as string + () => (window as any).MonacoEnvironment.monaco.editor.getModels()[0].getValue() as string ); }); diff --git a/test/scripts/test/jest_unit.sh b/test/scripts/test/jest_unit.sh index 1442a0f72872..fd1166b07f32 100755 --- a/test/scripts/test/jest_unit.sh +++ b/test/scripts/test/jest_unit.sh @@ -2,7 +2,5 @@ source src/dev/ci_setup/setup_env.sh -export NODE_OPTIONS="--max-old-space-size=2048" - checks-reporter-with-killswitch "Jest Unit Tests" \ node scripts/jest --ci --verbose --maxWorkers=8 diff --git a/tsconfig.refs.json b/tsconfig.refs.json index d5482a85856f..4105f23fd5b3 100644 --- a/tsconfig.refs.json +++ b/tsconfig.refs.json @@ -87,6 +87,7 @@ { "path": "./x-pack/plugins/maps/tsconfig.json" }, { "path": "./x-pack/plugins/ml/tsconfig.json" }, { "path": "./x-pack/plugins/observability/tsconfig.json" }, + { "path": "./x-pack/plugins/osquery/tsconfig.json" }, { "path": "./x-pack/plugins/painless_lab/tsconfig.json" }, { "path": "./x-pack/plugins/reporting/tsconfig.json" }, { "path": "./x-pack/plugins/saved_objects_tagging/tsconfig.json" }, diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/fixtures/es_archiver/apm_8.0.0/mappings.json b/x-pack/plugins/apm/ftr_e2e/cypress/fixtures/es_archiver/apm_8.0.0/mappings.json index 13bfec74269b..9f84f4885c4c 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/fixtures/es_archiver/apm_8.0.0/mappings.json +++ b/x-pack/plugins/apm/ftr_e2e/cypress/fixtures/es_archiver/apm_8.0.0/mappings.json @@ -31,7 +31,7 @@ "core-usage-stats": "3d1b76c39bfb2cc8296b024d73854724", "dashboard": "40554caf09725935e2c02e02563a2d07", "endpoint:user-artifact": "4a11183eee21e6fbad864f7a30b39ad0", - "endpoint:user-artifact-manifest": "4b9c0e7cfaf86d82a7ee9ed68065e50d", + "endpoint:user-artifact-manifest": "a0d7b04ad405eed54d76e279c3727862", "enterprise_search_telemetry": "3d1b76c39bfb2cc8296b024d73854724", "epm-packages": "2b83397e3eaaaa8ef15e38813f3721c3", "exception-list": "67f055ab8c10abd7b2ebfd969b836788", @@ -818,16 +818,25 @@ "index": false, "type": "date" }, - "ids": { - "index": false, - "type": "keyword" - }, "schemaVersion": { "type": "keyword" }, "semanticVersion": { "index": false, "type": "keyword" + }, + "artifacts": { + "type": "nested", + "properties": { + "policyId": { + "type": "keyword", + "index": false + }, + "artifactId": { + "type": "keyword", + "index": false + } + } } } }, @@ -22352,4 +22361,4 @@ } } } -} \ No newline at end of file +} diff --git a/x-pack/plugins/beats_management/common/constants/index.ts b/x-pack/plugins/beats_management/common/constants/index.ts index a94c3614ae7a..ac4f89b639c2 100644 --- a/x-pack/plugins/beats_management/common/constants/index.ts +++ b/x-pack/plugins/beats_management/common/constants/index.ts @@ -7,7 +7,7 @@ export { UNIQUENESS_ENFORCING_TYPES } from './configuration_blocks'; export { INDEX_NAMES } from './index_names'; -export { PLUGIN } from './plugin'; +export { PLUGIN, MANAGEMENT_SECTION } from './plugin'; export { LICENSES, REQUIRED_LICENSES, REQUIRED_ROLES } from './security'; export { TABLE_CONFIG } from './table'; export const BASE_PATH = '/management/ingest/beats_management'; diff --git a/x-pack/plugins/beats_management/common/constants/plugin.ts b/x-pack/plugins/beats_management/common/constants/plugin.ts index 87b600b975fe..912bc75b98f6 100644 --- a/x-pack/plugins/beats_management/common/constants/plugin.ts +++ b/x-pack/plugins/beats_management/common/constants/plugin.ts @@ -9,3 +9,4 @@ export const PLUGIN = { ID: 'beats_management', }; export const CONFIG_PREFIX = 'xpack.beats'; +export const MANAGEMENT_SECTION = 'beats_management'; diff --git a/x-pack/plugins/beats_management/public/bootstrap.tsx b/x-pack/plugins/beats_management/public/bootstrap.tsx index 04d3eada6112..4a4d3a893286 100644 --- a/x-pack/plugins/beats_management/public/bootstrap.tsx +++ b/x-pack/plugins/beats_management/public/bootstrap.tsx @@ -15,11 +15,18 @@ import { CoreSetup } from '../../../../src/core/public'; import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; import { LicensingPluginSetup } from '../../licensing/public'; import { BeatsManagementConfigType } from '../common'; +import { MANAGEMENT_SECTION } from '../common/constants'; async function startApp(libs: FrontendLibs, core: CoreSetup) { - await libs.framework.waitUntilFrameworkReady(); + const [startServices] = await Promise.all([ + core.getStartServices(), + libs.framework.waitUntilFrameworkReady(), + ]); - if (libs.framework.licenseIsAtLeast('standard')) { + const capabilities = startServices[0].application.capabilities; + const hasBeatsCapability = capabilities.management.ingest?.[MANAGEMENT_SECTION] ?? false; + + if (libs.framework.licenseIsAtLeast('standard') && hasBeatsCapability) { const mount = async (params: any) => { const [coreStart, pluginsStart] = await core.getStartServices(); setServices(coreStart, pluginsStart, params); diff --git a/x-pack/plugins/beats_management/public/lib/adapters/framework/kibana_framework_adapter.ts b/x-pack/plugins/beats_management/public/lib/adapters/framework/kibana_framework_adapter.ts index f86f9c5eb8c7..03a9a7760849 100644 --- a/x-pack/plugins/beats_management/public/lib/adapters/framework/kibana_framework_adapter.ts +++ b/x-pack/plugins/beats_management/public/lib/adapters/framework/kibana_framework_adapter.ts @@ -11,6 +11,7 @@ import { PathReporter } from 'io-ts/lib/PathReporter'; import { isLeft } from 'fp-ts/lib/Either'; import { first } from 'rxjs/operators'; import { i18n } from '@kbn/i18n'; +import { MANAGEMENT_SECTION } from '../../../../common/constants'; import { SecurityPluginSetup } from '../../../../../security/public'; import { BufferedKibanaServiceCall, KibanaAdapterServiceRefs, KibanaUIConfig } from '../../types'; import { @@ -107,7 +108,7 @@ export class KibanaFrameworkAdapter implements FrameworkAdapter { public registerManagementUI(mount: RegisterManagementAppArgs['mount']) { const section = this.management.sections.section.ingest; section.registerApp({ - id: 'beats_management', + id: MANAGEMENT_SECTION, title: i18n.translate('xpack.beatsManagement.centralManagementLinkLabel', { defaultMessage: 'Beats Central Management', }), diff --git a/x-pack/plugins/canvas/public/application.tsx b/x-pack/plugins/canvas/public/application.tsx index 0e5300eeb1b0..66b02bdc1640 100644 --- a/x-pack/plugins/canvas/public/application.tsx +++ b/x-pack/plugins/canvas/public/application.tsx @@ -13,6 +13,8 @@ import { i18n } from '@kbn/i18n'; import { Provider } from 'react-redux'; import { BehaviorSubject } from 'rxjs'; +import { includes, remove } from 'lodash'; + import { AppMountParameters, CoreStart, CoreSetup, AppUpdater } from 'kibana/public'; import { CanvasStartDeps, CanvasSetupDeps } from './plugin'; @@ -39,6 +41,11 @@ import { initFunctions } from './functions'; // @ts-expect-error untyped local import { appUnload } from './state/actions/app'; +// @ts-expect-error Not going to convert +import { size } from '../canvas_plugin_src/renderers/plot/plugins/size'; +// @ts-expect-error Not going to convert +import { text } from '../canvas_plugin_src/renderers/plot/plugins/text'; + import './style/index.scss'; const { ReadOnlyBadge: strings } = CapabilitiesStrings; @@ -147,6 +154,17 @@ export const initializeCanvas = async ( export const teardownCanvas = (coreStart: CoreStart, startPlugins: CanvasStartDeps) => { destroyRegistries(); + // Canvas pollutes the jQuery plot plugins collection with custom plugins that only work in Canvas. + // Remove them when Canvas is unmounted. + // see: ../canvas_plugin_src/renderers/plot/plugins/index.ts + if (includes($.plot.plugins, size)) { + remove($.plot.plugins, size); + } + + if (includes($.plot.plugins, text)) { + remove($.plot.plugins, text); + } + // TODO: Not cleaning these up temporarily. // We have an issue where if requests are inflight, and you navigate away, // those requests could still be trying to act on the store and possibly require services. diff --git a/x-pack/plugins/canvas/public/components/expression_input/expression_input.tsx b/x-pack/plugins/canvas/public/components/expression_input/expression_input.tsx index 27d1f76c2bf8..53ba7996eb64 100644 --- a/x-pack/plugins/canvas/public/components/expression_input/expression_input.tsx +++ b/x-pack/plugins/canvas/public/components/expression_input/expression_input.tsx @@ -324,7 +324,6 @@ export class ExpressionInput extends React.Component { minimap: { enabled: false, }, - wordBasedSuggestions: false, wordWrap: 'on', wrappingIndent: 'indent', }} diff --git a/x-pack/plugins/code/kibana.json b/x-pack/plugins/code/kibana.json deleted file mode 100644 index 815bc147d3cf..000000000000 --- a/x-pack/plugins/code/kibana.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "id": "code", - "version": "8.0.0", - "kibanaVersion": "kibana", - "configPath": ["xpack", "code"], - "server": true, - "ui": false -} diff --git a/x-pack/plugins/code/server/index.ts b/x-pack/plugins/code/server/index.ts deleted file mode 100644 index ccea83ca1ff9..000000000000 --- a/x-pack/plugins/code/server/index.ts +++ /dev/null @@ -1,14 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { PluginInitializerContext } from 'src/core/server'; -import { CodeConfigSchema } from './config'; -import { CodePlugin } from './plugin'; - -export const config = { schema: CodeConfigSchema }; -export const plugin = (initializerContext: PluginInitializerContext) => - new CodePlugin(initializerContext); diff --git a/x-pack/plugins/code/server/plugin.test.ts b/x-pack/plugins/code/server/plugin.test.ts deleted file mode 100644 index 512658ca4da8..000000000000 --- a/x-pack/plugins/code/server/plugin.test.ts +++ /dev/null @@ -1,39 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { coreMock } from '../../../../src/core/server/mocks'; - -import { CodePlugin } from './plugin'; - -describe('Code Plugin', () => { - describe('setup()', () => { - it('does not log deprecation message if no xpack.code.* configurations are set', async () => { - const context = coreMock.createPluginInitializerContext(); - const plugin = new CodePlugin(context); - - await plugin.setup(); - - expect(context.logger.get).not.toHaveBeenCalled(); - }); - - it('logs deprecation message if any xpack.code.* configurations are set', async () => { - const context = coreMock.createPluginInitializerContext({ - foo: 'bar', - }); - const warn = jest.fn(); - context.logger.get = jest.fn().mockReturnValue({ warn }); - const plugin = new CodePlugin(context); - - await plugin.setup(); - - expect(context.logger.get).toHaveBeenCalledWith('config', 'deprecation'); - expect(warn.mock.calls[0][0]).toMatchInlineSnapshot( - `"The experimental app \\"Code\\" has been removed from Kibana. Remove all xpack.code.* configurations from kibana.yml so Kibana does not fail to start up in the next major version."` - ); - }); - }); -}); diff --git a/x-pack/plugins/code/server/plugin.ts b/x-pack/plugins/code/server/plugin.ts deleted file mode 100644 index eb7481d12387..000000000000 --- a/x-pack/plugins/code/server/plugin.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { TypeOf } from '@kbn/config-schema'; -import { PluginInitializerContext, Plugin } from 'src/core/server'; -import { CodeConfigSchema } from './config'; - -/** - * Represents Code Plugin instance that will be managed by the Kibana plugin system. - */ -export class CodePlugin implements Plugin { - constructor(private readonly initializerContext: PluginInitializerContext) {} - - public async setup() { - const config = this.initializerContext.config.get>(); - - if (config && Object.keys(config).length > 0) { - this.initializerContext.logger - .get('config', 'deprecation') - .warn( - 'The experimental app "Code" has been removed from Kibana. Remove all xpack.code.* ' + - 'configurations from kibana.yml so Kibana does not fail to start up in the next major version.' - ); - } - } - - public start() {} - - public stop() {} -} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/constants.ts index 3655c60bde3b..211995b2a7d1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/constants.ts @@ -11,3 +11,29 @@ export const RELEVANCE_TUNING_TITLE = i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.title', { defaultMessage: 'Relevance Tuning' } ); + +export const UPDATE_SUCCESS_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.relevanceTuning.messages.updateSuccess', + { + defaultMessage: 'Relevance successfully tuned. The changes will impact your results shortly.', + } +); +export const DELETE_SUCCESS_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.relevanceTuning.messages.deleteSuccess', + { + defaultMessage: + 'Relevance has been reset to default values. The change will impact your results shortly.', + } +); +export const RESET_CONFIRMATION_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.relevanceTuning.messages.resetConfirmation', + { + defaultMessage: 'Are you sure you want to restore relevance defaults?', + } +); +export const DELETE_CONFIRMATION_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.relevanceTuning.messages.deleteConfirmation', + { + defaultMessage: 'Are you sure you want to delete this boost?', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.test.ts index 7f7bce1b7ba9..194848bcfc86 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.test.ts @@ -5,12 +5,18 @@ * 2.0. */ -import { LogicMounter } from '../../../__mocks__'; +import { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../../__mocks__'; -import { BoostType } from './types'; +import { nextTick } from '@kbn/test/jest'; + +import { Boost, BoostType } from './types'; import { RelevanceTuningLogic } from './'; +jest.mock('../engine', () => ({ + EngineLogic: { values: { engineName: 'test-engine' } }, +})); + describe('RelevanceTuningLogic', () => { const { mount } = new LogicMounter(RelevanceTuningLogic); @@ -32,13 +38,27 @@ describe('RelevanceTuningLogic', () => { schema, schemaConflicts, }; - const searchResults = [{}, {}]; + const searchResults = [ + { + id: { + raw: '1', + }, + _meta: { + id: '1', + score: 100, + engine: 'my-engine', + }, + }, + ]; const DEFAULT_VALUES = { dataLoading: true, schema: {}, schemaConflicts: {}, - searchSettings: {}, + searchSettings: { + boosts: {}, + search_fields: {}, + }, unsavedChanges: false, filterInputValue: '', query: '', @@ -188,6 +208,873 @@ describe('RelevanceTuningLogic', () => { }); }); }); + + describe('setSearchSettingsResponse', () => { + it('should set searchSettings state and unsavedChanges to false', () => { + mount({ + unsavedChanges: true, + }); + RelevanceTuningLogic.actions.setSearchSettingsResponse(searchSettings); + + expect(RelevanceTuningLogic.values).toEqual({ + ...DEFAULT_VALUES, + searchSettings, + unsavedChanges: false, + }); + }); + }); + }); + + describe('listeners', () => { + const { http } = mockHttpValues; + const { flashAPIErrors, setSuccessMessage } = mockFlashMessageHelpers; + let scrollToSpy: jest.SpyInstance; + let confirmSpy: jest.SpyInstance; + + const searchSettingsWithBoost = (boost: Boost) => ({ + ...searchSettings, + boosts: { + foo: [ + { + factor: 1, + type: 'functional', + }, + boost, + ], + }, + }); + + beforeAll(() => { + scrollToSpy = jest.spyOn(window, 'scrollTo').mockImplementation(() => true); + confirmSpy = jest.spyOn(window, 'confirm'); + }); + + afterAll(() => { + scrollToSpy.mockRestore(); + confirmSpy.mockRestore(); + }); + + describe('initializeRelevanceTuning', () => { + it('should make an API call and set state based on the normalized response', async () => { + mount(); + http.get.mockReturnValueOnce( + Promise.resolve({ + ...relevanceTuningProps, + searchSettings: { + ...relevanceTuningProps.searchSettings, + boosts: { + foo: [ + { + type: 'value' as BoostType, + factor: 5, + value: 5, + }, + ], + }, + }, + }) + ); + jest.spyOn(RelevanceTuningLogic.actions, 'onInitializeRelevanceTuning'); + + RelevanceTuningLogic.actions.initializeRelevanceTuning(); + await nextTick(); + + expect(http.get).toHaveBeenCalledWith( + '/api/app_search/engines/test-engine/search_settings/details' + ); + expect(RelevanceTuningLogic.actions.onInitializeRelevanceTuning).toHaveBeenCalledWith({ + ...relevanceTuningProps, + searchSettings: { + ...relevanceTuningProps.searchSettings, + boosts: { + foo: [ + { + type: 'value' as BoostType, + factor: 5, + value: ['5'], + }, + ], + }, + }, + }); + }); + + it('handles errors', async () => { + mount(); + http.get.mockReturnValueOnce(Promise.reject('error')); + + RelevanceTuningLogic.actions.initializeRelevanceTuning(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + }); + + describe('getSearchResults', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('should make an API call and set state based on the response', async () => { + const searchSettingsWithNewBoostProp = { + boosts: { + foo: [ + { + type: 'value' as BoostType, + factor: 5, + newBoost: true, // This should be deleted before sent to the server + }, + ], + }, + }; + + const searchSettingsWithoutNewBoostProp = { + boosts: { + foo: [ + { + type: 'value' as BoostType, + factor: 5, + }, + ], + }, + }; + + mount({ + query: 'foo', + searchSettings: searchSettingsWithNewBoostProp, + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchResults'); + jest.spyOn(RelevanceTuningLogic.actions, 'setResultsLoading'); + http.post.mockReturnValueOnce( + Promise.resolve({ + results: searchResults, + }) + ); + + RelevanceTuningLogic.actions.getSearchResults(); + jest.runAllTimers(); + await nextTick(); + + expect(RelevanceTuningLogic.actions.setResultsLoading).toHaveBeenCalledWith(true); + expect(http.post).toHaveBeenCalledWith( + '/api/app_search/engines/test-engine/search_settings_search', + { + body: JSON.stringify(searchSettingsWithoutNewBoostProp), + query: { + query: 'foo', + }, + } + ); + expect(RelevanceTuningLogic.actions.setSearchResults).toHaveBeenCalledWith(searchResults); + }); + + it("won't send boosts or search_fields on the API call if there are none", async () => { + mount({ + query: 'foo', + searchSettings: { + searchField: {}, + boosts: {}, + }, + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchResults'); + http.post.mockReturnValueOnce( + Promise.resolve({ + results: searchResults, + }) + ); + + RelevanceTuningLogic.actions.getSearchResults(); + + jest.runAllTimers(); + await nextTick(); + + expect(http.post).toHaveBeenCalledWith( + '/api/app_search/engines/test-engine/search_settings_search', + { + body: '{}', + query: { + query: 'foo', + }, + } + ); + }); + + it('will call clearSearchResults if there is no query', async () => { + mount({ + query: '', + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchResults'); + jest.spyOn(RelevanceTuningLogic.actions, 'setResultsLoading'); + jest.spyOn(RelevanceTuningLogic.actions, 'clearSearchResults'); + + RelevanceTuningLogic.actions.getSearchResults(); + jest.runAllTimers(); + await nextTick(); + + expect(RelevanceTuningLogic.actions.clearSearchResults).toHaveBeenCalled(); + expect(RelevanceTuningLogic.actions.setSearchResults).not.toHaveBeenCalled(); + expect(RelevanceTuningLogic.actions.setResultsLoading).not.toHaveBeenCalled(); + }); + + it('handles errors', async () => { + mount({ + query: 'foo', + }); + http.post.mockReturnValueOnce(Promise.reject('error')); + RelevanceTuningLogic.actions.getSearchResults(); + + jest.runAllTimers(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + }); + + describe('setSearchSettings', () => { + it('updates search results whenever search settings are changed', () => { + mount(); + jest.spyOn(RelevanceTuningLogic.actions, 'getSearchResults'); + + RelevanceTuningLogic.actions.setSearchSettings(searchSettings); + + expect(RelevanceTuningLogic.actions.getSearchResults).toHaveBeenCalled(); + }); + }); + + describe('onSearchSettingsSuccess', () => { + it('should save the response, trigger a new search, and then scroll to the top', () => { + mount(); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettingsResponse'); + jest.spyOn(RelevanceTuningLogic.actions, 'getSearchResults'); + + RelevanceTuningLogic.actions.onSearchSettingsSuccess(searchSettings); + + expect(RelevanceTuningLogic.actions.setSearchSettingsResponse).toHaveBeenCalledWith( + searchSettings + ); + expect(RelevanceTuningLogic.actions.getSearchResults).toHaveBeenCalled(); + expect(scrollToSpy).toHaveBeenCalledWith(0, 0); + }); + }); + + describe('onSearchSettingsError', () => { + it('scrolls to the top', () => { + mount(); + RelevanceTuningLogic.actions.onSearchSettingsError(); + expect(scrollToSpy).toHaveBeenCalledWith(0, 0); + }); + }); + + describe('updateSearchSettings', () => { + it('calls an API endpoint and handles success response', async () => { + const searchSettingsWithNewBoostProp = { + boosts: { + foo: [ + { + type: 'value' as BoostType, + factor: 5, + newBoost: true, // This should be deleted before sent to the server + }, + ], + }, + }; + + const searchSettingsWithoutNewBoostProp = { + boosts: { + foo: [ + { + type: 'value' as BoostType, + factor: 5, + }, + ], + }, + }; + mount({ + searchSettings: searchSettingsWithNewBoostProp, + }); + jest.spyOn(RelevanceTuningLogic.actions, 'onSearchSettingsSuccess'); + http.put.mockReturnValueOnce(Promise.resolve(searchSettingsWithoutNewBoostProp)); + + RelevanceTuningLogic.actions.updateSearchSettings(); + await nextTick(); + + expect(http.put).toHaveBeenCalledWith( + '/api/app_search/engines/test-engine/search_settings', + { + body: JSON.stringify(searchSettingsWithoutNewBoostProp), + } + ); + expect(setSuccessMessage).toHaveBeenCalledWith( + 'Relevance successfully tuned. The changes will impact your results shortly.' + ); + expect(RelevanceTuningLogic.actions.onSearchSettingsSuccess).toHaveBeenCalledWith( + searchSettingsWithoutNewBoostProp + ); + }); + + it('handles errors', async () => { + mount(); + jest.spyOn(RelevanceTuningLogic.actions, 'onSearchSettingsError'); + http.put.mockReturnValueOnce(Promise.reject('error')); + + RelevanceTuningLogic.actions.updateSearchSettings(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + expect(RelevanceTuningLogic.actions.onSearchSettingsError).toHaveBeenCalled(); + }); + }); + + describe('resetSearchSettings', () => { + it('calls and API endpoint, shows a success message, and saves the response', async () => { + mount(); + jest.spyOn(RelevanceTuningLogic.actions, 'onSearchSettingsSuccess'); + confirmSpy.mockImplementation(() => true); + http.post.mockReturnValueOnce(Promise.resolve(searchSettings)); + + RelevanceTuningLogic.actions.resetSearchSettings(); + await nextTick(); + + expect(http.post).toHaveBeenCalledWith( + '/api/app_search/engines/test-engine/search_settings/reset' + ); + expect(setSuccessMessage).toHaveBeenCalledWith( + 'Relevance has been reset to default values. The change will impact your results shortly.' + ); + expect(RelevanceTuningLogic.actions.onSearchSettingsSuccess).toHaveBeenCalledWith( + searchSettings + ); + }); + + it('does nothing if the user does not confirm', async () => { + mount(); + confirmSpy.mockImplementation(() => false); + + RelevanceTuningLogic.actions.resetSearchSettings(); + + expect(http.post).not.toHaveBeenCalledWith( + '/api/app_search/engines/test-engine/search_settings/reset' + ); + }); + + it('handles errors', async () => { + mount(); + jest.spyOn(RelevanceTuningLogic.actions, 'onSearchSettingsError'); + confirmSpy.mockImplementation(() => true); + http.post.mockReturnValueOnce(Promise.reject('error')); + + RelevanceTuningLogic.actions.resetSearchSettings(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + expect(RelevanceTuningLogic.actions.onSearchSettingsError).toHaveBeenCalled(); + }); + }); + + describe('toggleSearchField', () => { + it('updates search weight to 1 in search fields when enabling', () => { + mount({ + searchSettings: { + ...searchSettings, + search_fields: { + bar: { + weight: 1, + }, + }, + }, + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.toggleSearchField('foo', false); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith({ + ...searchSettings, + search_fields: { + bar: { + weight: 1, + }, + foo: { + weight: 1, + }, + }, + }); + }); + + it('removes fields from search fields when disabling', () => { + mount({ + searchSettings: { + ...searchSettings, + search_fields: { + bar: { + weight: 1, + }, + }, + }, + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.toggleSearchField('bar', true); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith({ + ...searchSettings, + }); + }); + }); + + describe('updateFieldWeight', () => { + it('updates the search weight in search fields', () => { + mount({ + searchSettings, + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.updateFieldWeight('foo', 3); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith({ + ...searchSettings, + search_fields: { + foo: { + weight: 3, + }, + }, + }); + }); + + it('will round decimal numbers', () => { + mount({ + searchSettings, + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.updateFieldWeight('foo', 3.9393939); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith({ + ...searchSettings, + search_fields: { + foo: { + weight: 3.9, + }, + }, + }); + }); + }); + + describe('addBoost', () => { + it('adds a boost of given type for the given field', () => { + mount({ + searchSettings: { + ...searchSettings, + boosts: { + foo: [ + { + factor: 2, + type: 'value', + }, + ], + }, + }, + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.addBoost('foo', 'functional'); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith({ + ...searchSettings, + boosts: { + foo: [ + { + factor: 2, + type: 'value', + }, + { + factor: 1, + newBoost: true, + type: 'functional', + }, + ], + }, + }); + }); + + it('works even if there are no boosts yet', () => { + mount({ + searchSettings: { + ...searchSettings, + boosts: {}, + }, + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.addBoost('foo', 'functional'); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith({ + ...searchSettings, + boosts: { + foo: [ + { + factor: 1, + newBoost: true, + type: 'functional', + }, + ], + }, + }); + }); + }); + + describe('deleteBoost', () => { + it('deletes the boost with the given name and index', () => { + mount({ + searchSettings: { + ...searchSettings, + boosts: { + foo: [ + { + factor: 1, + type: 'functional', + }, + { + factor: 2, + type: 'value', + }, + ], + }, + }, + }); + confirmSpy.mockImplementation(() => true); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.deleteBoost('foo', 1); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith({ + ...searchSettings, + boosts: { + foo: [ + { + factor: 1, + type: 'functional', + }, + ], + }, + }); + }); + + it('will delete they field key in boosts if this is the last boost or that field', () => { + mount({ + searchSettings: { + ...searchSettings, + boosts: { + foo: [ + { + factor: 1, + type: 'functional', + }, + ], + }, + }, + }); + confirmSpy.mockImplementation(() => true); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.deleteBoost('foo', 0); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith({ + ...searchSettings, + boosts: {}, + }); + }); + + it('will do nothing if the user does not confirm', () => { + mount({ + searchSettings: { + ...searchSettings, + boosts: { + foo: [ + { + factor: 1, + type: 'functional', + }, + ], + }, + }, + }); + confirmSpy.mockImplementation(() => false); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.deleteBoost('foo', 0); + + expect(RelevanceTuningLogic.actions.setSearchSettings).not.toHaveBeenCalled(); + }); + }); + + describe('updateBoostFactor', () => { + it('updates the boost factor of the target boost', () => { + mount({ + searchSettings: searchSettingsWithBoost({ + factor: 1, + type: 'functional', + }), + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.updateBoostFactor('foo', 1, 5); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith( + searchSettingsWithBoost({ + factor: 5, + type: 'functional', + }) + ); + }); + + it('will round decimal numbers', () => { + mount({ + searchSettings: searchSettingsWithBoost({ + factor: 1, + type: 'functional', + }), + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.updateBoostFactor('foo', 1, 5.293191); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith( + searchSettingsWithBoost({ + factor: 5.3, + type: 'functional', + }) + ); + }); + }); + + describe('updateBoostValue', () => { + it('will update the boost value and update search reuslts', () => { + mount({ + searchSettings: searchSettingsWithBoost({ + factor: 1, + type: 'functional', + value: ['a', 'b', 'c'], + }), + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.updateBoostValue('foo', 1, 1, 'a'); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith( + searchSettingsWithBoost({ + factor: 1, + type: 'functional', + value: ['a', 'a', 'c'], + }) + ); + }); + + it('will create a new array if no array exists yet for value', () => { + mount({ + searchSettings: searchSettingsWithBoost({ + factor: 1, + type: 'functional', + }), + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.updateBoostValue('foo', 1, 0, 'a'); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith( + searchSettingsWithBoost({ + factor: 1, + type: 'functional', + value: ['a'], + }) + ); + }); + }); + + describe('updateBoostCenter', () => { + it('will parse the provided provided value and set the center to that parsed value', () => { + mount({ + schema: { + foo: 'number', + }, + searchSettings: searchSettingsWithBoost({ + factor: 1, + type: 'proximity', + center: 1, + }), + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.updateBoostCenter('foo', 1, '4'); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith( + searchSettingsWithBoost({ + factor: 1, + type: 'proximity', + center: 4, + }) + ); + }); + }); + + describe('addBoostValue', () => { + it('will add an empty boost value', () => { + mount({ + searchSettings: searchSettingsWithBoost({ + factor: 1, + type: 'functional', + value: ['a'], + }), + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.addBoostValue('foo', 1); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith( + searchSettingsWithBoost({ + factor: 1, + type: 'functional', + value: ['a', ''], + }) + ); + }); + + it('will add two empty boost values if none exist yet', () => { + mount({ + searchSettings: searchSettingsWithBoost({ + factor: 1, + type: 'functional', + }), + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.addBoostValue('foo', 1); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith( + searchSettingsWithBoost({ + factor: 1, + type: 'functional', + value: ['', ''], + }) + ); + }); + + it('will still work if the boost index is out of range', () => { + mount({ + searchSettings: searchSettingsWithBoost({ + factor: 1, + type: 'functional', + value: ['a', ''], + }), + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.addBoostValue('foo', 10); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith( + searchSettingsWithBoost({ + factor: 1, + type: 'functional', + value: ['a', ''], + }) + ); + }); + }); + + describe('removeBoostValue', () => { + it('will remove a boost value', () => { + mount({ + searchSettings: searchSettingsWithBoost({ + factor: 1, + type: 'functional', + value: ['a', 'b', 'c'], + }), + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.removeBoostValue('foo', 1, 1); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith( + searchSettingsWithBoost({ + factor: 1, + type: 'functional', + value: ['a', 'c'], + }) + ); + }); + + it('will do nothing if boost values do not exist', () => { + mount({ + searchSettings: searchSettingsWithBoost({ + factor: 1, + type: 'functional', + }), + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.removeBoostValue('foo', 1, 1); + + expect(RelevanceTuningLogic.actions.setSearchSettings).not.toHaveBeenCalled(); + }); + }); + + describe('updateBoostSelectOption', () => { + it('will update the boost', () => { + mount({ + searchSettings: searchSettingsWithBoost({ + factor: 1, + type: 'functional', + }), + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.updateBoostSelectOption('foo', 1, 'function', 'exponential'); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith( + searchSettingsWithBoost({ + factor: 1, + type: 'functional', + function: 'exponential', + }) + ); + }); + + it('can also update operation', () => { + mount({ + searchSettings: searchSettingsWithBoost({ + factor: 1, + type: 'functional', + }), + }); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings'); + + RelevanceTuningLogic.actions.updateBoostSelectOption('foo', 1, 'operation', 'add'); + + expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith( + searchSettingsWithBoost({ + factor: 1, + type: 'functional', + operation: 'add', + }) + ); + }); + }); + + describe('updateSearchValue', () => { + it('should update the query then update search results', () => { + mount(); + jest.spyOn(RelevanceTuningLogic.actions, 'setSearchQuery'); + jest.spyOn(RelevanceTuningLogic.actions, 'getSearchResults'); + + RelevanceTuningLogic.actions.updateSearchValue('foo'); + + expect(RelevanceTuningLogic.actions.setSearchQuery).toHaveBeenCalledWith('foo'); + expect(RelevanceTuningLogic.actions.getSearchResults).toHaveBeenCalled(); + }); + }); }); describe('selectors', () => { @@ -253,24 +1140,6 @@ describe('RelevanceTuningLogic', () => { }); expect(RelevanceTuningLogic.values.filteredSchemaFields).toEqual(['bar', 'baz']); }); - - it('should return all schema fields if there is no filter applied', () => { - mount({ - filterTerm: '', - schema: { - id: 'string', - foo: 'string', - bar: 'string', - baz: 'string', - }, - }); - expect(RelevanceTuningLogic.values.filteredSchemaFields).toEqual([ - 'id', - 'foo', - 'bar', - 'baz', - ]); - }); }); describe('filteredSchemaFieldsWithConflicts', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.ts index d4ec5e37f6ce..cd3d8b5686cc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.ts @@ -6,10 +6,28 @@ */ import { kea, MakeLogicType } from 'kea'; +import { omit, cloneDeep, isEmpty } from 'lodash'; +import { setSuccessMessage, flashAPIErrors } from '../../../shared/flash_messages'; +import { HttpLogic } from '../../../shared/http'; import { Schema, SchemaConflicts } from '../../../shared/types'; -import { SearchSettings } from './types'; +import { EngineLogic } from '../engine'; +import { Result } from '../result/types'; + +import { + UPDATE_SUCCESS_MESSAGE, + RESET_CONFIRMATION_MESSAGE, + DELETE_SUCCESS_MESSAGE, + DELETE_CONFIRMATION_MESSAGE, +} from './constants'; +import { BaseBoost, Boost, BoostType, SearchSettings } from './types'; +import { + filterIfTerm, + parseBoostCenter, + removeBoostStateProps, + normalizeBoostValues, +} from './utils'; interface RelevanceTuningProps { searchSettings: SearchSettings; @@ -22,15 +40,60 @@ interface RelevanceTuningActions { setSearchSettings(searchSettings: SearchSettings): { searchSettings: SearchSettings }; setFilterValue(value: string): string; setSearchQuery(value: string): string; - setSearchResults(searchResults: object[]): object[]; + setSearchResults(searchResults: Result[]): Result[]; setResultsLoading(resultsLoading: boolean): boolean; clearSearchResults(): void; resetSearchSettingsState(): void; dismissSchemaConflictCallout(): void; + initializeRelevanceTuning(): void; + getSearchResults(): void; + setSearchSettingsResponse(searchSettings: SearchSettings): { searchSettings: SearchSettings }; + onSearchSettingsSuccess(searchSettings: SearchSettings): { searchSettings: SearchSettings }; + onSearchSettingsError(): void; + updateSearchSettings(): void; + resetSearchSettings(): void; + toggleSearchField(name: string, disableField: boolean): { name: string; disableField: boolean }; + updateFieldWeight(name: string, weight: number): { name: string; weight: number }; + addBoost(name: string, type: BoostType): { name: string; type: BoostType }; + deleteBoost(name: string, index: number): { name: string; index: number }; + updateBoostFactor( + name: string, + index: number, + factor: number + ): { name: string; index: number; factor: number }; + updateBoostValue( + name: string, + boostIndex: number, + valueIndex: number, + value: string + ): { name: string; boostIndex: number; valueIndex: number; value: string }; + updateBoostCenter( + name: string, + boostIndex: number, + value: string | number + ): { name: string; boostIndex: number; value: string | number }; + addBoostValue(name: string, boostIndex: number): { name: string; boostIndex: number }; + removeBoostValue( + name: string, + boostIndex: number, + valueIndex: number + ): { name: string; boostIndex: number; valueIndex: number }; + updateBoostSelectOption( + name: string, + boostIndex: number, + optionType: keyof BaseBoost, + value: string + ): { + name: string; + boostIndex: number; + optionType: keyof BaseBoost; + value: string; + }; + updateSearchValue(query: string): string; } interface RelevanceTuningValues { - searchSettings: Partial; + searchSettings: SearchSettings; schema: Schema; schemaFields: string[]; schemaFieldsWithConflicts: string[]; @@ -43,15 +106,10 @@ interface RelevanceTuningValues { query: string; unsavedChanges: boolean; dataLoading: boolean; - searchResults: object[] | null; + searchResults: Result[] | null; resultsLoading: boolean; } -// If the user hasn't entered a filter, then we can skip filtering the array entirely -const filterIfTerm = (array: string[], filterTerm: string): string[] => { - return filterTerm === '' ? array : array.filter((item) => item.includes(filterTerm)); -}; - export const RelevanceTuningLogic = kea< MakeLogicType >({ @@ -66,13 +124,47 @@ export const RelevanceTuningLogic = kea< clearSearchResults: true, resetSearchSettingsState: true, dismissSchemaConflictCallout: true, + initializeRelevanceTuning: true, + getSearchResults: true, + setSearchSettingsResponse: (searchSettings) => ({ + searchSettings, + }), + onSearchSettingsSuccess: (searchSettings) => ({ searchSettings }), + onSearchSettingsError: () => true, + updateSearchSettings: true, + resetSearchSettings: true, + toggleSearchField: (name, disableField) => ({ name, disableField }), + updateFieldWeight: (name, weight) => ({ name, weight }), + addBoost: (name, type) => ({ name, type }), + deleteBoost: (name, index) => ({ name, index }), + updateBoostFactor: (name, index, factor) => ({ name, index, factor }), + updateBoostValue: (name, boostIndex, valueIndex, value) => ({ + name, + boostIndex, + valueIndex, + value, + }), + updateBoostCenter: (name, boostIndex, value) => ({ name, boostIndex, value }), + addBoostValue: (name, boostIndex) => ({ name, boostIndex }), + removeBoostValue: (name, boostIndex, valueIndex) => ({ name, boostIndex, valueIndex }), + updateBoostSelectOption: (name, boostIndex, optionType, value) => ({ + name, + boostIndex, + optionType, + value, + }), + updateSearchValue: (query) => query, }), reducers: () => ({ searchSettings: [ - {}, + { + search_fields: {}, + boosts: {}, + }, { onInitializeRelevanceTuning: (_, { searchSettings }) => searchSettings, setSearchSettings: (_, { searchSettings }) => searchSettings, + setSearchSettingsResponse: (_, { searchSettings }) => searchSettings, }, ], schema: [ @@ -109,6 +201,7 @@ export const RelevanceTuningLogic = kea< false, { setSearchSettings: () => true, + setSearchSettingsResponse: () => false, }, ], @@ -155,4 +248,268 @@ export const RelevanceTuningLogic = kea< (schema: Schema): boolean => Object.keys(schema).length >= 2, ], }), + listeners: ({ actions, values }) => ({ + initializeRelevanceTuning: async () => { + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + const url = `/api/app_search/engines/${engineName}/search_settings/details`; + + try { + const response = await http.get(url); + actions.onInitializeRelevanceTuning({ + ...response, + searchSettings: { + ...response.searchSettings, + boosts: normalizeBoostValues(response.searchSettings.boosts), + }, + }); + } catch (e) { + flashAPIErrors(e); + } + }, + getSearchResults: async (_, breakpoint) => { + await breakpoint(250); + + const query = values.query; + if (!query) return actions.clearSearchResults(); + + const { engineName } = EngineLogic.values; + const { http } = HttpLogic.values; + const { search_fields: searchFields, boosts } = removeBoostStateProps(values.searchSettings); + const url = `/api/app_search/engines/${engineName}/search_settings_search`; + + actions.setResultsLoading(true); + + try { + const response = await http.post(url, { + query: { + query, + }, + body: JSON.stringify({ + boosts: isEmpty(boosts) ? undefined : boosts, + search_fields: isEmpty(searchFields) ? undefined : searchFields, + }), + }); + + actions.setSearchResults(response.results); + } catch (e) { + flashAPIErrors(e); + } + }, + setSearchSettings: () => { + actions.getSearchResults(); + }, + onSearchSettingsSuccess: ({ searchSettings }) => { + actions.setSearchSettingsResponse(searchSettings); + actions.getSearchResults(); + window.scrollTo(0, 0); + }, + onSearchSettingsError: () => { + window.scrollTo(0, 0); + }, + updateSearchSettings: async () => { + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + const url = `/api/app_search/engines/${engineName}/search_settings`; + + try { + const response = await http.put(url, { + body: JSON.stringify(removeBoostStateProps(values.searchSettings)), + }); + setSuccessMessage(UPDATE_SUCCESS_MESSAGE); + actions.onSearchSettingsSuccess(response); + } catch (e) { + flashAPIErrors(e); + actions.onSearchSettingsError(); + } + }, + resetSearchSettings: async () => { + if (window.confirm(RESET_CONFIRMATION_MESSAGE)) { + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + const url = `/api/app_search/engines/${engineName}/search_settings/reset`; + + try { + const response = await http.post(url); + setSuccessMessage(DELETE_SUCCESS_MESSAGE); + actions.onSearchSettingsSuccess(response); + } catch (e) { + flashAPIErrors(e); + actions.onSearchSettingsError(); + } + } + }, + toggleSearchField: ({ name, disableField }) => { + const { searchSettings } = values; + + const searchFields = disableField + ? omit(searchSettings.search_fields, name) + : { ...searchSettings.search_fields, [name]: { weight: 1 } }; + + actions.setSearchSettings({ + ...searchSettings, + search_fields: searchFields, + }); + }, + updateFieldWeight: ({ name, weight }) => { + const { searchSettings } = values; + const { search_fields: searchFields } = searchSettings; + + actions.setSearchSettings({ + ...searchSettings, + search_fields: { + ...searchFields, + [name]: { + ...searchFields[name], + weight: Math.round(weight * 10) / 10, + }, + }, + }); + }, + addBoost: ({ name, type }) => { + const { searchSettings } = values; + const { boosts } = searchSettings; + const emptyBoost = { type, factor: 1, newBoost: true }; + let boostArray; + + if (Array.isArray(boosts[name])) { + boostArray = boosts[name].slice(); + boostArray.push(emptyBoost); + } else { + boostArray = [emptyBoost]; + } + + actions.setSearchSettings({ + ...searchSettings, + boosts: { + ...boosts, + [name]: boostArray, + }, + }); + }, + deleteBoost: ({ name, index }) => { + if (window.confirm(DELETE_CONFIRMATION_MESSAGE)) { + const { searchSettings } = values; + const { boosts } = searchSettings; + const boostsRemoved = boosts[name].slice(); + boostsRemoved.splice(index, 1); + const updatedBoosts = { ...boosts }; + + if (boostsRemoved.length > 0) { + updatedBoosts[name] = boostsRemoved; + } else { + delete updatedBoosts[name]; + } + + actions.setSearchSettings({ + ...searchSettings, + boosts: updatedBoosts, + }); + } + }, + updateBoostFactor: ({ name, index, factor }) => { + const { searchSettings } = values; + const { boosts } = searchSettings; + const updatedBoosts = cloneDeep(boosts[name]); + updatedBoosts[index].factor = Math.round(factor * 10) / 10; + + actions.setSearchSettings({ + ...searchSettings, + boosts: { + ...boosts, + [name]: updatedBoosts, + }, + }); + }, + updateBoostValue: ({ name, boostIndex, valueIndex, value }) => { + const { searchSettings } = values; + const { boosts } = searchSettings; + const updatedBoosts: Boost[] = cloneDeep(boosts[name]); + const existingValue = updatedBoosts[boostIndex].value; + if (existingValue === undefined) { + updatedBoosts[boostIndex].value = [value]; + } else { + existingValue[valueIndex] = value; + } + + actions.setSearchSettings({ + ...searchSettings, + boosts: { + ...boosts, + [name]: updatedBoosts, + }, + }); + }, + updateBoostCenter: ({ name, boostIndex, value }) => { + const { searchSettings } = values; + const { boosts } = searchSettings; + const updatedBoosts = cloneDeep(boosts[name]); + const fieldType = values.schema[name]; + updatedBoosts[boostIndex].center = parseBoostCenter(fieldType, value); + + actions.setSearchSettings({ + ...searchSettings, + boosts: { + ...boosts, + [name]: updatedBoosts, + }, + }); + }, + addBoostValue: ({ name, boostIndex }) => { + const { searchSettings } = values; + const { boosts } = searchSettings; + const updatedBoosts = cloneDeep(boosts[name]); + const updatedBoost = updatedBoosts[boostIndex]; + if (updatedBoost) { + updatedBoost.value = Array.isArray(updatedBoost.value) ? updatedBoost.value : ['']; + updatedBoost.value.push(''); + } + + actions.setSearchSettings({ + ...searchSettings, + boosts: { + ...boosts, + [name]: updatedBoosts, + }, + }); + }, + removeBoostValue: ({ name, boostIndex, valueIndex }) => { + const { searchSettings } = values; + const { boosts } = searchSettings; + const updatedBoosts = cloneDeep(boosts[name]); + const boostValue = updatedBoosts[boostIndex].value; + + if (boostValue === undefined) return; + + boostValue.splice(valueIndex, 1); + actions.setSearchSettings({ + ...searchSettings, + boosts: { + ...boosts, + [name]: updatedBoosts, + }, + }); + }, + updateBoostSelectOption: ({ name, boostIndex, optionType, value }) => { + const { searchSettings } = values; + const { boosts } = searchSettings; + const updatedBoosts = cloneDeep(boosts[name]); + updatedBoosts[boostIndex][optionType] = value; + + actions.setSearchSettings({ + ...searchSettings, + boosts: { + ...boosts, + [name]: updatedBoosts, + }, + }); + }, + updateSearchValue: (query) => { + actions.setSearchQuery(query); + actions.getSearchResults(); + }, + }), }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/types.ts index 25187df89d64..a1ed9797b9f5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/types.ts @@ -7,17 +7,31 @@ export type BoostType = 'value' | 'functional' | 'proximity'; -export interface Boost { - type: BoostType; +export interface BaseBoost { operation?: string; function?: string; +} + +// A boost that comes from the server, before we normalize it has a much looser schema +export interface RawBoost extends BaseBoost { + type: BoostType; newBoost?: boolean; center?: string | number; - value?: string | number | string[] | number[]; + value?: string | number | boolean | object | Array; factor: number; } +// We normalize raw boosts to make them safer and easier to work with +export interface Boost extends RawBoost { + value?: string[]; +} export interface SearchSettings { boosts: Record; - search_fields: object; + search_fields: Record< + string, + { + weight: number; + } + >; + result_fields?: object; } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/utils.test.ts new file mode 100644 index 000000000000..a6598bf991c1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/utils.test.ts @@ -0,0 +1,147 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { BoostType } from './types'; +import { + filterIfTerm, + normalizeBoostValues, + removeBoostStateProps, + parseBoostCenter, +} from './utils'; + +describe('filterIfTerm', () => { + it('will filter a list of strings to a list of strings containing the specified string', () => { + expect(filterIfTerm(['jalepeno', 'no', 'not', 'panorama', 'truck'], 'no')).toEqual([ + 'jalepeno', + 'no', + 'not', + 'panorama', + ]); + }); + + it('will not filter at all if an empty string is provided', () => { + expect(filterIfTerm(['jalepeno', 'no', 'not', 'panorama', 'truck'], '')).toEqual([ + 'jalepeno', + 'no', + 'not', + 'panorama', + 'truck', + ]); + }); +}); + +describe('removeBoostStateProps', () => { + it('will remove the newBoost flag from boosts within the provided searchSettings object', () => { + const searchSettings = { + boosts: { + foo: [ + { + type: 'value' as BoostType, + factor: 5, + newBoost: true, + }, + ], + }, + search_fields: { + foo: { + weight: 1, + }, + }, + }; + expect(removeBoostStateProps(searchSettings)).toEqual({ + ...searchSettings, + boosts: { + foo: [ + { + type: 'value' as BoostType, + factor: 5, + }, + ], + }, + }); + }); +}); + +describe('parseBoostCenter', () => { + it('should parse a boost center', () => { + expect(parseBoostCenter('text', 5)).toEqual(5); + expect(parseBoostCenter('text', '4')).toEqual('4'); + expect(parseBoostCenter('number', 5)).toEqual(5); + expect(parseBoostCenter('number', '5')).toEqual(5); + }); +}); + +describe('normalizeBoostValues', () => { + const boosts = { + foo: [ + { + type: 'value' as BoostType, + factor: 9.5, + value: 1, + }, + { + type: 'value' as BoostType, + factor: 9.5, + value: '1', + }, + { + type: 'value' as BoostType, + factor: 9.5, + value: [1], + }, + { + type: 'value' as BoostType, + factor: 9.5, + value: ['1'], + }, + { + type: 'value' as BoostType, + factor: 9.5, + value: [ + '1', + 1, + '2', + 2, + true, + { + b: 'a', + }, + {}, + ], + }, + ], + bar: [ + { + type: 'proximity' as BoostType, + factor: 9.5, + }, + ], + sp_def: [ + { + type: 'functional' as BoostType, + factor: 5, + }, + ], + }; + + it('converts all value types to string for consistency', () => { + expect(normalizeBoostValues(boosts)).toEqual({ + bar: [{ factor: 9.5, type: 'proximity' }], + foo: [ + { factor: 9.5, type: 'value', value: ['1'] }, + { factor: 9.5, type: 'value', value: ['1'] }, + { factor: 9.5, type: 'value', value: ['1'] }, + { factor: 9.5, type: 'value', value: ['1'] }, + { + factor: 9.5, + type: 'value', + value: ['1', '1', '2', '2', 'true', '[object Object]', '[object Object]'], + }, + ], + sp_def: [{ type: 'functional', factor: 5 }], + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/utils.ts new file mode 100644 index 000000000000..e2fd0f0bbd65 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/utils.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { cloneDeep, omit } from 'lodash'; + +import { NUMBER } from '../../../shared/constants/field_types'; +import { SchemaTypes } from '../../../shared/types'; + +import { RawBoost, Boost, SearchSettings, BoostType } from './types'; + +// If the user hasn't entered a filter, then we can skip filtering the array entirely +export const filterIfTerm = (array: string[], filterTerm: string): string[] => { + return filterTerm === '' ? array : array.filter((item) => item.includes(filterTerm)); +}; + +export const removeBoostStateProps = (searchSettings: SearchSettings) => { + const updatedSettings = cloneDeep(searchSettings); + const { boosts } = updatedSettings; + const keys = Object.keys(boosts); + keys.forEach((key) => boosts[key].forEach((boost) => delete boost.newBoost)); + + return updatedSettings; +}; + +export const parseBoostCenter = (fieldType: SchemaTypes, value: string | number) => { + // Leave non-numeric fields alone + if (fieldType === NUMBER) { + const floatValue = parseFloat(value as string); + return isNaN(floatValue) ? value : floatValue; + } + return value; +}; + +const toArray = (v: T | T[]): T[] => (Array.isArray(v) ? v : [v]); +const toString = (v1: T) => String(v1); + +const normalizeBoostValue = (boost: RawBoost): Boost => { + if (!boost.hasOwnProperty('value')) { + // Can't simply do `return boost` here as TS can't infer the correct type + return omit(boost, 'value'); + } + + return { + ...boost, + type: boost.type as BoostType, + value: toArray(boost.value).map(toString), + }; +}; + +// Data may have been set to invalid types directly via the public App Search API. Since these are invalid, we don't want to deal +// with them as valid types in the UI. For that reason, we stringify all values here, as the data comes in. +// Additionally, values can be in single values or in arrays. +export const normalizeBoostValues = (boosts: Record): Record => + Object.entries(boosts).reduce((newBoosts, [fieldName, boostList]) => { + return { + ...newBoosts, + [fieldName]: boostList.map(normalizeBoostValue), + }; + }, {}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts index af650d95efaf..28850531ebb9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts @@ -35,7 +35,12 @@ import { SchemaLogic, dataTypeOptions } from './schema_logic'; describe('SchemaLogic', () => { const { http } = mockHttpValues; - const { clearFlashMessages, flashAPIErrors, setSuccessMessage } = mockFlashMessageHelpers; + const { + clearFlashMessages, + flashAPIErrors, + setSuccessMessage, + setErrorMessage, + } = mockFlashMessageHelpers; const { mount } = new LogicMounter(SchemaLogic); const defaultValues = { @@ -298,10 +303,7 @@ describe('SchemaLogic', () => { ); await nextTick(); - expect(flashAPIErrors).toHaveBeenCalledWith({ - error: 'this is an error', - message: SCHEMA_FIELD_ERRORS_ERROR_MESSAGE, - }); + expect(setErrorMessage).toHaveBeenCalledWith(SCHEMA_FIELD_ERRORS_ERROR_MESSAGE); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.ts index 9906efe707d8..10b7f85a631b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.ts @@ -13,6 +13,7 @@ import { ADD, UPDATE } from '../../../../../shared/constants/operations'; import { flashAPIErrors, setSuccessMessage, + setErrorMessage, clearFlashMessages, } from '../../../../../shared/flash_messages'; import { HttpLogic } from '../../../../../shared/http'; @@ -295,7 +296,7 @@ export const SchemaLogic = kea>({ fieldCoercionErrors: response.fieldCoercionErrors, }); } catch (e) { - flashAPIErrors({ ...e, message: SCHEMA_FIELD_ERRORS_ERROR_MESSAGE }); + setErrorMessage(SCHEMA_FIELD_ERRORS_ERROR_MESSAGE); } }, addNewField: ({ fieldName, newFieldType }) => { diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/header.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/header.tsx index 1b580a528fe0..d12311cf16d3 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/header.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/header.tsx @@ -37,7 +37,7 @@ export interface HeaderProps { leftColumn?: JSX.Element; rightColumn?: JSX.Element; rightColumnGrow?: EuiFlexItemProps['grow']; - tabs?: EuiTabProps[]; + tabs?: Array & { name?: JSX.Element | string }>; tabsClassName?: string; 'data-test-subj'?: string; } @@ -73,7 +73,7 @@ export const Header: React.FC = ({ {tabs.map((props) => ( - + {props.name} ))} diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/home_integration/tutorial_module_notice.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/home_integration/tutorial_module_notice.tsx index 069b34e13c9a..88b6933fe477 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/home_integration/tutorial_module_notice.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/home_integration/tutorial_module_notice.tsx @@ -43,7 +43,7 @@ const TutorialModuleNotice: TutorialModuleNoticeComponent = memo(({ moduleName } ), availableAsIntegrationLink: ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/constants/page_paths.ts b/x-pack/plugins/fleet/public/applications/fleet/constants/page_paths.ts index da0e40270053..bcb450d5ec94 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/constants/page_paths.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/constants/page_paths.ts @@ -18,7 +18,10 @@ export type StaticPage = | 'data_streams'; export type DynamicPage = - | 'integration_details' + | 'integration_details_overview' + | 'integration_details_policies' + | 'integration_details_settings' + | 'integration_details_custom' | 'integration_policy_edit' | 'policy_details' | 'add_integration_from_policy' @@ -43,6 +46,10 @@ export const PAGE_ROUTING_PATHS = { integrations_all: '/integrations', integrations_installed: '/integrations/installed', integration_details: '/integrations/detail/:pkgkey/:panel?', + integration_details_overview: '/integrations/detail/:pkgkey/overview', + integration_details_policies: '/integrations/detail/:pkgkey/policies', + integration_details_settings: '/integrations/detail/:pkgkey/settings', + integration_details_custom: '/integrations/detail/:pkgkey/custom', integration_policy_edit: '/integrations/edit-integration/:packagePolicyId', policies: '/policies', policies_list: '/policies', @@ -70,8 +77,10 @@ export const pagePathGetters: { integrations: () => '/integrations', integrations_all: () => '/integrations', integrations_installed: () => '/integrations/installed', - integration_details: ({ pkgkey, panel }) => - `/integrations/detail/${pkgkey}${panel ? `/${panel}` : ''}`, + integration_details_overview: ({ pkgkey }) => `/integrations/detail/${pkgkey}/overview`, + integration_details_policies: ({ pkgkey }) => `/integrations/detail/${pkgkey}/policies`, + integration_details_settings: ({ pkgkey }) => `/integrations/detail/${pkgkey}/settings`, + integration_details_custom: ({ pkgkey }) => `/integrations/detail/${pkgkey}/custom`, integration_policy_edit: ({ packagePolicyId }) => `/integrations/edit-integration/${packagePolicyId}`, policies: () => '/policies', diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx index aa869cd076e7..22dfe2e8be51 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx @@ -18,7 +18,7 @@ const BASE_BREADCRUMB: ChromeBreadcrumb = { }; const breadcrumbGetters: { - [key in Page]: (values: DynamicPagePathValues) => ChromeBreadcrumb[]; + [key in Page]?: (values: DynamicPagePathValues) => ChromeBreadcrumb[]; } = { base: () => [BASE_BREADCRUMB], overview: () => [ @@ -65,7 +65,7 @@ const breadcrumbGetters: { }), }, ], - integration_details: ({ pkgTitle }) => [ + integration_details_overview: ({ pkgTitle }) => [ BASE_BREADCRUMB, { href: pagePathGetters.integrations(), @@ -84,7 +84,7 @@ const breadcrumbGetters: { }), }, { - href: pagePathGetters.integration_details({ pkgkey, panel: 'policies' }), + href: pagePathGetters.integration_details_policies({ pkgkey }), text: pkgTitle, }, { text: policyName }, @@ -142,7 +142,7 @@ const breadcrumbGetters: { }), }, { - href: pagePathGetters.integration_details({ pkgkey }), + href: pagePathGetters.integration_details_overview({ pkgkey }), text: pkgTitle, }, { @@ -221,10 +221,11 @@ const breadcrumbGetters: { export function useBreadcrumbs(page: Page, values: DynamicPagePathValues = {}) { const { chrome, http } = useStartServices(); - const breadcrumbs: ChromeBreadcrumb[] = breadcrumbGetters[page](values).map((breadcrumb) => ({ - ...breadcrumb, - href: breadcrumb.href ? http.basePath.prepend(`${BASE_PATH}#${breadcrumb.href}`) : undefined, - })); + const breadcrumbs: ChromeBreadcrumb[] = + breadcrumbGetters[page]?.(values).map((breadcrumb) => ({ + ...breadcrumb, + href: breadcrumb.href ? http.basePath.prepend(`${BASE_PATH}#${breadcrumb.href}`) : undefined, + })) || []; const docTitle: string[] = [...breadcrumbs] .reverse() .map((breadcrumb) => breadcrumb.text as string); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx index a8a11c583535..a07601210042 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx @@ -217,7 +217,7 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { } return from === 'policy' ? getHref('policy_details', { policyId: agentPolicyId || policyId }) - : getHref('integration_details', { pkgkey }); + : getHref('integration_details_overview', { pkgkey }); }, [agentPolicyId, policyId, from, getHref, pkgkey, routeState]); const cancelClickHandler: ReactEventHandler = useCallback( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx index 94133ecdef33..b0f2232cf506 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx @@ -246,9 +246,8 @@ export const EditPackagePolicyForm = memo<{ const cancelUrl = useMemo((): string => { if (packageInfo && policyId) { return from === 'package-edit' - ? getHref('integration_details', { + ? getHref('integration_details_policies', { pkgkey: pkgKeyFromPackageInfo(packageInfo!), - panel: 'policies', }) : getHref('policy_details', { policyId }); } @@ -258,9 +257,8 @@ export const EditPackagePolicyForm = memo<{ const successRedirectPath = useMemo(() => { if (packageInfo && policyId) { return from === 'package-edit' - ? getPath('integration_details', { + ? getPath('integration_details_policies', { pkgkey: pkgKeyFromPackageInfo(packageInfo!), - panel: 'policies', }) : getPath('policy_details', { policyId }); } diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/components/package_card.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/components/package_card.tsx index 9c1a2d2fad99..cac8ff7d5e7a 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/components/package_card.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/components/package_card.tsx @@ -43,7 +43,7 @@ export function PackageCard({ title={title || ''} description={description} icon={} - href={getHref('integration_details', { pkgkey: `${name}-${urlVersion}` })} + href={getHref('integration_details_overview', { pkgkey: `${name}-${urlVersion}` })} betaBadgeLabel={release && release !== 'ga' ? RELEASE_BADGE_LABEL[release] : undefined} betaBadgeTooltipContent={ release && release !== 'ga' ? RELEASE_BADGE_DESCRIPTION[release] : undefined diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/hooks/use_package_install.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/hooks/use_package_install.tsx index e5e7f9f81fd1..d3fccb600173 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/hooks/use_package_install.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/hooks/use_package_install.tsx @@ -90,9 +90,8 @@ function usePackageInstall({ notifications }: { notifications: NotificationsStar } else { setPackageInstallStatus({ name, status: InstallStatus.installed, version }); if (fromUpdate) { - const settingsPath = getPath('integration_details', { + const settingsPath = getPath('integration_details_settings', { pkgkey: `${name}-${version}`, - panel: 'settings', }); history.push(settingsPath); } diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/components/icon_panel.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/components/icon_panel.tsx new file mode 100644 index 000000000000..b2495b607af5 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/components/icon_panel.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import styled from 'styled-components'; +import { EuiIcon, EuiPanel } from '@elastic/eui'; +import { usePackageIconType, UsePackageIconType } from '../../../../../hooks'; +import { Loading } from '../../../../../components'; + +const PanelWrapper = styled.div` + // NOTE: changes to the width here will impact navigation tabs page layout under integration package details + width: ${(props) => + parseFloat(props.theme.eui.euiSize) * 6 + parseFloat(props.theme.eui.euiSizeXL) * 2}px; + height: 1px; + z-index: 1; +`; + +const Panel = styled(EuiPanel)` + padding: ${(props) => props.theme.eui.spacerSizes.xl}; + margin-bottom: -100%; + svg, + img { + height: ${(props) => parseFloat(props.theme.eui.euiSize) * 6}px; + width: ${(props) => parseFloat(props.theme.eui.euiSize) * 6}px; + } + .euiFlexItem { + height: ${(props) => parseFloat(props.theme.eui.euiSize) * 6}px; + justify-content: center; + } +`; + +export function IconPanel({ + packageName, + version, + icons, +}: Pick) { + const iconType = usePackageIconType({ packageName, version, icons }); + + return ( + + + + + + ); +} + +export function LoadingIconPanel() { + return ( + + + + + + ); +} diff --git a/x-pack/plugins/code/server/config.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/components/index.tsx similarity index 56% rename from x-pack/plugins/code/server/config.ts rename to x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/components/index.tsx index 2cc3e78c0b96..8424fecad08c 100644 --- a/x-pack/plugins/code/server/config.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/components/index.tsx @@ -4,11 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { schema } from '@kbn/config-schema'; - -const createCodeConfigSchema = () => { - return schema.any({ defaultValue: {} }); -}; - -export const CodeConfigSchema = createCodeConfigSchema(); +export { UpdateIcon } from './update_icon'; +export { IntegrationAgentPolicyCount } from './integration_agent_policy_count'; +export { IconPanel, LoadingIconPanel } from './icon_panel'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/integration_agent_policy_count.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/components/integration_agent_policy_count.tsx similarity index 90% rename from x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/integration_agent_policy_count.tsx rename to x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/components/integration_agent_policy_count.tsx index 27d0a19aba5c..eeb74526046e 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/integration_agent_policy_count.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/components/integration_agent_policy_count.tsx @@ -6,7 +6,7 @@ */ import React, { memo } from 'react'; -import { useGetPackageStats } from '../../../../hooks'; +import { useGetPackageStats } from '../../../../../hooks'; /** * Displays a count of Agent Policies that are using the given integration diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/components/update_icon.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/components/update_icon.tsx new file mode 100644 index 000000000000..d7ad6667b6db --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/components/update_icon.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiIconTip, EuiIconProps } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export const UpdateIcon = ({ size = 'm' }: { size?: EuiIconProps['size'] }) => ( + +); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/content.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/content.tsx deleted file mode 100644 index e6c5a1278949..000000000000 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/content.tsx +++ /dev/null @@ -1,114 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import React, { memo, useMemo } from 'react'; -import styled from 'styled-components'; -import { Redirect } from 'react-router-dom'; -import { DetailParams } from '.'; -import { DetailViewPanelName, PackageInfo } from '../../../../types'; -import { AssetsFacetGroup } from '../../components/assets_facet_group'; -import { CenterColumn, LeftColumn, RightColumn } from './layout'; -import { OverviewPanel } from './overview_panel'; -import { PackagePoliciesPanel } from './package_policies_panel'; -import { SettingsPanel } from './settings_panel'; -import { useUIExtension } from '../../../../hooks/use_ui_extension'; -import { ExtensionWrapper } from '../../../../components/extension_wrapper'; -import { useLink } from '../../../../hooks'; -import { pkgKeyFromPackageInfo } from '../../../../services/pkg_key_from_package_info'; - -type ContentProps = PackageInfo & Pick; - -const LeftSideColumn = styled(LeftColumn)` - /* 🤢🤷 https://www.styled-components.com/docs/faqs#how-can-i-override-styles-with-higher-specificity */ - &&& { - margin-top: 77px; - } -`; - -// fixes IE11 problem with nested flex items -const ContentFlexGroup = styled(EuiFlexGroup)` - flex: 0 0 auto !important; -`; - -export function Content(props: ContentProps) { - const { panel } = props; - const showRightColumn = useMemo(() => { - const fullWidthContentPages: DetailViewPanelName[] = ['policies', 'custom']; - return !fullWidthContentPages.includes(panel!); - }, [panel]); - - return ( - - - - - - {showRightColumn && ( - - - - )} - - ); -} - -interface ContentPanelProps { - packageInfo: PackageInfo; - panel: DetailViewPanelName; -} -export const ContentPanel = memo(({ panel, packageInfo }) => { - const { name, version, assets, title, removable, latestVersion } = packageInfo; - const pkgkey = pkgKeyFromPackageInfo(packageInfo); - - const CustomView = useUIExtension(name, 'package-detail-custom'); - const { getPath } = useLink(); - - switch (panel) { - case 'settings': - return ( - - ); - case 'policies': - return ; - case 'custom': - return CustomView ? ( - - - - ) : ( - - ); - case 'overview': - default: - return ; - } -}); - -type RightColumnContentProps = PackageInfo & Pick; -function RightColumnContent(props: RightColumnContentProps) { - const { assets, panel } = props; - switch (panel) { - case 'overview': - return assets ? ( - - - - - - ) : null; - default: - return ; - } -} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/content_collapse.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/content_collapse.tsx deleted file mode 100644 index c6b578dd5364..000000000000 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/content_collapse.tsx +++ /dev/null @@ -1,98 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiButton, EuiButtonEmpty, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; -import React, { Fragment, useCallback, useLayoutEffect, useRef, useState } from 'react'; -import styled from 'styled-components'; - -const BottomFade = styled.div` - width: 100%; - background: ${(props) => - `linear-gradient(${props.theme.eui.euiColorEmptyShade}00 0%, ${props.theme.eui.euiColorEmptyShade} 100%)`}; - margin-top: -${(props) => parseInt(props.theme.eui.spacerSizes.xl, 10) * 2}px; - height: ${(props) => parseInt(props.theme.eui.spacerSizes.xl, 10) * 2}px; - position: absolute; -`; -const ContentCollapseContainer = styled.div` - position: relative; -`; -const CollapseButtonContainer = styled.div` - display: inline-block; - background-color: ${(props) => props.theme.eui.euiColorEmptyShade}; - position: absolute; - left: 50%; - transform: translateX(-50%); - top: ${(props) => parseInt(props.theme.eui.euiButtonHeight, 10) / 2}px; -`; -const CollapseButtonTop = styled(EuiButtonEmpty)` - float: right; -`; - -const CollapseButton = ({ - open, - toggleCollapse, -}: { - open: boolean; - toggleCollapse: () => void; -}) => { - return ( -
- - - - - {open ? 'Collapse' : 'Read more'} - - -
- ); -}; - -export const ContentCollapse = ({ children }: { children: React.ReactNode }) => { - const [open, setOpen] = useState(false); - const [height, setHeight] = useState('auto'); - const [collapsible, setCollapsible] = useState(true); - const contentEl = useRef(null); - const collapsedHeight = 360; - - // if content is too small, don't collapse - useLayoutEffect( - () => - contentEl.current && contentEl.current.clientHeight < collapsedHeight - ? setCollapsible(false) - : setHeight(collapsedHeight), - [] - ); - - const clickOpen = useCallback(() => { - setOpen(!open); - }, [open]); - - return ( - - {collapsible ? ( - -
- {open && ( - - Collapse - - )} - {children} -
- {!open && } - -
- ) : ( -
{children}
- )} -
- ); -}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/custom/custom.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/custom/custom.tsx new file mode 100644 index 000000000000..f005c1e24dee --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/custom/custom.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { memo, useMemo } from 'react'; +import { Redirect } from 'react-router-dom'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { useUIExtension } from '../../../../../hooks/use_ui_extension'; +import { useLink } from '../../../../../hooks'; +import { PackageInfo } from '../../../../../types'; +import { pkgKeyFromPackageInfo } from '../../../../../services/pkg_key_from_package_info'; +import { ExtensionWrapper } from '../../../../../components/extension_wrapper'; + +interface Props { + packageInfo: PackageInfo; +} + +export const CustomViewPage: React.FC = memo(({ packageInfo }) => { + const CustomView = useUIExtension(packageInfo.name, 'package-detail-custom'); + const { getPath } = useLink(); + const pkgkey = useMemo(() => pkgKeyFromPackageInfo(packageInfo), [packageInfo]); + + return CustomView ? ( + + + + + + + + + ) : ( + + ); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/custom/index.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/custom/index.ts new file mode 100644 index 000000000000..bc480b1daa35 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/custom/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +export { CustomViewPage } from './custom'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.test.tsx index b60d3b5eb1f2..32e39d7c4d6e 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.test.tsx @@ -28,7 +28,7 @@ import { act, cleanup } from '@testing-library/react'; describe('when on integration detail', () => { const pkgkey = 'nginx-0.3.7'; - const detailPageUrlPath = pagePathGetters.integration_details({ pkgkey }); + const detailPageUrlPath = pagePathGetters.integration_details_overview({ pkgkey }); let testRenderer: TestRenderer; let renderResult: ReturnType; let mockedApi: MockedApi; @@ -100,7 +100,7 @@ describe('when on integration detail', () => { it('should redirect if custom url is accessed', () => { act(() => { testRenderer.history.push( - pagePathGetters.integration_details({ pkgkey: 'nginx-0.3.7', panel: 'custom' }) + pagePathGetters.integration_details_custom({ pkgkey: 'nginx-0.3.7' }) ); }); expect(testRenderer.history.location.pathname).toEqual(detailPageUrlPath); @@ -148,7 +148,7 @@ describe('when on integration detail', () => { it('should display custom content when tab is clicked', async () => { act(() => { testRenderer.history.push( - pagePathGetters.integration_details({ pkgkey: 'nginx-0.3.7', panel: 'custom' }) + pagePathGetters.integration_details_custom({ pkgkey: 'nginx-0.3.7' }) ); }); await lazyComponentWasRendered; @@ -173,14 +173,14 @@ describe('when on integration detail', () => { onCancelNavigateTo: [ 'fleet', { - path: '#/integrations/detail/nginx-0.3.7', + path: '#/integrations/detail/nginx-0.3.7/overview', }, ], - onCancelUrl: '#/integrations/detail/nginx-0.3.7', + onCancelUrl: '#/integrations/detail/nginx-0.3.7/overview', onSaveNavigateTo: [ 'fleet', { - path: '#/integrations/detail/nginx-0.3.7', + path: '#/integrations/detail/nginx-0.3.7/overview', }, ], }); @@ -188,7 +188,7 @@ describe('when on integration detail', () => { }); describe('and on the Policies Tab', () => { - const policiesTabURLPath = pagePathGetters.integration_details({ pkgkey, panel: 'policies' }); + const policiesTabURLPath = pagePathGetters.integration_details_policies({ pkgkey }); beforeEach(() => { testRenderer.history.push(policiesTabURLPath); render(); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.tsx index fabcd2ab917a..3cb57b63e707 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.tsx @@ -4,72 +4,50 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import React, { useEffect, useState, useMemo, useCallback, ReactEventHandler } from 'react'; -import { useHistory, useLocation, useParams } from 'react-router-dom'; +import React, { ReactEventHandler, useCallback, useEffect, useMemo, useState } from 'react'; +import { Redirect, Route, Switch, useHistory, useLocation, useParams } from 'react-router-dom'; import styled from 'styled-components'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; import { - EuiFlexGroup, - EuiFlexItem, - EuiButtonEmpty, - EuiText, - EuiSpacer, EuiBetaBadge, EuiButton, + EuiButtonEmpty, EuiDescriptionList, - EuiDescriptionListTitle, EuiDescriptionListDescription, + EuiDescriptionListTitle, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useUIExtension } from '../../../../hooks/use_ui_extension'; +import { PAGE_ROUTING_PATHS, PLUGIN_ID } from '../../../../constants'; +import { useCapabilities, useGetPackageInfoByKey, useLink } from '../../../../hooks'; +import { pkgKeyFromPackageInfo } from '../../../../services/pkg_key_from_package_info'; import { CreatePackagePolicyRouteState, DetailViewPanelName, - entries, InstallStatus, PackageInfo, } from '../../../../types'; -import { Loading, Error } from '../../../../components'; -import { - useGetPackageInfoByKey, - useBreadcrumbs, - useLink, - useCapabilities, -} from '../../../../hooks'; +import { Error, Loading } from '../../../../components'; +import { useBreadcrumbs } from '../../../../hooks'; import { WithHeaderLayout, WithHeaderLayoutProps } from '../../../../layouts'; +import { RELEASE_BADGE_DESCRIPTION, RELEASE_BADGE_LABEL } from '../../components/release_badge'; import { useSetPackageInstallStatus } from '../../hooks'; -import { IconPanel, LoadingIconPanel } from '../../components/icon_panel'; -import { RELEASE_BADGE_LABEL, RELEASE_BADGE_DESCRIPTION } from '../../components/release_badge'; -import { UpdateIcon } from '../../components/icons'; -import { Content } from './content'; +import { IntegrationAgentPolicyCount, UpdateIcon, IconPanel, LoadingIconPanel } from './components'; +import { OverviewPage } from './overview'; +import { PackagePoliciesPage } from './policies'; +import { SettingsPage } from './settings'; +import { CustomViewPage } from './custom'; import './index.scss'; -import { useUIExtension } from '../../../../hooks/use_ui_extension'; -import { PLUGIN_ID } from '../../../../../../../common/constants'; -import { pkgKeyFromPackageInfo } from '../../../../services/pkg_key_from_package_info'; -import { IntegrationAgentPolicyCount } from './integration_agent_policy_count'; - -export const DEFAULT_PANEL: DetailViewPanelName = 'overview'; export interface DetailParams { pkgkey: string; panel?: DetailViewPanelName; } -const PanelDisplayNames: Record = { - overview: i18n.translate('xpack.fleet.epm.packageDetailsNav.overviewLinkText', { - defaultMessage: 'Overview', - }), - policies: i18n.translate('xpack.fleet.epm.packageDetailsNav.packagePoliciesLinkText', { - defaultMessage: 'Policies', - }), - settings: i18n.translate('xpack.fleet.epm.packageDetailsNav.settingsLinkText', { - defaultMessage: 'Settings', - }), - custom: i18n.translate('xpack.fleet.epm.packageDetailsNav.packageCustomLinkText', { - defaultMessage: 'Advanced', - }), -}; - const Divider = styled.div` width: 0; height: 100%; @@ -82,12 +60,12 @@ const FlexItemWithMinWidth = styled(EuiFlexItem)` `; function Breadcrumbs({ packageTitle }: { packageTitle: string }) { - useBreadcrumbs('integration_details', { pkgTitle: packageTitle }); + useBreadcrumbs('integration_details_overview', { pkgTitle: packageTitle }); return null; } export function Detail() { - const { pkgkey, panel = DEFAULT_PANEL } = useParams(); + const { pkgkey, panel } = useParams(); const { getHref, getPath } = useLink(); const hasWriteCapabilites = useCapabilities().write; const history = useHistory(); @@ -247,7 +225,7 @@ export function Detail() { { isDivider: true }, { label: i18n.translate('xpack.fleet.epm.usedByLabel', { - defaultMessage: 'Agent Policies', + defaultMessage: 'Agent policies', }), 'data-test-subj': 'agentPolicyCount', content: , @@ -306,37 +284,79 @@ export function Detail() { ] ); - const tabs = useMemo(() => { + const headerTabs = useMemo(() => { if (!packageInfo) { return []; } + const packageInfoKey = pkgKeyFromPackageInfo(packageInfo); - return (entries(PanelDisplayNames) - .filter(([panelId]) => { - // Don't show `Policies` tab if package is not installed - if (panelId === 'policies' && packageInstallStatus !== InstallStatus.installed) { - return false; - } + const tabs: WithHeaderLayoutProps['tabs'] = [ + { + id: 'overview', + name: ( + + ), + isSelected: panel === 'overview', + 'data-test-subj': `tab-overview`, + href: getHref('integration_details_overview', { + pkgkey: packageInfoKey, + }), + }, + ]; - // Don't show `custom` tab if a custom component is not registered - if (panelId === 'custom' && !showCustomTab) { - return false; - } + if (packageInstallStatus === InstallStatus.installed) { + tabs.push({ + id: 'policies', + name: ( + + ), + isSelected: panel === 'policies', + 'data-test-subj': `tab-policies`, + href: getHref('integration_details_policies', { + pkgkey: packageInfoKey, + }), + }); + } + + tabs.push({ + id: 'settings', + name: ( + + ), + isSelected: panel === 'settings', + 'data-test-subj': `tab-settings`, + href: getHref('integration_details_settings', { + pkgkey: packageInfoKey, + }), + }); + + if (showCustomTab) { + tabs.push({ + id: 'custom', + name: ( + + ), + isSelected: panel === 'custom', + 'data-test-subj': `tab-custom`, + href: getHref('integration_details_custom', { + pkgkey: packageInfoKey, + }), + }); + } - return true; - }) - .map(([panelId, display]) => { - return { - id: panelId, - name: display, - isSelected: panelId === panel, - 'data-test-subj': `tab-${panelId}`, - href: getHref('integration_details', { - pkgkey: pkgKeyFromPackageInfo(packageInfo || {}), - panel: panelId, - }), - }; - }) as unknown) as WithHeaderLayoutProps['tabs']; + return tabs; }, [getHref, packageInfo, panel, showCustomTab, packageInstallStatus]); return ( @@ -344,7 +364,7 @@ export function Detail() { leftColumn={headerLeftContent} rightColumn={headerRightContent} rightColumnGrow={false} - tabs={tabs} + tabs={headerTabs} tabsClassName="fleet__epm__shiftNavTabs" > {packageInfo ? : null} @@ -361,7 +381,21 @@ export function Detail() { ) : isLoading || !packageInfo ? ( ) : ( - + + + + + + + + + + + + + + + )} ); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/layout.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/layout.tsx deleted file mode 100644 index f751a4c057b7..000000000000 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/layout.tsx +++ /dev/null @@ -1,52 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiFlexItem } from '@elastic/eui'; -import React, { FunctionComponent, ReactNode } from 'react'; -import { FlexItemGrowSize } from '@elastic/eui/src/components/flex/flex_item'; - -interface ColumnProps { - children?: ReactNode; - className?: string; - columnGrow?: FlexItemGrowSize; -} - -export const LeftColumn: FunctionComponent = ({ - columnGrow = 2, - children, - ...rest -}) => { - return ( - - {children} - - ); -}; - -export const CenterColumn: FunctionComponent = ({ - columnGrow = 9, - children, - ...rest -}) => { - return ( - - {children} - - ); -}; - -export const RightColumn: FunctionComponent = ({ - columnGrow = 3, - children, - ...rest -}) => { - return ( - - {children} - - ); -}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/details.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/details.tsx new file mode 100644 index 000000000000..3b9daaeb0216 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/details.tsx @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { memo, useMemo } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiTextColor, + EuiDescriptionList, + EuiNotificationBadge, +} from '@elastic/eui'; +import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list'; +import { + PackageInfo, + PackageSpecCategory, + AssetTypeToParts, + KibanaAssetType, + entries, +} from '../../../../../types'; +import { useGetCategories } from '../../../../../hooks'; +import { AssetTitleMap, DisplayedAssets, ServiceTitleMap } from '../../../constants'; + +interface Props { + packageInfo: PackageInfo; +} + +export const Details: React.FC = memo(({ packageInfo }) => { + const { data: categoriesData, isLoading: isLoadingCategories } = useGetCategories(); + const packageCategories: string[] = useMemo(() => { + if (!isLoadingCategories && categoriesData && categoriesData.response) { + return categoriesData.response + .filter((category) => packageInfo.categories?.includes(category.id as PackageSpecCategory)) + .map((category) => category.title); + } + return []; + }, [categoriesData, isLoadingCategories, packageInfo.categories]); + + const listItems = useMemo(() => { + // Base details: version and categories + const items: EuiDescriptionListProps['listItems'] = [ + { + title: ( + + + + ), + description: packageInfo.version, + }, + { + title: ( + + + + ), + description: packageCategories.join(', '), + }, + ]; + + // Asset details and counts + entries(packageInfo.assets).forEach(([service, typeToParts]) => { + // Filter out assets we are not going to display + // (currently we only display Kibana and Elasticsearch assets) + const filteredTypes: AssetTypeToParts = entries(typeToParts).reduce( + (acc: any, [asset, value]) => { + if (DisplayedAssets[service].includes(asset)) acc[asset] = value; + return acc; + }, + {} + ); + if (Object.entries(filteredTypes).length) { + items.push({ + title: ( + + + + ), + description: ( + + {entries(filteredTypes).map(([_type, parts]) => { + const type = _type as KibanaAssetType; + return ( + + + {AssetTitleMap[type]} + + {parts.length} + + + + ); + })} + + ), + }); + } + }); + + // Feature (data stream type) details + const dataStreamTypes = [ + ...new Set(packageInfo.data_streams?.map((dataStream) => dataStream.type) || []), + ]; + if (dataStreamTypes.length) { + items.push({ + title: ( + + + + ), + description: dataStreamTypes.join(', '), + }); + } + + // License details + if (packageInfo.license) { + items.push({ + title: ( + + + + ), + description: packageInfo.license, + }); + } + + return items; + }, [ + packageCategories, + packageInfo.assets, + packageInfo.data_streams, + packageInfo.license, + packageInfo.version, + ]); + + return ( + + + +

+ +

+
+
+ + + +
+ ); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/index.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/index.ts new file mode 100644 index 000000000000..70a5453aeb42 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +export { OverviewPage } from './overview'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/markdown_renderers.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/markdown_renderers.tsx similarity index 100% rename from x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/markdown_renderers.tsx rename to x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/markdown_renderers.tsx diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/overview.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/overview.tsx new file mode 100644 index 000000000000..4e45ecd2f70c --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/overview.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { memo } from 'react'; +import styled from 'styled-components'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { PackageInfo } from '../../../../../types'; +import { Screenshots } from './screenshots'; +import { Readme } from './readme'; +import { Details } from './details'; + +interface Props { + packageInfo: PackageInfo; +} + +const LeftColumn = styled(EuiFlexItem)` + /* 🤢🤷 https://www.styled-components.com/docs/faqs#how-can-i-override-styles-with-higher-specificity */ + &&& { + margin-top: 77px; + } +`; + +export const OverviewPage: React.FC = memo(({ packageInfo }: Props) => { + return ( + + + + {packageInfo.readme ? ( + + ) : null} + + + + {packageInfo.screenshots && packageInfo.screenshots.length ? ( + + + + ) : null} + +
+ + + + + ); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/readme.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/readme.tsx similarity index 82% rename from x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/readme.tsx rename to x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/readme.tsx index 55050894d870..92022f5e97a6 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/readme.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/readme.tsx @@ -8,10 +8,9 @@ import { EuiLoadingContent, EuiText } from '@elastic/eui'; import React, { Fragment, useEffect, useState } from 'react'; import ReactMarkdown from 'react-markdown'; -import { useLinks } from '../../hooks'; -import { ContentCollapse } from './content_collapse'; +import { useLinks } from '../../../hooks'; +import { sendGetFileByPath } from '../../../../../hooks'; import { markdownRenderers } from './markdown_renderers'; -import { sendGetFileByPath } from '../../../../hooks'; export function Readme({ readmePath, @@ -43,13 +42,11 @@ export function Readme({ return ( {markdown !== undefined ? ( - - - + ) : ( {/* simulates a long page of text loading */} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/screenshots.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/screenshots.tsx new file mode 100644 index 000000000000..33b2c3efe2fb --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/screenshots.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useState, useMemo, memo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFlexGroup, EuiFlexItem, EuiImage, EuiText, EuiPagination } from '@elastic/eui'; +import { ScreenshotItem } from '../../../../../types'; +import { useLinks } from '../../../hooks'; + +interface ScreenshotProps { + images: ScreenshotItem[]; + packageName: string; + version: string; +} + +export const Screenshots: React.FC = memo(({ images, packageName, version }) => { + const { toPackageImage } = useLinks(); + const [currentImageIndex, setCurrentImageIndex] = useState(0); + const maxImageIndex = useMemo(() => images.length - 1, [images.length]); + const currentImageUrl = useMemo( + () => toPackageImage(images[currentImageIndex], packageName, version), + [currentImageIndex, images, packageName, toPackageImage, version] + ); + + return ( + + {/* Title with carousel navigation */} + + + + +

+ +

+
+
+ + setCurrentImageIndex(activePage)} + compressed + /> + +
+
+ + {/* Current screenshot */} + + {currentImageUrl ? ( + + ) : ( + + )} + +
+ ); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview_panel.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview_panel.tsx deleted file mode 100644 index 750f8ad3f80b..000000000000 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview_panel.tsx +++ /dev/null @@ -1,23 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiSpacer } from '@elastic/eui'; -import React, { Fragment } from 'react'; -import { PackageInfo } from '../../../../types'; -import { Readme } from './readme'; -import { Screenshots } from './screenshots'; - -export function OverviewPanel(props: PackageInfo) { - const { screenshots, readme, name, version } = props; - return ( - - {readme && } - - {screenshots && } - - ); -} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/policies/index.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/policies/index.ts new file mode 100644 index 000000000000..d92dfc923632 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/policies/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +export { PackagePoliciesPage } from './package_policies'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/package_policies_panel.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/policies/package_policies.tsx similarity index 86% rename from x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/package_policies_panel.tsx rename to x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/policies/package_policies.tsx index b8e5388dc153..53cd642a1ef7 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/package_policies_panel.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/policies/package_policies.tsx @@ -12,21 +12,22 @@ import { EuiBasicTable, EuiLink, EuiTableFieldDataColumnType, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedRelative, FormattedMessage } from '@kbn/i18n/react'; -import { useGetPackageInstallStatus } from '../../hooks'; -import { InstallStatus } from '../../../../types'; -import { useLink } from '../../../../hooks'; -import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../../../../../common/constants'; -import { useUrlPagination } from '../../../../hooks'; +import { InstallStatus } from '../../../../../types'; +import { useLink, useUrlPagination } from '../../../../../hooks'; +import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../../../constants'; +import { LinkAndRevision, LinkAndRevisionProps } from '../../../../../components'; +import { LinkedAgentCount } from '../../../../../components/linked_agent_count'; +import { useGetPackageInstallStatus } from '../../../hooks'; import { PackagePolicyAndAgentPolicy, usePackagePoliciesWithAgentPolicy, } from './use_package_policies_with_agent_policy'; -import { LinkAndRevision, LinkAndRevisionProps } from '../../../../components'; import { Persona } from './persona'; -import { LinkedAgentCount } from '../../../../components/linked_agent_count'; const IntegrationDetailsLink = memo<{ packagePolicy: PackagePolicyAndAgentPolicy['packagePolicy']; @@ -52,6 +53,7 @@ const AgentPolicyDetailLink = memo<{ children: ReactNode; }>(({ agentPolicyId, revision, children }) => { const { getHref } = useLink(); + return ( { +export const PackagePoliciesPage = ({ name, version }: PackagePoliciesPanelProps) => { const { getPath } = useLink(); const getPackageInstallStatus = useGetPackageInstallStatus(); const packageInstallStatus = getPackageInstallStatus(name); @@ -197,18 +199,25 @@ export const PackagePoliciesPanel = ({ name, version }: PackagePoliciesPanelProp // if they arrive at this page and the package is not installed, send them to overview // this happens if they arrive with a direct url or they uninstall while on this tab if (packageInstallStatus.status !== InstallStatus.installed) { - return ; + return ( + + ); } return ( - + + + + + + ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/persona.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/policies/persona.tsx similarity index 99% rename from x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/persona.tsx rename to x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/policies/persona.tsx index 373140f6fef6..02e36df57009 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/persona.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/policies/persona.tsx @@ -4,9 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { EuiAvatar, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import React, { CSSProperties, memo, useCallback } from 'react'; +import { EuiAvatar, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import { EuiAvatarProps } from '@elastic/eui/src/components/avatar/avatar'; const MIN_WIDTH: CSSProperties = { minWidth: 0 }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/use_package_policies_with_agent_policy.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/policies/use_package_policies_with_agent_policy.ts similarity index 87% rename from x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/use_package_policies_with_agent_policy.ts rename to x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/policies/use_package_policies_with_agent_policy.ts index 4f014d93c5d5..ff8343314b4a 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/use_package_policies_with_agent_policy.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/policies/use_package_policies_with_agent_policy.ts @@ -6,19 +6,19 @@ */ import { useEffect, useMemo, useState } from 'react'; -import { PackagePolicy } from '../../../../../../../common/types/models'; import { + PackagePolicy, GetAgentPoliciesResponse, GetAgentPoliciesResponseItem, -} from '../../../../../../../common/types/rest_spec'; -import { useGetPackagePolicies } from '../../../../hooks/use_request'; + GetPackagePoliciesResponse, +} from '../../../../../types'; +import { agentPolicyRouteService } from '../../../../../services'; +import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../../../../../constants'; +import { useGetPackagePolicies } from '../../../../../hooks'; import { SendConditionalRequestConfig, useConditionalRequest, -} from '../../../../hooks/use_request/use_request'; -import { agentPolicyRouteService } from '../../../../../../../common/services'; -import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../../../../../../../common/constants'; -import { GetPackagePoliciesResponse } from '../../../../../../../common/types/rest_spec'; +} from '../../../../../hooks/use_request/use_request'; export interface PackagePolicyEnriched extends PackagePolicy { _agentPolicy: GetAgentPoliciesResponseItem | undefined; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/screenshots.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/screenshots.tsx deleted file mode 100644 index d1e6cf07f57a..000000000000 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/screenshots.tsx +++ /dev/null @@ -1,92 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { Fragment } from 'react'; -import styled from 'styled-components'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiFlexGroup, EuiFlexItem, EuiImage, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; -import { ScreenshotItem } from '../../../../types'; -import { useLinks } from '../../hooks'; - -interface ScreenshotProps { - images: ScreenshotItem[]; - packageName: string; - version: string; -} - -const getHorizontalPadding = (styledProps: any): number => - parseInt(styledProps.theme.eui.paddingSizes.xl, 10) * 2; -const getVerticalPadding = (styledProps: any): number => - parseInt(styledProps.theme.eui.paddingSizes.xl, 10) * 1.75; -const getPadding = (styledProps: any) => - styledProps.hascaption - ? `${styledProps.theme.eui.paddingSizes.xl} ${getHorizontalPadding( - styledProps - )}px ${getVerticalPadding(styledProps)}px` - : `${getHorizontalPadding(styledProps)}px ${getVerticalPadding(styledProps)}px`; -const ScreenshotsContainer = styled(EuiFlexGroup)` - background: linear-gradient(360deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0) 100%), - ${(styledProps) => styledProps.theme.eui.euiColorPrimary}; - padding: ${(styledProps) => getPadding(styledProps)}; - flex: 0 0 auto; - border-radius: ${(styledProps) => styledProps.theme.eui.euiBorderRadius}; -`; - -// fixes ie11 problems with nested flex items -const NestedEuiFlexItem = styled(EuiFlexItem)` - flex: 0 0 auto !important; -`; - -export function Screenshots(props: ScreenshotProps) { - const { toPackageImage } = useLinks(); - const { images, packageName, version } = props; - - // for now, just get first image - const image = images[0]; - const hasCaption = image.title ? true : false; - const screenshotUrl = toPackageImage(image, packageName, version); - - return ( - - -

- -

-
- - - {hasCaption && ( - - - {image.title} - - - - )} - {screenshotUrl && ( - - {/* By default EuiImage sets width to 100% and Figure to 22.5rem for size=l images, - set image to same width. Will need to update if size changes. - */} - - - )} - -
- ); -} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/confirm_package_install.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/confirm_package_install.tsx similarity index 100% rename from x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/confirm_package_install.tsx rename to x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/confirm_package_install.tsx diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/confirm_package_uninstall.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/confirm_package_uninstall.tsx similarity index 100% rename from x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/confirm_package_uninstall.tsx rename to x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/confirm_package_uninstall.tsx diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/index.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/index.ts new file mode 100644 index 000000000000..33c6a11f4067 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +export { SettingsPage } from './settings'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/installation_button.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/installation_button.tsx similarity index 96% rename from x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/installation_button.tsx rename to x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/installation_button.tsx index ae85f59424fb..ca37095f5db1 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/installation_button.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/installation_button.tsx @@ -8,9 +8,9 @@ import { EuiButton } from '@elastic/eui'; import React, { Fragment, useCallback, useMemo, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { PackageInfo, InstallStatus } from '../../../../types'; -import { useCapabilities } from '../../../../hooks'; -import { useUninstallPackage, useGetPackageInstallStatus, useInstallPackage } from '../../hooks'; +import { PackageInfo, InstallStatus } from '../../../../../types'; +import { useCapabilities } from '../../../../../hooks'; +import { useUninstallPackage, useGetPackageInstallStatus, useInstallPackage } from '../../../hooks'; import { ConfirmPackageUninstall } from './confirm_package_uninstall'; import { ConfirmPackageInstall } from './confirm_package_install'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/settings.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/settings.tsx new file mode 100644 index 000000000000..2c3559a65130 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/settings.tsx @@ -0,0 +1,238 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import styled from 'styled-components'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiText, EuiSpacer } from '@elastic/eui'; +import { InstallStatus, PackageInfo } from '../../../../../types'; +import { useGetPackagePolicies } from '../../../../../hooks'; +import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../../../constants'; +import { useGetPackageInstallStatus } from '../../../hooks'; +import { UpdateIcon } from '../components'; +import { InstallationButton } from './installation_button'; + +const SettingsTitleCell = styled.td` + padding-right: ${(props) => props.theme.eui.spacerSizes.xl}; + padding-bottom: ${(props) => props.theme.eui.spacerSizes.m}; +`; + +const UpdatesAvailableMsgContainer = styled.span` + padding-left: ${(props) => props.theme.eui.spacerSizes.s}; +`; + +const NoteLabel = () => ( + +); +const UpdatesAvailableMsg = () => ( + + + + +); + +interface Props { + packageInfo: PackageInfo; +} + +export const SettingsPage: React.FC = memo(({ packageInfo }: Props) => { + const { name, title, removable, latestVersion, version } = packageInfo; + const getPackageInstallStatus = useGetPackageInstallStatus(); + const { data: packagePoliciesData } = useGetPackagePolicies({ + perPage: 0, + page: 1, + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${name}`, + }); + const { status: installationStatus, version: installedVersion } = getPackageInstallStatus(name); + const packageHasUsages = !!packagePoliciesData?.total; + const updateAvailable = installedVersion && installedVersion < latestVersion ? true : false; + const isViewingOldPackage = version < latestVersion; + // hide install/remove options if the user has version of the package is installed + // and this package is out of date or if they do have a version installed but it's not this one + const hideInstallOptions = + (installationStatus === InstallStatus.notInstalled && isViewingOldPackage) || + (installationStatus === InstallStatus.installed && installedVersion !== version); + + const isUpdating = installationStatus === InstallStatus.installing && installedVersion; + return ( + + + + + +

+ +

+
+ + {installedVersion !== null && ( +
+ +

+ +

+
+ + + + + + + + + + + + + + + + +
+ + {installedVersion} + + {updateAvailable && } +
+ + {latestVersion} + +
+ {updateAvailable && ( +

+ +

+ )} +
+ )} + {!hideInstallOptions && !isUpdating && ( +
+ + {installationStatus === InstallStatus.notInstalled || + installationStatus === InstallStatus.installing ? ( +
+ +

+ +

+
+ +

+ +

+
+ ) : ( +
+ +

+ +

+
+ +

+ +

+
+ )} + + +

+ +

+
+
+ {packageHasUsages && removable === true && ( +

+ + + + ), + }} + /> +

+ )} + {removable === false && ( +

+ + + + ), + }} + /> +

+ )} +
+ )} +
+
+
+ ); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings_panel.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings_panel.tsx deleted file mode 100644 index b9835f41a0d7..000000000000 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings_panel.tsx +++ /dev/null @@ -1,230 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; -import { EuiSpacer } from '@elastic/eui'; -import styled from 'styled-components'; -import { InstallStatus, PackageInfo } from '../../../../types'; -import { useGetPackagePolicies } from '../../../../hooks'; -import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../../constants'; -import { useGetPackageInstallStatus } from '../../hooks'; -import { InstallationButton } from './installation_button'; -import { UpdateIcon } from '../../components/icons'; - -const SettingsTitleCell = styled.td` - padding-right: ${(props) => props.theme.eui.spacerSizes.xl}; - padding-bottom: ${(props) => props.theme.eui.spacerSizes.m}; -`; - -const UpdatesAvailableMsgContainer = styled.span` - padding-left: ${(props) => props.theme.eui.spacerSizes.s}; -`; - -const NoteLabel = () => ( - -); -const UpdatesAvailableMsg = () => ( - - - - -); - -export const SettingsPanel = ( - props: Pick -) => { - const { name, title, removable, latestVersion, version } = props; - const getPackageInstallStatus = useGetPackageInstallStatus(); - const { data: packagePoliciesData } = useGetPackagePolicies({ - perPage: 0, - page: 1, - kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${props.name}`, - }); - const { status: installationStatus, version: installedVersion } = getPackageInstallStatus(name); - const packageHasUsages = !!packagePoliciesData?.total; - const updateAvailable = installedVersion && installedVersion < latestVersion ? true : false; - const isViewingOldPackage = version < latestVersion; - // hide install/remove options if the user has version of the package is installed - // and this package is out of date or if they do have a version installed but it's not this one - const hideInstallOptions = - (installationStatus === InstallStatus.notInstalled && isViewingOldPackage) || - (installationStatus === InstallStatus.installed && installedVersion !== version); - - const isUpdating = installationStatus === InstallStatus.installing && installedVersion; - return ( - - -

- -

-
- - {installedVersion !== null && ( -
- -

- -

-
- - - - - - - - - - - - - - - - -
- - {installedVersion} - - {updateAvailable && } -
- - {latestVersion} - -
- {updateAvailable && ( -

- -

- )} -
- )} - {!hideInstallOptions && !isUpdating && ( -
- - {installationStatus === InstallStatus.notInstalled || - installationStatus === InstallStatus.installing ? ( -
- -

- -

-
- -

- -

-
- ) : ( -
- -

- -

-
- -

- -

-
- )} - - -

- -

-
-
- {packageHasUsages && removable === true && ( -

- - - - ), - }} - /> -

- )} - {removable === false && ( -

- - - - ), - }} - /> -

- )} -
- )} -
- ); -}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/types/index.ts b/x-pack/plugins/fleet/public/applications/fleet/types/index.ts index 5ccbfc953428..89aa5ad1add3 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/types/index.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/types/index.ts @@ -49,6 +49,7 @@ export { CreatePackagePolicyResponse, UpdatePackagePolicyRequest, UpdatePackagePolicyResponse, + GetPackagePoliciesResponse, // API schemas - Data streams GetDataStreamsResponse, // API schemas - Agents @@ -122,6 +123,7 @@ export { InstallationStatus, Installable, RegistryRelease, + PackageSpecCategory, } from '../../../../common'; export * from './intra_app_route_state'; diff --git a/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts b/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts index ef0c34ee5639..6b35f74b3feb 100644 --- a/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts @@ -79,11 +79,10 @@ export const createPackagePolicyHandler: RequestHandler< const esClient = context.core.elasticsearch.client.asCurrentUser; const callCluster = context.core.elasticsearch.legacy.client.callAsCurrentUser; const user = (await appContextService.getSecurity()?.authc.getCurrentUser(request)) || undefined; - let newData = { ...request.body }; try { - newData = await packagePolicyService.runExternalCallbacks( + const newData = await packagePolicyService.runExternalCallbacks( 'packagePolicyCreate', - newData, + { ...request.body }, context, request ); diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 9800ddf95f7b..31e9a63175d1 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -36,7 +36,11 @@ import { FleetServerPolicy, AGENT_POLICY_INDEX, } from '../../common'; -import { AgentPolicyNameExistsError, AgentPolicyDeletionError } from '../errors'; +import { + AgentPolicyNameExistsError, + AgentPolicyDeletionError, + IngestManagerError, +} from '../errors'; import { createAgentPolicyAction, listAgents } from './agents'; import { packagePolicyService } from './package_policy'; import { outputService } from './output'; @@ -382,6 +386,10 @@ class AgentPolicyService { throw new Error('Agent policy not found'); } + if (oldAgentPolicy.is_managed) { + throw new IngestManagerError(`Cannot update integrations of managed policy ${id}`); + } + return await this._update( soClient, esClient, @@ -409,6 +417,10 @@ class AgentPolicyService { throw new Error('Agent policy not found'); } + if (oldAgentPolicy.is_managed) { + throw new IngestManagerError(`Cannot remove integrations of managed policy ${id}`); + } + return await this._update( soClient, esClient, diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index 4b04014b2096..8d1ac90f3ec1 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -25,6 +25,7 @@ import { doesAgentPolicyAlreadyIncludePackage, } from '../../common'; import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../constants'; +import { IngestManagerError, ingestErrorToResponseOptions } from '../errors'; import { NewPackagePolicy, UpdatePackagePolicy, @@ -63,15 +64,20 @@ class PackagePolicyService { const parentAgentPolicy = await agentPolicyService.get(soClient, packagePolicy.policy_id); if (!parentAgentPolicy) { throw new Error('Agent policy not found'); - } else { - if ( - (parentAgentPolicy.package_policies as PackagePolicy[]).find( - (siblingPackagePolicy) => siblingPackagePolicy.name === packagePolicy.name - ) - ) { - throw new Error('There is already a package with the same name on this agent policy'); - } } + if (parentAgentPolicy.is_managed) { + throw new IngestManagerError( + `Cannot add integrations to managed policy ${parentAgentPolicy.id}` + ); + } + if ( + (parentAgentPolicy.package_policies as PackagePolicy[]).find( + (siblingPackagePolicy) => siblingPackagePolicy.name === packagePolicy.name + ) + ) { + throw new Error('There is already a package with the same name on this agent policy'); + } + // Add ids to stream const packagePolicyId = options?.id || uuid.v4(); let inputs: PackagePolicyInput[] = packagePolicy.inputs.map((input) => @@ -285,6 +291,9 @@ class PackagePolicyService { if (!parentAgentPolicy) { throw new Error('Agent policy not found'); } else { + if (parentAgentPolicy.is_managed) { + throw new IngestManagerError(`Cannot update integrations of managed policy ${id}`); + } if ( (parentAgentPolicy.package_policies as PackagePolicy[]).find( (siblingPackagePolicy) => @@ -295,7 +304,7 @@ class PackagePolicyService { } } - let inputs = await restOfPackagePolicy.inputs.map((input) => + let inputs = restOfPackagePolicy.inputs.map((input) => assignStreamIdToInput(oldPackagePolicy.id, input) ); @@ -363,10 +372,11 @@ class PackagePolicyService { name: packagePolicy.name, success: true, }); - } catch (e) { + } catch (error) { result.push({ id, success: false, + ...ingestErrorToResponseOptions(error), }); } } diff --git a/x-pack/plugins/infra/public/components/saved_views/toolbar_control.tsx b/x-pack/plugins/infra/public/components/saved_views/toolbar_control.tsx index 09e319b9935d..ade43638deb6 100644 --- a/x-pack/plugins/infra/public/components/saved_views/toolbar_control.tsx +++ b/x-pack/plugins/infra/public/components/saved_views/toolbar_control.tsx @@ -6,7 +6,7 @@ */ import { EuiFlexGroup } from '@elastic/eui'; -import React, { useCallback, useState, useEffect, useContext } from 'react'; +import React, { useCallback, useState, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -21,7 +21,7 @@ import { SavedViewCreateModal } from './create_modal'; import { SavedViewUpdateModal } from './update_modal'; import { SavedViewManageViewsFlyout } from './manage_views_flyout'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; -import { SavedView } from '../../containers/saved_view/saved_view'; +import { useSavedViewContext } from '../../containers/saved_view/saved_view'; import { SavedViewListModal } from './view_list_modal'; interface Props { @@ -47,7 +47,7 @@ export function SavedViewsToolbarControls(props: Props) { updatedView, currentView, setCurrentView, - } = useContext(SavedView.Context); + } = useSavedViewContext(); const [modalOpen, setModalOpen] = useState(false); const [viewListModalOpen, setViewListModalOpen] = useState(false); const [isInvalid, setIsInvalid] = useState(false); diff --git a/x-pack/plugins/infra/public/containers/saved_view/saved_view.tsx b/x-pack/plugins/infra/public/containers/saved_view/saved_view.tsx index e867cf800f4b..4c4835cbe4cd 100644 --- a/x-pack/plugins/infra/public/containers/saved_view/saved_view.tsx +++ b/x-pack/plugins/infra/public/containers/saved_view/saved_view.tsx @@ -6,9 +6,14 @@ */ import createContainer from 'constate'; +import * as rt from 'io-ts'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { constant, identity } from 'fp-ts/lib/function'; import { useCallback, useMemo, useState, useEffect, useContext } from 'react'; import { i18n } from '@kbn/i18n'; import { SimpleSavedObject, SavedObjectAttributes } from 'kibana/public'; +import { useUrlState } from '../../utils/use_url_state'; import { useFindSavedObject } from '../../hooks/use_find_saved_object'; import { useCreateSavedObject } from '../../hooks/use_create_saved_object'; import { useDeleteSavedObject } from '../../hooks/use_delete_saved_object'; @@ -39,6 +44,14 @@ interface Props { shouldLoadDefault: boolean; } +const savedViewUrlStateRT = rt.type({ + viewId: rt.string, +}); +type SavedViewUrlState = rt.TypeOf; +const DEFAULT_SAVED_VIEW_STATE: SavedViewUrlState = { + viewId: '0', +}; + export const useSavedView = (props: Props) => { const { source, @@ -52,6 +65,13 @@ export const useSavedView = (props: Props) => { const { data, loading, find, error: errorOnFind, hasView } = useFindSavedObject< SavedViewSavedObject >(viewType); + const [urlState, setUrlState] = useUrlState({ + defaultState: DEFAULT_SAVED_VIEW_STATE, + decodeUrlState, + encodeUrlState, + urlStateKey: 'savedView', + }); + const [shouldLoadDefault] = useState(props.shouldLoadDefault); const [currentView, setCurrentView] = useState | null>(null); const [loadingDefaultView, setLoadingDefaultView] = useState(null); @@ -212,25 +232,35 @@ export const useSavedView = (props: Props) => { }); }, [setCurrentView, defaultViewId, defaultViewState]); - useEffect(() => { - if (loadingDefaultView || currentView || !shouldLoadDefault) { - return; - } - + const loadDefaultViewIfSet = useCallback(() => { if (defaultViewId !== '0') { loadDefaultView(); } else { setDefault(); setLoadingDefaultView(false); } - }, [ - loadDefaultView, - shouldLoadDefault, - setDefault, - loadingDefaultView, - currentView, - defaultViewId, - ]); + }, [defaultViewId, loadDefaultView, setDefault, setLoadingDefaultView]); + + useEffect(() => { + if (loadingDefaultView || currentView || !shouldLoadDefault) { + return; + } + + loadDefaultViewIfSet(); + }, [loadDefaultViewIfSet, loadingDefaultView, currentView, shouldLoadDefault]); + + useEffect(() => { + if (currentView && urlState.viewId !== currentView.id && data) + setUrlState({ viewId: currentView.id }); + }, [urlState, setUrlState, currentView, defaultViewId, data]); + + useEffect(() => { + if (!currentView && !loading && data) { + const viewToSet = views.find((v) => v.id === urlState.viewId); + if (viewToSet) setCurrentView(viewToSet); + else loadDefaultViewIfSet(); + } + }, [loading, currentView, data, views, setCurrentView, loadDefaultViewIfSet, urlState.viewId]); return { views, @@ -260,3 +290,11 @@ export const useSavedView = (props: Props) => { export const SavedView = createContainer(useSavedView); export const [SavedViewProvider, useSavedViewContext] = SavedView; + +const encodeUrlState = (state: SavedViewUrlState) => { + return savedViewUrlStateRT.encode(state); +}; +const decodeUrlState = (value: unknown) => { + const state = pipe(savedViewUrlStateRT.decode(value), fold(constant(undefined), identity)); + return state; +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/index.tsx b/x-pack/plugins/infra/public/pages/metrics/index.tsx index 8fd32bda7fbc..240cb778275b 100644 --- a/x-pack/plugins/infra/public/pages/metrics/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/index.tsx @@ -36,7 +36,7 @@ import { WaffleTimeProvider } from './inventory_view/hooks/use_waffle_time'; import { WaffleFiltersProvider } from './inventory_view/hooks/use_waffle_filters'; import { MetricsAlertDropdown } from '../../alerting/common/components/metrics_alert_dropdown'; -import { SavedView } from '../../containers/saved_view/saved_view'; +import { SavedViewProvider } from '../../containers/saved_view/saved_view'; import { AlertPrefillProvider } from '../../alerting/use_alert_prefill'; import { InfraMLCapabilitiesProvider } from '../../containers/ml/infra_ml_capabilities'; import { AnomalyDetectionFlyout } from './inventory_view/components/ml/anomaly_detection/anomaly_detection_flyout'; @@ -195,7 +195,7 @@ const PageContent = (props: { const { options } = useContext(MetricsExplorerOptionsContainer.Context); return ( - - + ); }; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx index a3a47276bb52..e313cf0762af 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx @@ -89,11 +89,11 @@ export const NodeContextPopover = ({ - +

{node.name}

-
+ @@ -194,3 +194,12 @@ const OverlayPanel = euiStyled(EuiPanel).attrs({ paddingSize: 'none' })` max-width: 100%; } `; + +const OverlayTitle = euiStyled(EuiFlexItem)` + overflow: hidden; + & h4 { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } +`; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx index 7123c022538e..6b980d33c255 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx @@ -23,7 +23,7 @@ import { useTrackPageview } from '../../../../../observability/public'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { Layout } from './components/layout'; import { useLinkProps } from '../../../hooks/use_link_props'; -import { SavedView } from '../../../containers/saved_view/saved_view'; +import { SavedViewProvider } from '../../../containers/saved_view/saved_view'; import { DEFAULT_WAFFLE_VIEW_STATE } from './hooks/use_waffle_view_state'; import { useWaffleOptionsContext } from './hooks/use_waffle_options'; @@ -64,13 +64,13 @@ export const SnapshotPage = () => { ) : metricIndicesExist ? ( <> - - + ) : hasFailedLoadingSource ? ( diff --git a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_datasets_stats.ts b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_datasets_stats.ts index 850fab6937f4..7c92a81e1a89 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_datasets_stats.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_datasets_stats.ts @@ -79,7 +79,7 @@ export async function getLatestLogEntriesCategoriesDatasetsStats( return { categorization_status: latestHitSource.categorization_status, categorized_doc_count: latestHitSource.categorized_doc_count, - dataset: bucket.key.dataset ?? '', + dataset: bucket.key?.dataset ?? '', dead_category_count: latestHitSource.dead_category_count, failed_category_count: latestHitSource.failed_category_count, frequent_category_count: latestHitSource.frequent_category_count, diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/latest_log_entry_categories_datasets_stats.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/latest_log_entry_categories_datasets_stats.ts index c7b2590e4be9..2cbb0ef60cd1 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/latest_log_entry_categories_datasets_stats.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/latest_log_entry_categories_datasets_stats.ts @@ -98,9 +98,12 @@ export const logEntryCategorizerStatsHitRT = rt.type({ export type LogEntryCategorizerStatsHit = rt.TypeOf; -const compositeDatasetKeyRT = rt.type({ - dataset: rt.union([rt.string, rt.null]), -}); +const compositeDatasetKeyRT = rt.union([ + rt.type({ + dataset: rt.union([rt.string, rt.null]), + }), + rt.undefined, +]); export type CompositeDatasetKey = rt.TypeOf; diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx index 898071e85ea7..07c1368e5345 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx @@ -119,8 +119,14 @@ interface DragInnerProps extends BaseProps { /** * The props for a non-draggable instance of that component. */ -interface DropInnerProps extends BaseProps, DragContextState { - isDragging: boolean; +interface DropInnerProps extends BaseProps { + dragging: DragContextState['dragging']; + setKeyboardMode: DragContextState['setKeyboardMode']; + setDragging: DragContextState['setDragging']; + setActiveDropTarget: DragContextState['setActiveDropTarget']; + setA11yMessage: DragContextState['setA11yMessage']; + registerDropTarget: DragContextState['registerDropTarget']; + isActiveDropTarget: boolean; isNotDroppable: boolean; } @@ -141,27 +147,42 @@ export const DragDrop = (props: BaseProps) => { const { value, draggable, dropType, reorderableGroup } = props; const isDragging = !!(draggable && value.id === dragging?.id); - const dragProps = { - ...props, - isDragging, - keyboardMode: isDragging ? keyboardMode : false, // optimization to not rerender all dragging components - activeDropTarget: isDragging ? activeDropTarget : undefined, // optimization to not rerender all dragging components - setKeyboardMode, - setDragging, - setActiveDropTarget, - setA11yMessage, - }; + if (draggable && !dropType) { + const dragProps = { + ...props, + isDragging, + keyboardMode: isDragging ? keyboardMode : false, // optimization to not rerender all dragging components + activeDropTarget: isDragging ? activeDropTarget : undefined, // optimization to not rerender all dragging components + setKeyboardMode, + setDragging, + setActiveDropTarget, + setA11yMessage, + }; + if (reorderableGroup && reorderableGroup.length > 1) { + return ( + + ); + } else { + return ; + } + } + const isActiveDropTarget = Boolean( + activeDropTarget?.activeDropTarget && activeDropTarget.activeDropTarget.id === value.id + ); const dropProps = { ...props, setKeyboardMode, - keyboardMode, dragging, setDragging, - activeDropTarget, + isActiveDropTarget, setActiveDropTarget, registerDropTarget, - isDragging, setA11yMessage, isNotDroppable: // If the configuration has provided a droppable flag, but this particular item is not @@ -169,21 +190,6 @@ export const DragDrop = (props: BaseProps) => { // draggable and drop targets !!(!dropType && dragging && value.id !== dragging.id), }; - - if (draggable && !dropType) { - if (reorderableGroup && reorderableGroup.length > 1) { - return ( - - ); - } else { - return ; - } - } if ( reorderableGroup && reorderableGroup.length > 1 && @@ -340,19 +346,16 @@ const DropInner = memo(function DropInner(props: DropInnerProps) { children, draggable, dragging, - isDragging, isNotDroppable, - dragType = 'copy', dropType, - keyboardMode, - activeDropTarget, - registerDropTarget, - setActiveDropTarget, + order, getAdditionalClassesOnEnter, getAdditionalClassesOnDroppable, + isActiveDropTarget, + registerDropTarget, + setActiveDropTarget, setKeyboardMode, setDragging, - order, setA11yMessage, } = props; @@ -365,11 +368,6 @@ const DropInner = memo(function DropInner(props: DropInnerProps) { } }, [order, value, registerDropTarget, dropType]); - const activeDropTargetMatches = - activeDropTarget?.activeDropTarget && activeDropTarget.activeDropTarget.id === value.id; - - const isMoveDragging = isDragging && dragType === 'move'; - const classesOnEnter = getAdditionalClassesOnEnter?.(dropType); const classesOnDroppable = getAdditionalClassesOnDroppable?.(dropType); @@ -377,15 +375,12 @@ const DropInner = memo(function DropInner(props: DropInnerProps) { 'lnsDragDrop', { 'lnsDragDrop-isDraggable': draggable, - 'lnsDragDrop-isDragging': isDragging, - 'lnsDragDrop-isHidden': isMoveDragging && !keyboardMode, 'lnsDragDrop-isDroppable': !draggable, 'lnsDragDrop-isDropTarget': dropType && dropType !== 'reorder', - 'lnsDragDrop-isActiveDropTarget': - dropType && activeDropTargetMatches && dropType !== 'reorder', - 'lnsDragDrop-isNotDroppable': !isMoveDragging && isNotDroppable, + 'lnsDragDrop-isActiveDropTarget': dropType && isActiveDropTarget && dropType !== 'reorder', + 'lnsDragDrop-isNotDroppable': isNotDroppable, }, - classesOnEnter && { [classesOnEnter]: activeDropTargetMatches }, + classesOnEnter && { [classesOnEnter]: isActiveDropTarget }, classesOnDroppable && { [classesOnDroppable]: dropType } ); @@ -396,7 +391,7 @@ const DropInner = memo(function DropInner(props: DropInnerProps) { e.preventDefault(); // An optimization to prevent a bunch of React churn. - if (!activeDropTargetMatches && dragging && onDrop) { + if (!isActiveDropTarget && dragging && onDrop) { setActiveDropTarget({ ...value, dropType, onDrop }); setA11yMessage(announce.selectedTarget(dragging.humanData, value.humanData, dropType)); } @@ -602,7 +597,7 @@ const ReorderableDrop = memo(function ReorderableDrop( dragging, setDragging, setKeyboardMode, - activeDropTarget, + isActiveDropTarget, setActiveDropTarget, reorderableGroup, setA11yMessage, @@ -610,8 +605,6 @@ const ReorderableDrop = memo(function ReorderableDrop( } = props; const currentIndex = reorderableGroup.findIndex((i) => i.id === value.id); - const activeDropTargetMatches = - activeDropTarget?.activeDropTarget && activeDropTarget.activeDropTarget.id === value.id; const { reorderState: { isReorderOn, reorderedItems, draggingHeight, direction }, @@ -646,7 +639,7 @@ const ReorderableDrop = memo(function ReorderableDrop( e.preventDefault(); // An optimization to prevent a bunch of React churn. - if (!activeDropTargetMatches && dropType && onDrop) { + if (!isActiveDropTarget && dropType && onDrop) { setActiveDropTarget({ ...value, dropType, onDrop }); } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts index cbd599743f81..69c7e8c3c2ae 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts @@ -12,12 +12,11 @@ import { DraggedOperation, } from '../../types'; import { IndexPatternColumn } from '../indexpattern'; -import { insertOrReplaceColumn, deleteColumn } from '../operations'; +import { insertOrReplaceColumn, deleteColumn, getOperationTypesForField } from '../operations'; import { mergeLayer } from '../state_helpers'; import { hasField, isDraggedField } from '../utils'; import { IndexPatternPrivateState, IndexPatternField, DraggedField } from '../types'; import { trackUiEvent } from '../../lens_ui_telemetry'; -import { getOperationSupportMatrix } from './operation_support'; type DropHandlerProps = DatasourceDimensionDropHandlerProps & { droppedItem: T; @@ -34,7 +33,8 @@ export function getDropTypes( const layerIndexPatternId = props.state.layers[props.layerId].indexPatternId; function hasOperationForField(field: IndexPatternField) { - return !!getOperationSupportMatrix(props).operationByField[field.name]; + const operationsForNewField = getOperationTypesForField(field, props.filterOperations); + return !!operationsForNewField.length; } const currentColumn = props.state.layers[props.layerId].columns[props.columnId]; @@ -171,10 +171,9 @@ function onMoveDropToNonCompatibleGroup(props: DropHandlerProps) { const { columnId, setState, state, layerId, droppedItem } = props; - const operationSupportMatrix = getOperationSupportMatrix(props); - function hasOperationForField(field: IndexPatternField) { - return !!operationSupportMatrix.operationByField[field.name]; - } + const operationsForNewField = getOperationTypesForField( + droppedItem.field, + props.filterOperations + ); - if (!isDraggedField(droppedItem) || !hasOperationForField(droppedItem.field)) { + if (!isDraggedField(droppedItem) || !operationsForNewField.length) { // TODO: What do we do if we couldn't find a column? return false; } - // dragged field, not operation - - const operationsForNewField = operationSupportMatrix.operationByField[droppedItem.field.name]; - - if (!operationsForNewField || operationsForNewField.size === 0) { - return false; - } - const layer = state.layers[layerId]; const selectedColumn: IndexPatternColumn | null = layer.columns[columnId] || null; @@ -308,18 +299,13 @@ function onFieldDrop(props: DropHandlerProps) { // Detects if we can change the field only, otherwise change field + operation const fieldIsCompatibleWithCurrent = - selectedColumn && - operationSupportMatrix.operationByField[droppedItem.field.name]?.has( - selectedColumn.operationType - ); + selectedColumn && operationsForNewField.includes(selectedColumn.operationType); const newLayer = insertOrReplaceColumn({ layer, columnId, indexPattern: currentIndexPattern, - op: fieldIsCompatibleWithCurrent - ? selectedColumn.operationType - : operationsForNewField.values().next().value, + op: fieldIsCompatibleWithCurrent ? selectedColumn.operationType : operationsForNewField[0], field: droppedItem.field, }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_scaling.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_scaling.tsx index 0dd54118d030..a9362060b2dd 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_scaling.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_scaling.tsx @@ -90,7 +90,7 @@ export function TimeScaling({ iconSide="right" data-test-subj="indexPattern-time-scaling-popover" onClick={() => { - setPopoverOpen(true); + setPopoverOpen(!popoverOpen); }} > {i18n.translate('xpack.lens.indexPattern.timeScale.advancedSettings', { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts index 4249f8397716..360e1697ae58 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts @@ -56,7 +56,22 @@ describe('getOperationTypesForField', () => { aggregatable: true, searchable: true, }) - ).toEqual(expect.arrayContaining(['terms'])); + ).toEqual(['terms', 'cardinality', 'last_value']); + }); + + it('should return only bucketed operations on strings when passed proper filterOperations function', () => { + expect( + getOperationTypesForField( + { + type: 'string', + name: 'a', + displayName: 'aLabel', + aggregatable: true, + searchable: true, + }, + (op) => op.isBucketed + ) + ).toEqual(['terms']); }); it('should return operations on numbers', () => { @@ -68,7 +83,33 @@ describe('getOperationTypesForField', () => { aggregatable: true, searchable: true, }) - ).toEqual(expect.arrayContaining(['avg', 'sum', 'min', 'max'])); + ).toEqual([ + 'range', + 'terms', + 'avg', + 'sum', + 'min', + 'max', + 'cardinality', + 'median', + 'percentile', + 'last_value', + ]); + }); + + it('should return only metric operations on numbers when passed proper filterOperations function', () => { + expect( + getOperationTypesForField( + { + type: 'number', + name: 'a', + displayName: 'aLabel', + aggregatable: true, + searchable: true, + }, + (op) => !op.isBucketed + ) + ).toEqual(['avg', 'sum', 'min', 'max', 'cardinality', 'median', 'percentile', 'last_value']); }); it('should return operations on dates', () => { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.ts index 2869b14208e1..63671fe35e99 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.ts @@ -58,13 +58,20 @@ export function getSortScoreByPriority( * Returns all `OperationType`s that can build a column using `buildColumn` based on the * passed in field. */ -export function getOperationTypesForField(field: IndexPatternField): OperationType[] { +export function getOperationTypesForField( + field: IndexPatternField, + filterOperations?: (operation: OperationMetadata) => boolean +): OperationType[] { return operationDefinitions - .filter( - (operationDefinition) => - operationDefinition.input === 'field' && - operationDefinition.getPossibleOperationForField(field) - ) + .filter((operationDefinition) => { + if (operationDefinition.input !== 'field') { + return false; + } + const possibleOperation = operationDefinition.getPossibleOperationForField(field); + return filterOperations + ? possibleOperation && filterOperations(possibleOperation) + : possibleOperation; + }) .sort(getSortScoreByPriority) .map(({ type }) => type); } diff --git a/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.mock.ts b/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.mock.ts index e2c5ea504bd4..ab2aac39c19d 100644 --- a/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.mock.ts +++ b/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.mock.ts @@ -24,7 +24,9 @@ import { import { ExceptionListItemSchema } from './exception_list_item_schema'; -export const getExceptionListItemSchemaMock = (): ExceptionListItemSchema => ({ +export const getExceptionListItemSchemaMock = ( + overrides?: Partial +): ExceptionListItemSchema => ({ _version: undefined, comments: COMMENTS, created_at: DATE_NOW, @@ -43,6 +45,7 @@ export const getExceptionListItemSchemaMock = (): ExceptionListItemSchema => ({ type: ITEM_TYPE, updated_at: DATE_NOW, updated_by: USER, + ...(overrides || {}), }); export const getExceptionListItemSchemaXMock = (count = 1): ExceptionListItemSchema[] => { diff --git a/x-pack/plugins/logstash/public/plugin.ts b/x-pack/plugins/logstash/public/plugin.ts index 8f88f626160c..cfca262ec09c 100644 --- a/x-pack/plugins/logstash/public/plugin.ts +++ b/x-pack/plugins/logstash/public/plugin.ts @@ -6,11 +6,11 @@ */ import { i18n } from '@kbn/i18n'; -import { Subscription } from 'rxjs'; +import { Subscription, Subject, combineLatest } from 'rxjs'; import { map } from 'rxjs/operators'; import { once } from 'lodash'; -import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { Capabilities, CoreSetup, CoreStart, Plugin } from 'src/core/public'; import { HomePublicPluginSetup, FeatureCatalogueCategory, @@ -30,6 +30,7 @@ interface SetupDeps { export class LogstashPlugin implements Plugin { private licenseSubscription?: Subscription; + private capabilities$ = new Subject(); public setup(core: CoreSetup, plugins: SetupDeps) { const logstashLicense$ = plugins.licensing.license$.pipe( @@ -51,35 +52,43 @@ export class LogstashPlugin implements Plugin { }, }); - this.licenseSubscription = logstashLicense$.subscribe((license: any) => { - if (license.enableLinks) { - managementApp.enable(); - } else { - managementApp.disable(); - } + this.licenseSubscription = combineLatest([logstashLicense$, this.capabilities$]).subscribe( + ([license, capabilities]) => { + const shouldShow = license.enableLinks && capabilities.management.ingest.pipelines === true; + if (shouldShow) { + managementApp.enable(); + } else { + managementApp.disable(); + } - if (plugins.home && license.enableLinks) { - // Ensure that we don't register the feature more than once - once(() => { - plugins.home!.featureCatalogue.register({ - id: 'management_logstash', - title: i18n.translate('xpack.logstash.homeFeature.logstashPipelinesTitle', { - defaultMessage: 'Logstash Pipelines', - }), - description: i18n.translate('xpack.logstash.homeFeature.logstashPipelinesDescription', { - defaultMessage: 'Create, delete, update, and clone data ingestion pipelines.', - }), - icon: 'pipelineApp', - path: '/app/management/ingest/pipelines', - showOnHomePage: false, - category: FeatureCatalogueCategory.ADMIN, + if (plugins.home && shouldShow) { + // Ensure that we don't register the feature more than once + once(() => { + plugins.home!.featureCatalogue.register({ + id: 'management_logstash', + title: i18n.translate('xpack.logstash.homeFeature.logstashPipelinesTitle', { + defaultMessage: 'Logstash Pipelines', + }), + description: i18n.translate( + 'xpack.logstash.homeFeature.logstashPipelinesDescription', + { + defaultMessage: 'Create, delete, update, and clone data ingestion pipelines.', + } + ), + icon: 'pipelineApp', + path: '/app/management/ingest/pipelines', + showOnHomePage: false, + category: FeatureCatalogueCategory.ADMIN, + }); }); - }); + } } - }); + ); } - public start(core: CoreStart) {} + public start(core: CoreStart) { + this.capabilities$.next(core.application.capabilities); + } public stop() { if (this.licenseSubscription) { diff --git a/x-pack/plugins/maps/public/reducers/non_serializable_instances.js b/x-pack/plugins/maps/public/reducers/non_serializable_instances.js index 4cc4e91a308a..402d7727cd6f 100644 --- a/x-pack/plugins/maps/public/reducers/non_serializable_instances.js +++ b/x-pack/plugins/maps/public/reducers/non_serializable_instances.js @@ -77,7 +77,6 @@ export const getEventHandlers = ({ nonSerializableInstances }) => { }; export function getChartsPaletteServiceGetColor({ nonSerializableInstances }) { - console.log('getChartsPaletteServiceGetColor', nonSerializableInstances); return nonSerializableInstances.chartsPaletteServiceGetColor; } diff --git a/x-pack/plugins/maps/public/reducers/store.js b/x-pack/plugins/maps/public/reducers/store.js index 4e355add59fe..76199de5b24c 100644 --- a/x-pack/plugins/maps/public/reducers/store.js +++ b/x-pack/plugins/maps/public/reducers/store.js @@ -36,5 +36,6 @@ export function createMapStore() { }; const storeConfig = {}; - return createStore(rootReducer, storeConfig, compose(...enhancers)); + const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; + return createStore(rootReducer, storeConfig, composeEnhancers(...enhancers)); } diff --git a/x-pack/plugins/ml/common/util/errors/process_errors.ts b/x-pack/plugins/ml/common/util/errors/process_errors.ts index dd97886f2ff3..821ba670e2dd 100644 --- a/x-pack/plugins/ml/common/util/errors/process_errors.ts +++ b/x-pack/plugins/ml/common/util/errors/process_errors.ts @@ -59,11 +59,21 @@ export const extractErrorProperties = (error: ErrorType): MLErrorObject => { typeof error.body.attributes === 'object' && typeof error.body.attributes.body?.error?.reason === 'string' ) { - return { + const errObj: MLErrorObject = { message: error.body.attributes.body.error.reason, statusCode: error.body.statusCode, fullError: error.body.attributes.body, }; + if ( + typeof error.body.attributes.body.error.caused_by === 'object' && + (typeof error.body.attributes.body.error.caused_by?.reason === 'string' || + typeof error.body.attributes.body.error.caused_by?.caused_by?.reason === 'string') + ) { + errObj.causedBy = + error.body.attributes.body.error.caused_by?.caused_by?.reason || + error.body.attributes.body.error.caused_by?.reason; + } + return errObj; } else { return { message: error.body.message, diff --git a/x-pack/plugins/ml/common/util/errors/types.ts b/x-pack/plugins/ml/common/util/errors/types.ts index 23cd91a57c4f..39e9ed4e2575 100644 --- a/x-pack/plugins/ml/common/util/errors/types.ts +++ b/x-pack/plugins/ml/common/util/errors/types.ts @@ -11,6 +11,7 @@ import Boom from '@hapi/boom'; export interface EsErrorRootCause { type: string; reason: string; + caused_by?: EsErrorRootCause; } export interface EsErrorBody { @@ -37,6 +38,7 @@ export interface ErrorMessage { } export interface MLErrorObject { + causedBy?: string; message: string; statusCode?: number; fullError?: EsErrorBody; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts index 929b055b5f7b..4f1799ed26f8 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts @@ -33,18 +33,24 @@ export { getAnalysisType } from '../../../../common/util/analytics_utils'; export type IndexPattern = string; export enum ANALYSIS_ADVANCED_FIELDS { + ALPHA = 'alpha', ETA = 'eta', + ETA_GROWTH_RATE_PER_TREE = 'eta_growth_rate_per_tree', + DOWNSAMPLE_FACTOR = 'downsample_factor', FEATURE_BAG_FRACTION = 'feature_bag_fraction', FEATURE_INFLUENCE_THRESHOLD = 'feature_influence_threshold', GAMMA = 'gamma', LAMBDA = 'lambda', MAX_TREES = 'max_trees', + MAX_OPTIMIZATION_ROUNDS_PER_HYPERPARAMETER = 'max_optimization_rounds_per_hyperparameter', METHOD = 'method', N_NEIGHBORS = 'n_neighbors', NUM_TOP_CLASSES = 'num_top_classes', NUM_TOP_FEATURE_IMPORTANCE_VALUES = 'num_top_feature_importance_values', OUTLIER_FRACTION = 'outlier_fraction', RANDOMIZE_SEED = 'randomize_seed', + SOFT_TREE_DEPTH_LIMIT = 'soft_tree_depth_limit', + SOFT_TREE_DEPTH_TOLERANCE = 'soft_tree_depth_tolerance', } export enum OUTLIER_ANALYSIS_METHOD { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx index 67ade39d6fa7..8e25fc961c7c 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx @@ -138,14 +138,18 @@ export const AdvancedStepForm: FC = ({ const { setEstimatedModelMemoryLimit, setFormState } = actions; const { form, isJobCreated, estimatedModelMemoryLimit } = state; const { + alpha, computeFeatureInfluence, + downsampleFactor, eta, + etaGrowthRatePerTree, featureBagFraction, featureInfluenceThreshold, gamma, jobType, lambda, maxNumThreads, + maxOptimizationRoundsPerHyperparameter, maxTrees, method, modelMemoryLimit, @@ -157,6 +161,8 @@ export const AdvancedStepForm: FC = ({ outlierFraction, predictionFieldName, randomizeSeed, + softTreeDepthLimit, + softTreeDepthTolerance, useEstimatedMml, } = form; @@ -197,7 +203,7 @@ export const AdvancedStepForm: FC = ({ useEffect(() => { setFetchingAdvancedParamErrors(true); (async function () { - const { success, errorMessage, expectedMemory } = await fetchExplainData(form); + const { success, errorMessage, errorReason, expectedMemory } = await fetchExplainData(form); const paramErrors: AdvancedParamErrors = {}; if (success) { @@ -212,6 +218,8 @@ export const AdvancedStepForm: FC = ({ Object.values(ANALYSIS_ADVANCED_FIELDS).forEach((param) => { if (errorMessage.includes(`[${param}]`)) { paramErrors[param] = errorMessage; + } else if (errorReason?.includes(`[${param}]`)) { + paramErrors[param] = errorReason; } }); } @@ -219,12 +227,16 @@ export const AdvancedStepForm: FC = ({ setAdvancedParamErrors(paramErrors); })(); }, [ + alpha, + downsampleFactor, eta, + etaGrowthRatePerTree, featureBagFraction, featureInfluenceThreshold, gamma, lambda, maxNumThreads, + maxOptimizationRoundsPerHyperparameter, maxTrees, method, nNeighbors, @@ -232,6 +244,8 @@ export const AdvancedStepForm: FC = ({ numTopFeatureImportanceValues, outlierFraction, randomizeSeed, + softTreeDepthLimit, + softTreeDepthTolerance, ]); const outlierDetectionAdvancedConfig = ( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/hyper_parameters.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/hyper_parameters.tsx index 0bd817f5e275..03dfc09d97b0 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/hyper_parameters.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/hyper_parameters.tsx @@ -21,7 +21,20 @@ interface Props extends CreateAnalyticsFormProps { export const HyperParameters: FC = ({ actions, state, advancedParamErrors }) => { const { setFormState } = actions; - const { eta, featureBagFraction, gamma, lambda, maxTrees, randomizeSeed } = state.form; + const { + alpha, + downsampleFactor, + eta, + etaGrowthRatePerTree, + featureBagFraction, + gamma, + lambda, + maxOptimizationRoundsPerHyperparameter, + maxTrees, + randomizeSeed, + softTreeDepthLimit, + softTreeDepthTolerance, + } = state.form; return ( @@ -203,6 +216,215 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors /> + + + + setFormState({ alpha: e.target.value === '' ? undefined : +e.target.value }) + } + step={0.001} + min={0} + value={getNumberValue(alpha)} + /> + + + + + + setFormState({ + downsampleFactor: e.target.value === '' ? undefined : +e.target.value, + }) + } + step={0.001} + min={0} + max={1} + value={getNumberValue(downsampleFactor)} + /> + + + + + + setFormState({ + etaGrowthRatePerTree: e.target.value === '' ? undefined : +e.target.value, + }) + } + step={0.001} + min={0.5} + max={2} + value={getNumberValue(etaGrowthRatePerTree)} + /> + + + + + + setFormState({ + maxOptimizationRoundsPerHyperparameter: + e.target.value === '' ? undefined : +e.target.value, + }) + } + min={0} + max={20} + step={1} + value={getNumberValue(maxOptimizationRoundsPerHyperparameter)} + /> + + + + + + setFormState({ + softTreeDepthLimit: e.target.value === '' ? undefined : +e.target.value, + }) + } + step={0.001} + min={0} + value={getNumberValue(softTreeDepthLimit)} + /> + + + + + + setFormState({ + softTreeDepthTolerance: e.target.value === '' ? undefined : +e.target.value, + }) + } + step={0.001} + min={0.01} + value={getNumberValue(softTreeDepthTolerance)} + /> + +
); }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/shared/fetch_explain_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/shared/fetch_explain_data.ts index 9b71b5d29c0f..ec567f1f9615 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/shared/fetch_explain_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/shared/fetch_explain_data.ts @@ -6,7 +6,7 @@ */ import { ml } from '../../../../../services/ml_api_service'; -import { extractErrorMessage } from '../../../../../../../common/util/errors'; +import { extractErrorProperties } from '../../../../../../../common/util/errors'; import { DfAnalyticsExplainResponse, FieldSelectionItem } from '../../../../common/analytics'; import { getJobConfigFromFormState, @@ -23,6 +23,7 @@ export interface FetchExplainDataReturnType { export const fetchExplainData = async (formState: State['form']) => { const jobConfig = getJobConfigFromFormState(formState); let errorMessage = ''; + let errorReason = ''; let success = true; let expectedMemory = ''; let fieldSelection: FieldSelectionItem[] = []; @@ -36,8 +37,12 @@ export const fetchExplainData = async (formState: State['form']) => { expectedMemory = resp.memory_estimation?.expected_memory_without_disk; fieldSelection = resp.field_selection || []; } catch (error) { + const errObj = extractErrorProperties(error); success = false; - errorMessage = extractErrorMessage(error); + errorMessage = errObj.message; + if (errObj.causedBy) { + errorReason = errObj.causedBy; + } } return { @@ -45,5 +50,6 @@ export const fetchExplainData = async (formState: State['form']) => { expectedMemory, fieldSelection, errorMessage, + errorReason, }; }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/exploration_query_bar.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/exploration_query_bar.tsx index a017de8d43a7..b079fc154713 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/exploration_query_bar.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/exploration_query_bar.tsx @@ -150,7 +150,7 @@ export const ExplorationQueryBar: FC = ({ ({ advancedEditorRawString: '', disableSwitchToForm: false, form: { + alpha: undefined, computeFeatureInfluence: 'true', createIndexPattern: true, dependentVariable: '', @@ -127,7 +134,9 @@ export const getInitialState = (): State => ({ destinationIndexNameValid: false, destinationIndexPatternTitleExists: false, earlyStoppingEnabled: undefined, + downsampleFactor: undefined, eta: undefined, + etaGrowthRatePerTree: undefined, featureBagFraction: undefined, featureInfluenceThreshold: undefined, gamma: undefined, @@ -143,6 +152,7 @@ export const getInitialState = (): State => ({ lambda: undefined, loadingFieldOptions: false, maxNumThreads: DEFAULT_MAX_NUM_THREADS, + maxOptimizationRoundsPerHyperparameter: undefined, maxTrees: undefined, method: undefined, modelMemoryLimit: undefined, @@ -158,6 +168,8 @@ export const getInitialState = (): State => ({ requiredFieldsError: undefined, randomizeSeed: undefined, resultsField: undefined, + softTreeDepthLimit: undefined, + softTreeDepthTolerance: undefined, sourceIndex: '', sourceIndexNameEmpty: true, sourceIndexNameValid: false, @@ -233,17 +245,31 @@ export const getJobConfigFromFormState = ( analysis = Object.assign( analysis, - formState.predictionFieldName && { prediction_field_name: formState.predictionFieldName }, + formState.alpha && { alpha: formState.alpha }, formState.eta && { eta: formState.eta }, + formState.etaGrowthRatePerTree && { + eta_growth_rate_per_tree: formState.etaGrowthRatePerTree, + }, + formState.downsampleFactor && { downsample_factor: formState.downsampleFactor }, formState.featureBagFraction && { feature_bag_fraction: formState.featureBagFraction, }, formState.gamma && { gamma: formState.gamma }, formState.lambda && { lambda: formState.lambda }, + formState.maxOptimizationRoundsPerHyperparameter && { + max_optimization_rounds_per_hyperparameter: + formState.maxOptimizationRoundsPerHyperparameter, + }, formState.maxTrees && { max_trees: formState.maxTrees }, formState.randomizeSeed && { randomize_seed: formState.randomizeSeed }, formState.earlyStoppingEnabled !== undefined && { early_stopping_enabled: formState.earlyStoppingEnabled, + }, + formState.predictionFieldName && { prediction_field_name: formState.predictionFieldName }, + formState.randomizeSeed && { randomize_seed: formState.randomizeSeed }, + formState.softTreeDepthLimit && { soft_tree_depth_limit: formState.softTreeDepthLimit }, + formState.softTreeDepthTolerance && { + soft_tree_depth_tolerance: formState.softTreeDepthTolerance, } ); diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/search_panel/search_panel.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/search_panel/search_panel.tsx index 34d0b3eea80d..1988ac39d0a9 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/search_panel/search_panel.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/search_panel/search_panel.tsx @@ -110,7 +110,7 @@ export const SearchPanel: FC = ({ closePopover={() => setErrorMessage(undefined)} input={ = ({ closePopover={() => setErrorMessage(undefined)} input={ { - beforeEach(() => { +describe('Timelines', (): void => { + before(() => { cleanKibana(); + loginAndWaitForPage(OVERVIEW_URL); }); - it('Creates a timeline', () => { - cy.intercept('PATCH', '/api/timeline').as('timeline'); + describe('Toggle create timeline from plus icon', () => { + after(() => { + closeTimeline(); + }); - loginAndWaitForPage(OVERVIEW_URL); - openTimelineUsingToggle(); - addNameAndDescriptionToTimeline(timeline); + it('toggle create timeline ', () => { + createNewTimeline(); + cy.get(TIMELINE_PANEL).should('be.visible'); + }); + }); - cy.wait('@timeline').then(({ response }) => { - const timelineId = response!.body.data.persistTimeline.timeline.savedObjectId; + describe('Creates a timeline by clicking untitled timeline from bottom bar', () => { + after(() => { + closeTimeline(); + }); + before(() => { + openTimelineUsingToggle(); + addNameAndDescriptionToTimeline(timeline); populateTimeline(); + }); + + beforeEach(() => { + goToQueryTab(); + }); + + it('can be added filter', () => { addFilter(timeline.filter); - pinFirstEvent(); + cy.get(TIMELINE_FILTER(timeline.filter)).should('exist'); + }); + it('pins an event', () => { + pinFirstEvent(); cy.get(PIN_EVENT) .should('have.attr', 'aria-label') .and('match', /Unpin the event in row 2/); + }); + + it('has a lock icon', () => { cy.get(LOCKED_ICON).should('be.visible'); + }); + it('can be added notes', () => { addNotesToTimeline(timeline.notes); + cy.get(NOTES_TEXT).should('have.text', timeline.notes); + }); + + it('can be marked as favorite', () => { markAsFavorite(); waitForTimelineChanges(); - createNewTimeline(); - closeTimeline(); - openTimelineFromSettings(); - - cy.get(OPEN_TIMELINE_MODAL).should('be.visible'); - cy.contains(timeline.title).should('exist'); - cy.get(TIMELINES_DESCRIPTION).first().should('have.text', timeline.description); - cy.get(TIMELINES_PINNED_EVENT_COUNT).first().should('have.text', '1'); - cy.get(TIMELINES_NOTES_COUNT).first().should('have.text', '1'); - cy.get(TIMELINES_FAVORITE).first().should('exist'); - - openTimeline(timelineId); - - cy.get(FAVORITE_TIMELINE).should('exist'); - cy.get(TIMELINE_TITLE).should('have.text', timeline.title); - cy.get(TIMELINE_DESCRIPTION).should('have.text', timeline.description); // This is the flake part where it sometimes does not show/load the timelines correctly - cy.get(TIMELINE_QUERY).should('have.text', `${timeline.query} `); - cy.get(TIMELINE_FILTER(timeline.filter)).should('exist'); - cy.get(PIN_EVENT) - .should('have.attr', 'aria-label') - .and('match', /Unpin the event in row 2/); - cy.get(UNLOCKED_ICON).should('be.visible'); - cy.get(NOTES_TAB_BUTTON).click(); - cy.get(NOTES_TEXT_AREA).should('exist'); - - cy.get(NOTES_TEXT).should('have.text', timeline.notes); + cy.get(FAVORITE_TIMELINE).should('have.text', 'Remove from favorites'); }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/notes_tab.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/notes_tab.spec.ts new file mode 100644 index 000000000000..6653290fc2eb --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/notes_tab.spec.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { timeline } from '../../objects/timeline'; + +import { NOTES_TEXT, NOTES_TEXT_AREA } from '../../screens/timeline'; +import { createTimeline } from '../../tasks/api_calls/timelines'; + +import { cleanKibana } from '../../tasks/common'; + +import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; +import { + addNotesToTimeline, + closeTimeline, + goToNotesTab, + openTimelineById, + waitForEventsPanelToBeLoaded, +} from '../../tasks/timeline'; +import { waitForTimelinesPanelToBeLoaded } from '../../tasks/timelines'; + +import { TIMELINES_URL } from '../../urls/navigation'; + +describe('Timeline notes tab', () => { + let timelineId: string | null = null; + before(() => { + cleanKibana(); + loginAndWaitForPageWithoutDateRange(TIMELINES_URL); + waitForTimelinesPanelToBeLoaded(); + + createTimeline(timeline) + .then((response) => { + timelineId = response.body.data.persistTimeline.timeline.savedObjectId; + }) + .then(() => { + waitForTimelinesPanelToBeLoaded(); + openTimelineById(timelineId!) + .click({ force: true }) + .then(() => { + waitForEventsPanelToBeLoaded(); + goToNotesTab(); + addNotesToTimeline(timeline.notes); + }); + }); + }); + after(() => { + closeTimeline(); + }); + + it('should contain notes', () => { + cy.get(NOTES_TEXT).should('have.text', timeline.notes); + }); + + it('should render mockdown', () => { + cy.get(NOTES_TEXT_AREA).should('exist'); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/open_timeline.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/open_timeline.spec.ts new file mode 100644 index 000000000000..5d5d125082b8 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/open_timeline.spec.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { timeline } from '../../objects/timeline'; + +import { TIMELINE_DESCRIPTION, TIMELINE_TITLE, OPEN_TIMELINE_MODAL } from '../../screens/timeline'; +import { + TIMELINES_DESCRIPTION, + TIMELINES_PINNED_EVENT_COUNT, + TIMELINES_NOTES_COUNT, + TIMELINES_FAVORITE, +} from '../../screens/timelines'; +import { addNoteToTimeline } from '../../tasks/api_calls/notes'; + +import { createTimeline } from '../../tasks/api_calls/timelines'; + +import { cleanKibana } from '../../tasks/common'; + +import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; +import { + closeOpenTimelineModal, + markAsFavorite, + openTimelineById, + openTimelineFromSettings, + pinFirstEvent, + waitForEventsPanelToBeLoaded, +} from '../../tasks/timeline'; +import { waitForTimelinesPanelToBeLoaded } from '../../tasks/timelines'; + +import { TIMELINES_URL } from '../../urls/navigation'; + +describe('Open timeline', () => { + let timelineId: string | null = null; + before(() => { + cleanKibana(); + loginAndWaitForPageWithoutDateRange(TIMELINES_URL); + waitForTimelinesPanelToBeLoaded(); + + createTimeline(timeline) + .then((response) => { + timelineId = response.body.data.persistTimeline.timeline.savedObjectId; + }) + .then(() => { + const note = timeline.notes; + addNoteToTimeline(note, timelineId!).should((response) => { + expect(response.status).to.equal(200); + waitForTimelinesPanelToBeLoaded(); + openTimelineById(timelineId!) + .click({ force: true }) + .then(() => { + waitForEventsPanelToBeLoaded(); + pinFirstEvent(); + markAsFavorite(); + }); + }); + }); + }); + describe('Open timeline modal', () => { + before(() => { + openTimelineFromSettings(); + }); + + after(() => { + closeOpenTimelineModal(); + }); + + it('should open a modal', () => { + cy.get(OPEN_TIMELINE_MODAL).should('be.visible'); + }); + + it('should display timeline info - title', () => { + cy.contains(timeline.title).should('exist'); + }); + + it('should display timeline info - description', () => { + cy.get(TIMELINES_DESCRIPTION).first().should('have.text', timeline.description); + }); + + it('should display timeline info - pinned event count', () => { + cy.get(TIMELINES_PINNED_EVENT_COUNT).first().should('have.text', '1'); + }); + + it('should display timeline info - notes count', () => { + cy.get(TIMELINES_NOTES_COUNT).first().should('have.text', '1'); + }); + + it('should display timeline info - favorite timeline', () => { + cy.get(TIMELINES_FAVORITE).first().should('exist'); + }); + + it('should display timeline content - title', () => { + cy.get(TIMELINE_TITLE).should('have.text', timeline.title); + }); + + it('should display timeline content - description', () => { + cy.get(TIMELINE_DESCRIPTION).should('have.text', timeline.description); // This is the flake part where it sometimes does not show/load the timelines correctly + }); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/query_tab.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/query_tab.spec.ts new file mode 100644 index 000000000000..56cb5d870d79 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/query_tab.spec.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { timeline } from '../../objects/timeline'; + +import { UNLOCKED_ICON, PIN_EVENT, TIMELINE_FILTER, TIMELINE_QUERY } from '../../screens/timeline'; +import { addNoteToTimeline } from '../../tasks/api_calls/notes'; +import { createTimeline } from '../../tasks/api_calls/timelines'; + +import { cleanKibana } from '../../tasks/common'; + +import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; +import { + addFilter, + closeTimeline, + openTimelineById, + pinFirstEvent, + waitForEventsPanelToBeLoaded, +} from '../../tasks/timeline'; +import { waitForTimelinesPanelToBeLoaded } from '../../tasks/timelines'; + +import { TIMELINES_URL } from '../../urls/navigation'; + +describe('Timeline query tab', () => { + let timelineId: string | null = null; + before(() => { + cleanKibana(); + loginAndWaitForPageWithoutDateRange(TIMELINES_URL); + waitForTimelinesPanelToBeLoaded(); + + createTimeline(timeline) + .then((response) => { + timelineId = response.body.data.persistTimeline.timeline.savedObjectId; + }) + .then(() => { + const note = timeline.notes; + addNoteToTimeline(note, timelineId!).should((response) => { + expect(response.status).to.equal(200); + waitForTimelinesPanelToBeLoaded(); + openTimelineById(timelineId!) + .click({ force: true }) + .then(() => { + waitForEventsPanelToBeLoaded(); + pinFirstEvent(); + addFilter(timeline.filter); + }); + }); + }); + }); + + describe('Query tab', () => { + after(() => { + closeTimeline(); + }); + it('should contain the right query', () => { + cy.get(TIMELINE_QUERY).should('have.text', `${timeline.query}`); + }); + + it('should display timeline filter', () => { + cy.get(TIMELINE_FILTER(timeline.filter)).should('exist'); + }); + + it('should display pinned events', () => { + cy.get(PIN_EVENT) + .should('have.attr', 'aria-label') + .and('match', /Unpin the event in row 2/); + }); + + it('should have an unlock icon', () => { + cy.get(UNLOCKED_ICON).should('be.visible'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/screens/timeline.ts b/x-pack/plugins/security_solution/cypress/screens/timeline.ts index 78bc091e8db7..92f96a591ab5 100644 --- a/x-pack/plugins/security_solution/cypress/screens/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/screens/timeline.ts @@ -73,6 +73,8 @@ export const OPEN_TIMELINE_ICON = '[data-test-subj="open-timeline-button"]'; export const OPEN_TIMELINE_MODAL = '[data-test-subj="open-timeline-modal"]'; +export const CLOSE_OPEN_TIMELINE_MODAL_BTN = `${OPEN_TIMELINE_MODAL} > button`; + export const OPEN_TIMELINE_TEMPLATE_ICON = '[data-test-subj="open-timeline-modal-body-filter-template"]'; @@ -148,6 +150,8 @@ export const TIMELINE_FLYOUT_BODY = '[data-test-subj="query-tab-flyout-body"]'; export const TIMELINE_INSPECT_BUTTON = `${TIMELINE_FLYOUT} [data-test-subj="inspect-icon-button"]`; +export const TIMELINE_PANEL = `[data-test-subj="timeline-flyout-header-panel"]`; + export const TIMELINE_QUERY = '[data-test-subj="timelineQueryInput"]'; export const TIMELINE_SETTINGS_ICON = '[data-test-subj="settings-plus-in-circle"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/api_calls/notes.ts b/x-pack/plugins/security_solution/cypress/tasks/api_calls/notes.ts new file mode 100644 index 000000000000..0fc1a8639560 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/tasks/api_calls/notes.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const addNoteToTimeline = (note: string, timelineId: string) => + cy.request({ + method: 'POST', + url: '/api/solutions/security/graphql', + body: { + operationName: 'PersistTimelineNoteMutation', + variables: { + noteId: null, + version: null, + note: { note, timelineId }, + }, + query: + 'mutation PersistTimelineNoteMutation($noteId: ID, $version: String, $note: NoteInput!) {\n persistNote(noteId: $noteId, version: $version, note: $note) {\n code\n message\n note {\n eventId\n note\n timelineId\n timelineVersion\n noteId\n created\n createdBy\n updated\n updatedBy\n version\n __typename\n }\n __typename\n }\n}\n', + }, + headers: { 'kbn-xsrf': 'cypress-creds' }, + }); diff --git a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts index edaa5be487a0..ca4c869e0f2d 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts @@ -50,6 +50,7 @@ import { TIMELINE_EDIT_MODAL_OPEN_BUTTON, TIMELINE_EDIT_MODAL_SAVE_BUTTON, QUERY_TAB_BUTTON, + CLOSE_OPEN_TIMELINE_MODAL_BTN, } from '../screens/timeline'; import { TIMELINES_TABLE } from '../screens/timelines'; @@ -83,8 +84,20 @@ export const addNameAndDescriptionToTimeline = (timeline: Timeline) => { cy.get(TIMELINE_TITLE_INPUT).should('not.exist'); }; +export const goToNotesTab = () => { + return cy.get(NOTES_TAB_BUTTON).click({ force: true }); +}; + +export const getNotePreviewByNoteId = (noteId: string) => { + return cy.get(`[data-test-subj="note-preview-${noteId}"]`); +}; + +export const goToQueryTab = () => { + cy.get(QUERY_TAB_BUTTON).click({ force: true }); +}; + export const addNotesToTimeline = (notes: string) => { - cy.get(NOTES_TAB_BUTTON).click(); + goToNotesTab(); cy.get(NOTES_TEXT_AREA).type(notes); cy.get(ADD_NOTE_BUTTON).click(); cy.get(QUERY_TAB_BUTTON).click(); @@ -123,6 +136,10 @@ export const checkIdToggleField = () => { }); }; +export const closeOpenTimelineModal = () => { + cy.get(CLOSE_OPEN_TIMELINE_MODAL_BTN).click({ force: true }); +}; + export const closeTimeline = () => { cy.get(CLOSE_TIMELINE_BTN).filter(':visible').click({ force: true }); }; @@ -170,6 +187,10 @@ export const openTimelineTemplateFromSettings = (id: string) => { cy.get(TIMELINE_TITLE_BY_ID(id)).click({ force: true }); }; +export const openTimelineById = (timelineId: string) => { + return cy.get(TIMELINE_TITLE_BY_ID(timelineId)).click({ force: true }); +}; + export const pinFirstEvent = () => { cy.get(PIN_EVENT).first().click({ force: true }); }; @@ -223,3 +244,7 @@ export const waitForTimelineChanges = () => { export const waitForTimelinesPanelToBeLoaded = () => { cy.get(TIMELINES_TABLE).should('exist'); }; + +export const waitForEventsPanelToBeLoaded = () => { + cy.get(QUERY_TAB_BUTTON).find('.euiBadge').should('exist'); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx index 4364ca2d3465..6dad6c439ce4 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx @@ -119,7 +119,7 @@ describe('EventsViewer', () => { let testProps = { defaultModel: eventsDefaultModel, end: to, - id: 'test-stateful-events-viewer', + id: TimelineId.test, start: from, scopeId: SourcererScopeName.timeline, }; @@ -155,7 +155,7 @@ describe('EventsViewer', () => { indexName: 'auditbeat-7.10.1-2020.12.18-000001', }, tabType: 'query', - timelineId: 'test-stateful-events-viewer', + timelineId: TimelineId.test, }, type: 'x-pack/security_solution/local/timeline/TOGGLE_EXPANDED_EVENT', }); @@ -199,17 +199,22 @@ describe('EventsViewer', () => { defaultHeaders.forEach((header) => { test(`it renders the ${header.id} default EventsViewer column header`, () => { + testProps = { + ...testProps, + // Update with a new id, to force columns back to default. + id: TimelineId.test2, + }; const wrapper = mount( ); - defaultHeaders.forEach((h) => + defaultHeaders.forEach((h) => { expect(wrapper.find(`[data-test-subj="header-text-${header.id}"]`).first().exists()).toBe( true - ) - ); + ); + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx index 77573dbab0a5..254309aee906 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx @@ -117,7 +117,7 @@ interface Props { filters: Filter[]; headerFilterGroup?: React.ReactNode; height?: number; - id: string; + id: TimelineId; indexNames: string[]; indexPattern: IIndexPattern; isLive: boolean; diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx index c2fbfdb666e0..5004c23f9111 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx @@ -16,6 +16,7 @@ import { useMountAppended } from '../../utils/use_mount_appended'; import { mockEventViewerResponse } from './mock'; import { StatefulEventsViewer } from '.'; import { eventsDefaultModel } from './default_model'; +import { TimelineId } from '../../../../common/types/timeline'; import { SourcererScopeName } from '../../store/sourcerer/model'; import { useTimelineEvents } from '../../../timelines/containers'; @@ -36,7 +37,7 @@ const testProps = { defaultModel: eventsDefaultModel, end: to, indexNames: [], - id: 'test-stateful-events-viewer', + id: TimelineId.test, scopeId: SourcererScopeName.default, start: from, }; diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index 526bc312172b..2b5420674b89 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -12,6 +12,7 @@ import styled from 'styled-components'; import { inputsModel, inputsSelectors, State } from '../../store'; import { inputsActions } from '../../store/actions'; +import { TimelineId } from '../../../../common/types/timeline'; import { timelineSelectors, timelineActions } from '../../../timelines/store/timeline'; import { SubsetTimelineModel, TimelineModel } from '../../../timelines/store/timeline/model'; import { Filter } from '../../../../../../../src/plugins/data/public'; @@ -34,7 +35,7 @@ const FullScreenContainer = styled.div<{ $isFullScreen: boolean }>` export interface OwnProps { defaultModel: SubsetTimelineModel; end: string; - id: string; + id: TimelineId; scopeId: SourcererScopeName; start: string; headerFilterGroup?: React.ReactNode; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_trusted_apps_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_trusted_apps_card.tsx index 13da6ac90403..fe6f82e632f7 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_trusted_apps_card.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/endpoint_package_custom_extension/components/fleet_trusted_apps_card.tsx @@ -32,10 +32,7 @@ export const FleetTrustedAppsCard = memo(( const trustedAppsListUrlPath = getTrustedAppsListPath(); const trustedAppRouteState = useMemo(() => { - const fleetPackageCustomUrlPath = `#${pagePathGetters.integration_details({ - pkgkey, - panel: 'custom', - })}`; + const fleetPackageCustomUrlPath = `#${pagePathGetters.integration_details_custom({ pkgkey })}`; return { backButtonLabel: i18n.translate( 'xpack.securitySolution.endpoint.fleetCustomExtension.backButtonLabel', diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts index 8df0c92ca83e..b5864a0a83cf 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts @@ -19,6 +19,7 @@ const initialState: DataState = { data: null, }, resolverComponentInstanceID: undefined, + indices: [], }; /* eslint-disable complexity */ export const dataReducer: Reducer = (state = initialState, action) => { @@ -35,6 +36,7 @@ export const dataReducer: Reducer = (state = initialS }, resolverComponentInstanceID: action.payload.resolverComponentInstanceID, locationSearch: action.payload.locationSearch, + indices: action.payload.indices, }; const panelViewAndParameters = selectors.panelViewAndParameters(nextState); return { diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts index c372c98c6e06..b864bb254a5f 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts @@ -664,4 +664,85 @@ describe('data state', () => { `); }); }); + describe('when the resolver tree response is complete, still use non-default indices', () => { + beforeEach(() => { + const { resolverTree } = mockTreeWithNoAncestorsAnd2Children({ + originID: 'a', + firstChildID: 'b', + secondChildID: 'c', + }); + const { schema, dataSource } = endpointSourceSchema(); + actions = [ + { + type: 'serverReturnedResolverData', + payload: { + result: resolverTree, + dataSource, + schema, + parameters: { + databaseDocumentID: '', + indices: ['someNonDefaultIndex'], + filters: {}, + }, + }, + }, + ]; + }); + it('should have an empty array for tree parameter indices, and a non empty array for event indices', () => { + const treeParameterIndices = selectors.treeParameterIndices(state()); + expect(treeParameterIndices.length).toBe(0); + const eventIndices = selectors.eventIndices(state()); + expect(eventIndices.length).toBe(1); + }); + }); + describe('when the resolver tree response is pending use the same indices the user is currently looking at data from', () => { + beforeEach(() => { + const { resolverTree } = mockTreeWithNoAncestorsAnd2Children({ + originID: 'a', + firstChildID: 'b', + secondChildID: 'c', + }); + const { schema, dataSource } = endpointSourceSchema(); + actions = [ + { + type: 'serverReturnedResolverData', + payload: { + result: resolverTree, + dataSource, + schema, + parameters: { + databaseDocumentID: '', + indices: ['defaultIndex'], + filters: {}, + }, + }, + }, + { + type: 'appReceivedNewExternalProperties', + payload: { + databaseDocumentID: '', + resolverComponentInstanceID: '', + locationSearch: '', + indices: ['someNonDefaultIndex', 'someOtherIndex'], + shouldUpdate: false, + filters: {}, + }, + }, + { + type: 'appRequestedResolverData', + payload: { + databaseDocumentID: '', + indices: ['someNonDefaultIndex', 'someOtherIndex'], + filters: {}, + }, + }, + ]; + }); + it('should have an empty array for tree parameter indices, and the same set of indices as the last tree response', () => { + const treeParameterIndices = selectors.treeParameterIndices(state()); + expect(treeParameterIndices.length).toBe(0); + const eventIndices = selectors.eventIndices(state()); + expect(eventIndices.length).toBe(1); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index a39aa4f0cd98..fb6fb6073d7c 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -63,6 +63,13 @@ export function resolverComponentInstanceID(state: DataState): string { return state.resolverComponentInstanceID ? state.resolverComponentInstanceID : ''; } +/** + * The indices resolver should use, passed in as external props. + */ +const currentIndices = (state: DataState): string[] => { + return state.indices; +}; + /** * The last NewResolverTree we received, if any. It may be stale (it might not be for the same databaseDocumentID that * we're currently interested in. @@ -71,6 +78,12 @@ const resolverTreeResponse = (state: DataState): NewResolverTree | undefined => return state.tree?.lastResponse?.successful ? state.tree?.lastResponse.result : undefined; }; +const lastResponseIndices = (state: DataState): string[] | undefined => { + return state.tree?.lastResponse?.successful + ? state.tree?.lastResponse?.parameters?.indices + : undefined; +}; + /** * If we received a NewResolverTree, return the schema associated with that tree, otherwise return undefined. * As of writing, this is only used for the info popover in the graph_controls panel @@ -336,10 +349,22 @@ export const timeRangeFilters = createSelector( /** * The indices to use for the requests with the backend. */ -export const treeParamterIndices = createSelector(treeParametersToFetch, (parameters) => { +export const treeParameterIndices = createSelector(treeParametersToFetch, (parameters) => { return parameters?.indices ?? []; }); +/** + * Panel requests should not use indices derived from the tree parameter selector, as this is only defined briefly while the resolver_tree_fetcher middleware is running. + * Instead, panel requests should use the indices used by the last good request, falling back to the indices passed as external props. + */ +export const eventIndices = createSelector( + lastResponseIndices, + currentIndices, + function eventIndices(lastIndices, current): string[] { + return lastIndices ?? current ?? []; + } +); + export const layout: (state: DataState) => IsometricTaxiLayout = createSelector( tree, originID, diff --git a/x-pack/plugins/security_solution/public/resolver/store/middleware/current_related_event_fetcher.ts b/x-pack/plugins/security_solution/public/resolver/store/middleware/current_related_event_fetcher.ts index 3b8389182e99..33772dddd676 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/middleware/current_related_event_fetcher.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/middleware/current_related_event_fetcher.ts @@ -32,7 +32,7 @@ export function CurrentRelatedEventFetcher( const state = api.getState(); const newParams = selectors.panelViewAndParameters(state); - const indices = selectors.treeParameterIndices(state); + const indices = selectors.eventIndices(state); const oldParams = last; last = newParams; diff --git a/x-pack/plugins/security_solution/public/resolver/store/middleware/node_data_fetcher.ts b/x-pack/plugins/security_solution/public/resolver/store/middleware/node_data_fetcher.ts index 696e7f921673..074fdf753579 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/middleware/node_data_fetcher.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/middleware/node_data_fetcher.ts @@ -38,7 +38,7 @@ export function NodeDataFetcher( * This gets the visible nodes that we haven't already requested or received data for */ const newIDsToRequest: Set = selectors.newIDsToRequest(state)(Number.POSITIVE_INFINITY); - const indices = selectors.treeParameterIndices(state); + const indices = selectors.eventIndices(state); if (newIDsToRequest.size <= 0) { return; diff --git a/x-pack/plugins/security_solution/public/resolver/store/middleware/related_events_fetcher.ts b/x-pack/plugins/security_solution/public/resolver/store/middleware/related_events_fetcher.ts index fbce03caf64d..19a11e07a9d8 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/middleware/related_events_fetcher.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/middleware/related_events_fetcher.ts @@ -27,7 +27,7 @@ export function RelatedEventsFetcher( const newParams = selectors.panelViewAndParameters(state); const isLoadingMoreEvents = selectors.isLoadingMoreNodeEventsInCategory(state); - const indices = selectors.treeParameterIndices(state); + const indices = selectors.eventIndices(state); const oldParams = last; const timeRangeFilters = selectors.timeRangeFilters(state); diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts index a845de57bbdc..4c088a8be4ed 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts @@ -80,9 +80,14 @@ export const treeRequestParametersToAbort = composeSelectors( */ export const treeParameterIndices = composeSelectors( dataStateSelector, - dataSelectors.treeParamterIndices + dataSelectors.treeParameterIndices ); +/** + * An array of indices to use for resolver panel requests. + */ +export const eventIndices = composeSelectors(dataStateSelector, dataSelectors.eventIndices); + export const resolverComponentInstanceID = composeSelectors( dataStateSelector, dataSelectors.resolverComponentInstanceID diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index d3ddc51429cc..e6a004938a26 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -376,6 +376,8 @@ export interface DataState { */ readonly resolverComponentInstanceID?: string; + readonly indices: string[]; + /** * The `search` part of the URL. */ diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx index 3783f5591c43..f57ce42e7e07 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx @@ -18,6 +18,7 @@ import { kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; +import { TimelineId } from '../../../../common/types/timeline'; import { createStore, State } from '../../../common/store'; import * as timelineActions from '../../store/timeline/actions'; @@ -43,7 +44,7 @@ describe('Flyout', () => { const { storage } = createSecuritySolutionStorageMock(); const props = { onAppLeave: jest.fn(), - timelineId: 'test', + timelineId: TimelineId.test, }; beforeEach(() => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx index f7518c2c34f6..bd7c7fbd1941 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx @@ -26,7 +26,7 @@ const Visible = styled.div<{ show?: boolean }>` Visible.displayName = 'Visible'; interface OwnProps { - timelineId: string; + timelineId: TimelineId; onAppLeave: (handler: AppLeaveHandler) => void; } diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.test.tsx index e16cec78cf13..4ccc7ef5b5bc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.test.tsx @@ -9,13 +9,14 @@ import { shallow } from 'enzyme'; import React from 'react'; import { TestProviders } from '../../../../common/mock'; +import { TimelineId } from '../../../../../common/types/timeline'; import { Pane } from '.'; describe('Pane', () => { test('renders correctly against snapshot', () => { const EmptyComponent = shallow( - + ); expect(EmptyComponent.find('Pane')).toMatchSnapshot(); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx index a4d85bd76b10..e63ffedf3da7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx @@ -11,12 +11,13 @@ import styled from 'styled-components'; import { useDispatch } from 'react-redux'; import { StatefulTimeline } from '../../timeline'; +import { TimelineId } from '../../../../../common/types/timeline'; import * as i18n from './translations'; import { timelineActions } from '../../../store/timeline'; import { focusActiveTimelineButton } from '../../timeline/helpers'; interface FlyoutPaneComponentProps { - timelineId: string; + timelineId: TimelineId; } const EuiFlyoutContainer = styled.div` diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.test.tsx index c0e1a54faa8d..1286208bff9e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.test.tsx @@ -15,7 +15,6 @@ import { } from '../../../common/containers/use_full_screen'; import { mockTimelineModel, TestProviders } from '../../../common/mock'; import { TimelineId } from '../../../../common/types/timeline'; - import { GraphOverlay } from '.'; jest.mock('../../../common/hooks/use_selector', () => ({ @@ -28,6 +27,10 @@ jest.mock('../../../common/containers/use_full_screen', () => ({ useTimelineFullScreen: jest.fn(), })); +jest.mock('../../../resolver/view/use_resolver_query_params_cleaner'); +jest.mock('../../../resolver/view/use_state_syncing_actions'); +jest.mock('../../../resolver/view/use_sync_selected_node'); + describe('GraphOverlay', () => { beforeEach(() => { (useGlobalFullScreen as jest.Mock).mockReturnValue({ @@ -42,12 +45,11 @@ describe('GraphOverlay', () => { describe('when used in an events viewer (i.e. in the Detections view, or the Host > Events view)', () => { const isEventViewer = true; - const timelineId = 'used-as-an-events-viewer'; test('it has 100% width when isEventViewer is true and NOT in full screen mode', async () => { const wrapper = mount( - + ); @@ -69,7 +71,7 @@ describe('GraphOverlay', () => { const wrapper = mount( - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx index 1b3a0c21ef68..9c9c56461609 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx @@ -20,7 +20,7 @@ import styled from 'styled-components'; import { FULL_SCREEN } from '../timeline/body/column_headers/translations'; import { EXIT_FULL_SCREEN } from '../../../common/components/exit_full_screen/translations'; -import { DEFAULT_INDEX_KEY, FULL_SCREEN_TOGGLED_CLASS_NAME } from '../../../../common/constants'; +import { FULL_SCREEN_TOGGLED_CLASS_NAME } from '../../../../common/constants'; import { useGlobalFullScreen, useTimelineFullScreen, @@ -30,6 +30,8 @@ import { TimelineId } from '../../../../common/types/timeline'; import { timelineSelectors } from '../../store/timeline'; import { timelineDefaults } from '../../store/timeline/defaults'; import { isFullScreen } from '../timeline/body/column_headers'; +import { useSourcererScope } from '../../../common/containers/sourcerer'; +import { SourcererScopeName } from '../../../common/store/sourcerer/model'; import { updateTimelineGraphEventId } from '../../../timelines/store/timeline/actions'; import { Resolver } from '../../../resolver/view'; import { @@ -38,8 +40,6 @@ import { endSelector, } from '../../../common/components/super_date_picker/selectors'; import * as i18n from './translations'; -import { useUiSetting$ } from '../../../common/lib/kibana'; -import { useSignalIndex } from '../../../detections/containers/detection_engine/alerts/use_signal_index'; const OverlayContainer = styled.div` ${({ $restrictWidth }: { $restrictWidth: boolean }) => @@ -61,14 +61,14 @@ const FullScreenButtonIcon = styled(EuiButtonIcon)` interface OwnProps { isEventViewer: boolean; - timelineId: string; + timelineId: TimelineId; } interface NavigationProps { fullScreen: boolean; globalFullScreen: boolean; onCloseOverlay: () => void; - timelineId: string; + timelineId: TimelineId; timelineFullScreen: boolean; toggleFullScreen: () => void; } @@ -169,16 +169,14 @@ const GraphOverlayComponent: React.FC = ({ isEventViewer, timelineId } globalFullScreen, ]); - const { signalIndexName } = useSignalIndex(); - const [siemDefaultIndices] = useUiSetting$(DEFAULT_INDEX_KEY); - const indices: string[] | null = useMemo(() => { - if (signalIndexName === null) { - return null; - } else { - return [...siemDefaultIndices, signalIndexName]; - } - }, [signalIndexName, siemDefaultIndices]); + let sourcereScope = SourcererScopeName.default; + if ([TimelineId.detectionsRulesDetailsPage, TimelineId.detectionsPage].includes(timelineId)) { + sourcereScope = SourcererScopeName.detections; + } else if (timelineId === TimelineId.active) { + sourcereScope = SourcererScopeName.timeline; + } + const { selectedPatterns } = useSourcererScope(sourcereScope); return ( = ({ isEventViewer, timelineId } - {graphEventId !== undefined && indices !== null ? ( + {graphEventId !== undefined ? ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/graph_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/graph_tab_content/index.tsx index db4867e1abfe..1678a92c4cda 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/graph_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/graph_tab_content/index.tsx @@ -9,10 +9,11 @@ import React, { useMemo } from 'react'; import { timelineSelectors } from '../../../store/timeline'; import { useShallowEqualSelector } from '../../../../common/hooks/use_selector'; +import { TimelineId } from '../../../../../common/types/timeline'; import { GraphOverlay } from '../../graph_overlay'; interface GraphTabContentProps { - timelineId: string; + timelineId: TimelineId; } const GraphTabContentComponent: React.FC = ({ timelineId }) => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx index 219d32f147b6..e7422e32805a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx @@ -12,7 +12,7 @@ import useResizeObserver from 'use-resize-observer/polyfilled'; import { DragDropContextWrapper } from '../../../common/components/drag_and_drop/drag_drop_context_wrapper'; import '../../../common/mock/match_media'; import { mockBrowserFields, mockDocValueFields } from '../../../common/containers/source/mock'; - +import { TimelineId } from '../../../../common/types/timeline'; import { mockIndexNames, mockIndexPattern, TestProviders } from '../../../common/mock'; import { StatefulTimeline, Props as StatefulTimelineOwnProps } from './index'; @@ -55,7 +55,7 @@ jest.mock('../../../common/containers/sourcerer', () => { }); describe('StatefulTimeline', () => { const props: StatefulTimelineOwnProps = { - timelineId: 'timeline-test', + timelineId: TimelineId.test, }; beforeEach(() => { @@ -91,7 +91,7 @@ describe('StatefulTimeline', () => { ); expect( wrapper - .find(`[data-timeline-id="timeline-test"].${SELECTOR_TIMELINE_GLOBAL_CONTAINER}`) + .find(`[data-timeline-id="test"].${SELECTOR_TIMELINE_GLOBAL_CONTAINER}`) .first() .exists() ).toEqual(true); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx index 9cb95daba685..c37fc93e33b0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx @@ -18,7 +18,7 @@ import { isTab } from '../../../common/components/accessibility/helpers'; import { useSourcererScope } from '../../../common/containers/sourcerer'; import { SourcererScopeName } from '../../../common/store/sourcerer/model'; import { FlyoutHeader, FlyoutHeaderPanel } from '../flyout/header'; -import { TimelineType, TimelineTabs } from '../../../../common/types/timeline'; +import { TimelineType, TimelineTabs, TimelineId } from '../../../../common/types/timeline'; import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/hooks/use_selector'; import { activeTimeline } from '../../containers/active_timeline_context'; import { EVENTS_COUNT_BUTTON_CLASS_NAME, onTimelineTabKeyPressed } from './helpers'; @@ -35,7 +35,7 @@ const TimelineTemplateBadge = styled.div` `; export interface Props { - timelineId: string; + timelineId: TimelineId; } const TimelineSavingProgressComponent: React.FC = ({ timelineId }) => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx index 9f6bfcf7e320..ca70e4ae6468 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx @@ -9,7 +9,7 @@ import { EuiBadge, EuiLoadingContent, EuiTabs, EuiTab } from '@elastic/eui'; import React, { lazy, memo, Suspense, useCallback, useEffect, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import styled from 'styled-components'; -import { TimelineTabs } from '../../../../../common/types/timeline'; +import { TimelineTabs, TimelineId } from '../../../../../common/types/timeline'; import { useShallowEqualSelector, @@ -42,7 +42,7 @@ const NotesTabContent = lazy(() => import('../notes_tab_content')); const PinnedTabContent = lazy(() => import('../pinned_tab_content')); interface BasicTimelineTab { - timelineId: string; + timelineId: TimelineId; graphEventId?: string; } diff --git a/x-pack/plugins/security_solution/server/endpoint/ingest_integration.test.ts b/x-pack/plugins/security_solution/server/endpoint/ingest_integration.test.ts index f4cdb28a01b8..733e25947347 100644 --- a/x-pack/plugins/security_solution/server/endpoint/ingest_integration.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/ingest_integration.test.ts @@ -11,10 +11,7 @@ import { policyFactory, policyFactoryWithoutPaidFeatures, } from '../../common/endpoint/models/policy_config'; -import { - getManifestManagerMock, - ManifestManagerMockType, -} from './services/artifacts/manifest_manager/manifest_manager.mock'; +import { buildManifestManagerMock } from './services/artifacts/manifest_manager/manifest_manager.mock'; import { getPackagePolicyCreateCallback, getPackagePolicyUpdateCallback, @@ -32,6 +29,12 @@ import { ProtectionModes } from '../../common/endpoint/types'; import type { SecuritySolutionRequestHandlerContext } from '../types'; import { getExceptionListClientMock } from '../../../lists/server/services/exception_lists/exception_list_client.mock'; import { ExceptionListClient } from '../../../lists/server'; +import { InternalArtifactCompleteSchema } from './schemas/artifacts'; +import { ManifestManager } from './services/artifacts/manifest_manager'; +import { getMockArtifacts, toArtifactRecords } from './lib/artifacts/mocks'; +import { Manifest } from './lib/artifacts'; +import { NewPackagePolicy } from '../../../fleet/common/types/models'; +import { ManifestSchema } from '../../common/endpoint/schema/manifest'; describe('ingest_integration tests ', () => { let endpointAppContextMock: EndpointAppContextServiceStartContract; @@ -53,21 +56,25 @@ describe('ingest_integration tests ', () => { licenseService = new LicenseService(); licenseService.start(licenseEmitter); }); + afterEach(() => { licenseService.stop(); licenseEmitter.complete(); }); - describe('ingest_integration sanity checks', () => { - beforeEach(() => { - licenseEmitter.next(Platinum); // set license level to platinum + describe('package policy init callback (atifacts manifest initialisation tests)', () => { + const createNewEndpointPolicyInput = (manifest: ManifestSchema) => ({ + type: 'endpoint', + enabled: true, + streams: [], + config: { + policy: { value: policyFactory() }, + artifact_manifest: { value: manifest }, + }, }); - test('policy is updated with initial manifest', async () => { - const logger = loggingSystemMock.create().get('ingest_integration.test'); - const manifestManager = getManifestManagerMock({ - mockType: ManifestManagerMockType.InitialSystemState, - }); + const invokeCallback = async (manifestManager: ManifestManager): Promise => { + const logger = loggingSystemMock.create().get('ingest_integration.test'); const callback = getPackagePolicyCreateCallback( logger, manifestManager, @@ -78,175 +85,153 @@ describe('ingest_integration tests ', () => { licenseService, exceptionListClient ); - const policyConfig = createNewPackagePolicyMock(); // policy config without manifest - const newPolicyConfig = await callback(policyConfig, ctx, req); // policy config WITH manifest - expect(newPolicyConfig.inputs[0]!.type).toEqual('endpoint'); - expect(newPolicyConfig.inputs[0]!.config!.policy.value).toEqual(policyFactory()); - expect(newPolicyConfig.inputs[0]!.config!.artifact_manifest.value).toEqual({ - artifacts: { - 'endpoint-exceptionlist-macos-v1': { - compression_algorithm: 'zlib', - decoded_sha256: 'd801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', - decoded_size: 14, - encoded_sha256: 'f8e6afa1d5662f5b37f83337af774b5785b5b7f1daee08b7b00c2d6813874cda', - encoded_size: 22, - encryption_algorithm: 'none', - relative_url: - '/api/endpoint/artifacts/download/endpoint-exceptionlist-macos-v1/d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', - }, - 'endpoint-exceptionlist-windows-v1': { - compression_algorithm: 'zlib', - decoded_sha256: 'd801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', - decoded_size: 14, - encoded_sha256: 'f8e6afa1d5662f5b37f83337af774b5785b5b7f1daee08b7b00c2d6813874cda', - encoded_size: 22, - encryption_algorithm: 'none', - relative_url: - '/api/endpoint/artifacts/download/endpoint-exceptionlist-windows-v1/d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', - }, - 'endpoint-trustlist-linux-v1': { - compression_algorithm: 'zlib', - decoded_sha256: '1a8295e6ccb93022c6f5ceb8997b29f2912389b3b38f52a8f5a2ff7b0154b1bc', - decoded_size: 287, - encoded_sha256: 'c3dec543df1177561ab2aa74a37997ea3c1d748d532a597884f5a5c16670d56c', - encoded_size: 133, - encryption_algorithm: 'none', - relative_url: - '/api/endpoint/artifacts/download/endpoint-trustlist-linux-v1/1a8295e6ccb93022c6f5ceb8997b29f2912389b3b38f52a8f5a2ff7b0154b1bc', - }, - 'endpoint-trustlist-macos-v1': { - compression_algorithm: 'zlib', - decoded_sha256: '1a8295e6ccb93022c6f5ceb8997b29f2912389b3b38f52a8f5a2ff7b0154b1bc', - decoded_size: 287, - encoded_sha256: 'c3dec543df1177561ab2aa74a37997ea3c1d748d532a597884f5a5c16670d56c', - encoded_size: 133, - encryption_algorithm: 'none', - relative_url: - '/api/endpoint/artifacts/download/endpoint-trustlist-macos-v1/1a8295e6ccb93022c6f5ceb8997b29f2912389b3b38f52a8f5a2ff7b0154b1bc', - }, - 'endpoint-trustlist-windows-v1': { - compression_algorithm: 'zlib', - decoded_sha256: '1a8295e6ccb93022c6f5ceb8997b29f2912389b3b38f52a8f5a2ff7b0154b1bc', - decoded_size: 287, - encoded_sha256: 'c3dec543df1177561ab2aa74a37997ea3c1d748d532a597884f5a5c16670d56c', - encoded_size: 133, - encryption_algorithm: 'none', - relative_url: - '/api/endpoint/artifacts/download/endpoint-trustlist-windows-v1/1a8295e6ccb93022c6f5ceb8997b29f2912389b3b38f52a8f5a2ff7b0154b1bc', - }, - }, - manifest_version: '1.0.0', - schema_version: 'v1', - }); + return callback(createNewPackagePolicyMock(), ctx, req); + }; + + const TEST_POLICY_ID_1 = 'c6d16e42-c32d-4dce-8a88-113cfe276ad1'; + const TEST_POLICY_ID_2 = '93c46720-c217-11ea-9906-b5b8a21b268e'; + const ARTIFACT_NAME_EXCEPTIONS_MACOS = 'endpoint-exceptionlist-macos-v1'; + const ARTIFACT_NAME_TRUSTED_APPS_MACOS = 'endpoint-trustlist-macos-v1'; + const ARTIFACT_NAME_TRUSTED_APPS_WINDOWS = 'endpoint-trustlist-windows-v1'; + let ARTIFACT_EXCEPTIONS_MACOS: InternalArtifactCompleteSchema; + let ARTIFACT_EXCEPTIONS_WINDOWS: InternalArtifactCompleteSchema; + let ARTIFACT_TRUSTED_APPS_MACOS: InternalArtifactCompleteSchema; + let ARTIFACT_TRUSTED_APPS_WINDOWS: InternalArtifactCompleteSchema; + + beforeAll(async () => { + const artifacts = await getMockArtifacts({ compress: true }); + ARTIFACT_EXCEPTIONS_MACOS = artifacts[0]; + ARTIFACT_EXCEPTIONS_WINDOWS = artifacts[1]; + ARTIFACT_TRUSTED_APPS_MACOS = artifacts[2]; + ARTIFACT_TRUSTED_APPS_WINDOWS = artifacts[3]; }); - test('policy is returned even if error is encountered during artifact creation', async () => { - const logger = loggingSystemMock.create().get('ingest_integration.test'); - const manifestManager = getManifestManagerMock(); - manifestManager.pushArtifacts = jest.fn().mockResolvedValue([new Error('error updating')]); - const lastComputed = await manifestManager.getLastComputedManifest(); + beforeEach(() => { + licenseEmitter.next(Platinum); // set license level to platinum + }); - const callback = getPackagePolicyCreateCallback( - logger, - manifestManager, - endpointAppContextMock.appClientFactory, - maxTimelineImportExportSize, - endpointAppContextMock.security, - endpointAppContextMock.alerts, - licenseService, - exceptionListClient - ); - const policyConfig = createNewPackagePolicyMock(); - const newPolicyConfig = await callback(policyConfig, ctx, req); + test('default manifest is taken when there is none and there are errors building new one', async () => { + const manifestManager = buildManifestManagerMock(); + manifestManager.getLastComputedManifest = jest.fn().mockResolvedValue(null); + manifestManager.buildNewManifest = jest.fn().mockRejectedValue(new Error()); - expect(newPolicyConfig.inputs[0]!.type).toEqual('endpoint'); - expect(newPolicyConfig.inputs[0]!.config!.policy.value).toEqual(policyFactory()); - expect(newPolicyConfig.inputs[0]!.config!.artifact_manifest.value).toEqual( - lastComputed!.toEndpointFormat() + expect((await invokeCallback(manifestManager)).inputs[0]).toStrictEqual( + createNewEndpointPolicyInput({ + artifacts: {}, + manifest_version: '1.0.0', + schema_version: 'v1', + }) ); + + expect(manifestManager.buildNewManifest).toHaveBeenCalledWith(); + expect(manifestManager.pushArtifacts).not.toHaveBeenCalled(); + expect(manifestManager.commit).not.toHaveBeenCalled(); }); - test('initial policy creation succeeds if manifest retrieval fails', async () => { - const logger = loggingSystemMock.create().get('ingest_integration.test'); - const manifestManager = getManifestManagerMock({ - mockType: ManifestManagerMockType.InitialSystemState, - }); - const lastComputed = await manifestManager.getLastComputedManifest(); - expect(lastComputed).toEqual(null); + test('default manifest is taken when there is none and there are errors pushing artifacts', async () => { + const newManifest = Manifest.getDefault(); + newManifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); - manifestManager.buildNewManifest = jest.fn().mockRejectedValue(new Error('abcd')); - const callback = getPackagePolicyCreateCallback( - logger, - manifestManager, - endpointAppContextMock.appClientFactory, - maxTimelineImportExportSize, - endpointAppContextMock.security, - endpointAppContextMock.alerts, - licenseService, - exceptionListClient + const manifestManager = buildManifestManagerMock(); + manifestManager.getLastComputedManifest = jest.fn().mockResolvedValue(null); + manifestManager.buildNewManifest = jest.fn().mockResolvedValue(newManifest); + manifestManager.pushArtifacts = jest.fn().mockResolvedValue([new Error()]); + + expect((await invokeCallback(manifestManager)).inputs[0]).toStrictEqual( + createNewEndpointPolicyInput({ + artifacts: {}, + manifest_version: '1.0.0', + schema_version: 'v1', + }) ); - const policyConfig = createNewPackagePolicyMock(); - const newPolicyConfig = await callback(policyConfig, ctx, req); - expect(newPolicyConfig.inputs[0]!.type).toEqual('endpoint'); - expect(newPolicyConfig.inputs[0]!.config!.policy.value).toEqual(policyFactory()); + expect(manifestManager.buildNewManifest).toHaveBeenCalledWith(); + expect(manifestManager.pushArtifacts).toHaveBeenCalledWith([ARTIFACT_EXCEPTIONS_MACOS]); + expect(manifestManager.commit).not.toHaveBeenCalled(); }); - test('subsequent policy creations succeed', async () => { - const logger = loggingSystemMock.create().get('ingest_integration.test'); - const manifestManager = getManifestManagerMock(); - const lastComputed = await manifestManager.getLastComputedManifest(); + test('default manifest is taken when there is none and there are errors commiting manifest', async () => { + const newManifest = Manifest.getDefault(); + newManifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); - manifestManager.buildNewManifest = jest.fn().mockResolvedValue(lastComputed); // no diffs - const callback = getPackagePolicyCreateCallback( - logger, - manifestManager, - endpointAppContextMock.appClientFactory, - maxTimelineImportExportSize, - endpointAppContextMock.security, - endpointAppContextMock.alerts, - licenseService, - exceptionListClient - ); - const policyConfig = createNewPackagePolicyMock(); - const newPolicyConfig = await callback(policyConfig, ctx, req); + const manifestManager = buildManifestManagerMock(); + manifestManager.getLastComputedManifest = jest.fn().mockResolvedValue(null); + manifestManager.buildNewManifest = jest.fn().mockResolvedValue(newManifest); + manifestManager.pushArtifacts = jest.fn().mockResolvedValue([]); + manifestManager.commit = jest.fn().mockRejectedValue(new Error()); - expect(newPolicyConfig.inputs[0]!.type).toEqual('endpoint'); - expect(newPolicyConfig.inputs[0]!.config!.policy.value).toEqual(policyFactory()); - expect(newPolicyConfig.inputs[0]!.config!.artifact_manifest.value).toEqual( - lastComputed!.toEndpointFormat() + expect((await invokeCallback(manifestManager)).inputs[0]).toStrictEqual( + createNewEndpointPolicyInput({ + artifacts: {}, + manifest_version: '1.0.0', + schema_version: 'v1', + }) ); + + expect(manifestManager.buildNewManifest).toHaveBeenCalledWith(); + expect(manifestManager.pushArtifacts).toHaveBeenCalledWith([ARTIFACT_EXCEPTIONS_MACOS]); + expect(manifestManager.commit).toHaveBeenCalledWith(newManifest); }); - test('policy creation succeeds even if endpoint exception list creation fails', async () => { - const mockError = new Error('error creating endpoint list'); - const logger = loggingSystemMock.create().get('ingest_integration.test'); - const manifestManager = getManifestManagerMock(); - const lastComputed = await manifestManager.getLastComputedManifest(); - exceptionListClient.createEndpointList = jest.fn().mockRejectedValue(mockError); - const callback = getPackagePolicyCreateCallback( - logger, - manifestManager, - endpointAppContextMock.appClientFactory, - maxTimelineImportExportSize, - endpointAppContextMock.security, - endpointAppContextMock.alerts, - licenseService, - exceptionListClient + test('manifest is created successfuly when there is none', async () => { + const newManifest = Manifest.getDefault(); + newManifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + newManifest.addEntry(ARTIFACT_TRUSTED_APPS_MACOS); + + const manifestManager = buildManifestManagerMock(); + manifestManager.getLastComputedManifest = jest.fn().mockResolvedValue(null); + manifestManager.buildNewManifest = jest.fn().mockResolvedValue(newManifest); + manifestManager.pushArtifacts = jest.fn().mockResolvedValue([]); + manifestManager.commit = jest.fn().mockResolvedValue(null); + + expect((await invokeCallback(manifestManager)).inputs[0]).toStrictEqual( + createNewEndpointPolicyInput({ + artifacts: toArtifactRecords({ + [ARTIFACT_NAME_EXCEPTIONS_MACOS]: ARTIFACT_EXCEPTIONS_MACOS, + [ARTIFACT_NAME_TRUSTED_APPS_MACOS]: ARTIFACT_TRUSTED_APPS_MACOS, + }), + manifest_version: '1.0.0', + schema_version: 'v1', + }) ); - const policyConfig = createNewPackagePolicyMock(); - const newPolicyConfig = await callback(policyConfig, ctx, req); - expect(exceptionListClient.createEndpointList).toHaveBeenCalled(); - expect(newPolicyConfig.inputs[0]!.type).toEqual('endpoint'); - expect(newPolicyConfig.inputs[0]!.config!.policy.value).toEqual(policyFactory()); - expect(newPolicyConfig.inputs[0]!.config!.artifact_manifest.value).toEqual( - lastComputed!.toEndpointFormat() + expect(manifestManager.buildNewManifest).toHaveBeenCalledWith(); + expect(manifestManager.pushArtifacts).toHaveBeenCalledWith([ + ARTIFACT_EXCEPTIONS_MACOS, + ARTIFACT_TRUSTED_APPS_MACOS, + ]); + expect(manifestManager.commit).toHaveBeenCalledWith(newManifest); + }); + + test('policy is updated with only default entries from manifest', async () => { + const manifest = new Manifest({ soVersion: '1.0.1', semanticVersion: '1.0.1' }); + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + manifest.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_1); + manifest.addEntry(ARTIFACT_TRUSTED_APPS_MACOS, TEST_POLICY_ID_2); + manifest.addEntry(ARTIFACT_TRUSTED_APPS_WINDOWS); + + const manifestManager = buildManifestManagerMock(); + manifestManager.getLastComputedManifest = jest.fn().mockResolvedValue(manifest); + + expect((await invokeCallback(manifestManager)).inputs[0]).toStrictEqual( + createNewEndpointPolicyInput({ + artifacts: toArtifactRecords({ + [ARTIFACT_NAME_EXCEPTIONS_MACOS]: ARTIFACT_EXCEPTIONS_MACOS, + [ARTIFACT_NAME_TRUSTED_APPS_WINDOWS]: ARTIFACT_TRUSTED_APPS_WINDOWS, + }), + manifest_version: '1.0.1', + schema_version: 'v1', + }) ); + + expect(manifestManager.buildNewManifest).not.toHaveBeenCalled(); + expect(manifestManager.pushArtifacts).not.toHaveBeenCalled(); + expect(manifestManager.commit).not.toHaveBeenCalled(); }); }); - describe('when the license is below platinum', () => { + + describe('package policy update callback (when the license is below platinum)', () => { beforeEach(() => { licenseEmitter.next(Gold); // set license level to gold }); @@ -271,7 +256,8 @@ describe('ingest_integration tests ', () => { expect(updatedPolicyConfig.inputs[0]!.config!.policy.value).toEqual(mockPolicy); }); }); - describe('when the license is at least platinum', () => { + + describe('package policy update callback (when the license is at least platinum)', () => { beforeEach(() => { licenseEmitter.next(Platinum); // set license level to platinum }); diff --git a/x-pack/plugins/security_solution/server/endpoint/ingest_integration.ts b/x-pack/plugins/security_solution/server/endpoint/ingest_integration.ts index 4dab1c305d17..080a8474da54 100644 --- a/x-pack/plugins/security_solution/server/endpoint/ingest_integration.ts +++ b/x-pack/plugins/security_solution/server/endpoint/ingest_integration.ts @@ -39,37 +39,18 @@ const getManifest = async (logger: Logger, manifestManager: ManifestManager): Pr if (manifest == null) { // New computed manifest based on current state of exception list const newManifest = await manifestManager.buildNewManifest(); - const diffs = newManifest.diff(Manifest.getDefault()); - - // Compress new artifacts - const adds = diffs.filter((diff) => diff.type === 'add').map((diff) => diff.id); - for (const artifactId of adds) { - const compressError = await newManifest.compressArtifact(artifactId); - if (compressError) { - throw compressError; - } - } // Persist new artifacts - const artifacts = adds - .map((artifactId) => newManifest.getArtifact(artifactId)) - .filter((artifact): artifact is InternalArtifactCompleteSchema => artifact !== undefined); - if (artifacts.length !== adds.length) { - throw new Error('Invalid artifact encountered.'); - } - const persistErrors = await manifestManager.pushArtifacts(artifacts); + const persistErrors = await manifestManager.pushArtifacts( + newManifest.getAllArtifacts() as InternalArtifactCompleteSchema[] + ); if (persistErrors.length) { reportErrors(logger, persistErrors); throw new Error('Unable to persist new artifacts.'); } // Commit the manifest state - if (diffs.length) { - const error = await manifestManager.commit(newManifest); - if (error) { - throw error; - } - } + await manifestManager.commit(newManifest); manifest = newManifest; } @@ -93,7 +74,7 @@ export const getPackagePolicyCreateCallback = ( licenseService: LicenseService, exceptionsClient: ExceptionListClient | undefined ): ExternalCallback[1] => { - const handlePackagePolicyCreate = async ( + return async ( newPackagePolicy: NewPackagePolicy, context: RequestHandlerContext, request: KibanaRequest @@ -143,7 +124,7 @@ export const getPackagePolicyCreateCallback = ( // Get most recent manifest const manifest = await getManifest(logger, manifestManager); - const serializedManifest = manifest.toEndpointFormat(); + const serializedManifest = manifest.toPackagePolicyManifest(); if (!manifestDispatchSchema.is(serializedManifest)) { // This should not happen. // But if it does, we log it and return it anyway. @@ -183,15 +164,13 @@ export const getPackagePolicyCreateCallback = ( return updatedPackagePolicy; }; - - return handlePackagePolicyCreate; }; export const getPackagePolicyUpdateCallback = ( logger: Logger, licenseService: LicenseService ): ExternalCallback[1] => { - const handlePackagePolicyUpdate = async ( + return async ( newPackagePolicy: NewPackagePolicy, context: RequestHandlerContext, request: KibanaRequest @@ -213,5 +192,4 @@ export const getPackagePolicyUpdateCallback = ( } return newPackagePolicy; }; - return handlePackagePolicyUpdate; }; diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts index c408cb56a7fd..6cc6a821eba3 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts @@ -11,7 +11,6 @@ import { ExceptionListItemSchema } from '../../../../../lists/common/schemas'; import { validate } from '../../../../common/validate'; import { Entry, EntryNested } from '../../../../../lists/common/schemas/types'; -import { FoundExceptionListItemSchema } from '../../../../../lists/common/schemas/response/found_exception_list_item_schema'; import { ExceptionListClient } from '../../../../../lists/server'; import { ENDPOINT_LIST_ID } from '../../../../common/shared_imports'; import { @@ -58,14 +57,14 @@ export async function maybeCompressArtifact( ): Promise { const compressedArtifact = { ...uncompressedArtifact }; if (internalArtifactCompleteSchema.is(uncompressedArtifact)) { - const compressedExceptionList = await compressExceptionList( + const compressedArtifactBody = await compressExceptionList( Buffer.from(uncompressedArtifact.body, 'base64') ); - compressedArtifact.body = compressedExceptionList.toString('base64'); - compressedArtifact.encodedSize = compressedExceptionList.byteLength; + compressedArtifact.body = compressedArtifactBody.toString('base64'); + compressedArtifact.encodedSize = compressedArtifactBody.byteLength; compressedArtifact.compressionAlgorithm = 'zlib'; compressedArtifact.encodedSha256 = createHash('sha256') - .update(compressedExceptionList) + .update(compressedArtifactBody) .digest('hex'); } return compressedArtifact; @@ -98,7 +97,7 @@ export async function getFullEndpointExceptionList( if (response?.data !== undefined) { exceptions.entries = exceptions.entries.concat( - translateToEndpointExceptions(response, schemaVersion) + translateToEndpointExceptions(response.data, schemaVersion) ); paging = (page - 1) * 100 + response.data.length < response.total; @@ -117,16 +116,17 @@ export async function getFullEndpointExceptionList( /** * Translates Exception list items to Exceptions the endpoint can understand - * @param exc + * @param exceptions + * @param schemaVersion */ export function translateToEndpointExceptions( - exc: FoundExceptionListItemSchema, + exceptions: ExceptionListItemSchema[], schemaVersion: string ): TranslatedExceptionListItem[] { const entrySet = new Set(); const entriesFiltered: TranslatedExceptionListItem[] = []; if (schemaVersion === 'v1') { - exc.data.forEach((entry) => { + exceptions.forEach((entry) => { const translatedItem = translateItem(schemaVersion, entry); const entryHash = createHash('sha256').update(JSON.stringify(translatedItem)).digest('hex'); if (!entrySet.has(entryHash)) { diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest.test.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest.test.ts index 047b79ea01ef..beaf0c06299f 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest.test.ts @@ -8,31 +8,48 @@ import { ManifestSchemaVersion } from '../../../../common/endpoint/schema/common'; import { InternalArtifactCompleteSchema } from '../../schemas'; import { getArtifactId } from './common'; -import { Manifest } from './manifest'; -import { - getMockArtifacts, - getMockManifest, - getMockManifestWithDiffs, - getEmptyMockManifest, -} from './mocks'; +import { isEmptyManifestDiff, Manifest } from './manifest'; +import { getMockArtifacts, toArtifactRecords } from './mocks'; describe('manifest', () => { - describe('Manifest object sanity checks', () => { - let artifacts: InternalArtifactCompleteSchema[] = []; - let manifest1: Manifest; - let manifest2: Manifest; - let emptyManifest: Manifest; + const TEST_POLICY_ID_1 = 'c6d16e42-c32d-4dce-8a88-113cfe276ad1'; + const TEST_POLICY_ID_2 = '93c46720-c217-11ea-9906-b5b8a21b268e'; + const ARTIFACT_ID_EXCEPTIONS_MACOS = + 'endpoint-exceptionlist-macos-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3'; + const ARTIFACT_ID_EXCEPTIONS_WINDOWS = + 'endpoint-exceptionlist-windows-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3'; + const ARTIFACT_ID_TRUSTED_APPS_MACOS = + 'endpoint-trustlist-macos-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3'; + const ARTIFACT_ID_TRUSTED_APPS_WINDOWS = + 'endpoint-trustlist-windows-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3'; - beforeAll(async () => { - artifacts = await getMockArtifacts({ compress: true }); - manifest1 = await getMockManifest({ compress: true }); - manifest2 = await getMockManifestWithDiffs({ compress: true }); - emptyManifest = await getEmptyMockManifest({ compress: true }); - }); + let ARTIFACTS: InternalArtifactCompleteSchema[] = []; + let ARTIFACTS_COPY: InternalArtifactCompleteSchema[] = []; + let ARTIFACT_EXCEPTIONS_MACOS: InternalArtifactCompleteSchema; + let ARTIFACT_EXCEPTIONS_WINDOWS: InternalArtifactCompleteSchema; + let ARTIFACT_TRUSTED_APPS_MACOS: InternalArtifactCompleteSchema; + let ARTIFACT_TRUSTED_APPS_WINDOWS: InternalArtifactCompleteSchema; + let ARTIFACT_COPY_EXCEPTIONS_MACOS: InternalArtifactCompleteSchema; + let ARTIFACT_COPY_EXCEPTIONS_WINDOWS: InternalArtifactCompleteSchema; + let ARTIFACT_COPY_TRUSTED_APPS_MACOS: InternalArtifactCompleteSchema; + let ARTIFACT_COPY_TRUSTED_APPS_WINDOWS: InternalArtifactCompleteSchema; + + beforeAll(async () => { + ARTIFACTS = await getMockArtifacts({ compress: true }); + ARTIFACTS_COPY = await getMockArtifacts({ compress: true }); + ARTIFACT_EXCEPTIONS_MACOS = ARTIFACTS[0]; + ARTIFACT_EXCEPTIONS_WINDOWS = ARTIFACTS[1]; + ARTIFACT_TRUSTED_APPS_MACOS = ARTIFACTS[2]; + ARTIFACT_TRUSTED_APPS_WINDOWS = ARTIFACTS[3]; + ARTIFACT_COPY_EXCEPTIONS_MACOS = ARTIFACTS_COPY[0]; + ARTIFACT_COPY_EXCEPTIONS_WINDOWS = ARTIFACTS_COPY[1]; + ARTIFACT_COPY_TRUSTED_APPS_MACOS = ARTIFACTS_COPY[2]; + ARTIFACT_COPY_TRUSTED_APPS_WINDOWS = ARTIFACTS_COPY[3]; + }); + describe('Manifest constructor', () => { test('Can create manifest with valid schema version', () => { - const manifest = new Manifest(); - expect(manifest).toBeInstanceOf(Manifest); + expect(new Manifest()).toBeInstanceOf(Manifest); }); test('Cannot create manifest with invalid schema version', () => { @@ -43,177 +60,638 @@ describe('manifest', () => { }).toThrow(); }); - test('Empty manifest transforms correctly to expected endpoint format', async () => { - expect(emptyManifest.toEndpointFormat()).toStrictEqual({ - artifacts: { - 'endpoint-exceptionlist-macos-v1': { - compression_algorithm: 'zlib', - encryption_algorithm: 'none', - decoded_sha256: 'd801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', - encoded_sha256: 'f8e6afa1d5662f5b37f83337af774b5785b5b7f1daee08b7b00c2d6813874cda', - decoded_size: 14, - encoded_size: 22, - relative_url: - '/api/endpoint/artifacts/download/endpoint-exceptionlist-macos-v1/d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', - }, - 'endpoint-exceptionlist-windows-v1': { - compression_algorithm: 'zlib', - encryption_algorithm: 'none', - decoded_sha256: 'd801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', - encoded_sha256: 'f8e6afa1d5662f5b37f83337af774b5785b5b7f1daee08b7b00c2d6813874cda', - decoded_size: 14, - encoded_size: 22, - relative_url: - '/api/endpoint/artifacts/download/endpoint-exceptionlist-windows-v1/d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', - }, - }, + test('Can create manifest with valid constructor parameters', () => { + const manifest = new Manifest({ + schemaVersion: 'v1', + semanticVersion: '1.1.1', + soVersion: '2.2.2', + }); + + expect(manifest.getAllArtifacts()).toStrictEqual([]); + expect(manifest.getSchemaVersion()).toBe('v1'); + expect(manifest.getSemanticVersion()).toBe('1.1.1'); + expect(manifest.getSavedObjectVersion()).toBe('2.2.2'); + }); + }); + + describe('Manifest.getDefault()', () => { + test('Creates empty default manifest', () => { + const manifest = Manifest.getDefault(); + + expect(manifest.getAllArtifacts()).toStrictEqual([]); + expect(manifest.getSchemaVersion()).toBe('v1'); + expect(manifest.getSemanticVersion()).toBe('1.0.0'); + expect(manifest.getSavedObjectVersion()).toBe(undefined); + }); + }); + + describe('bumpSemanticVersion', () => { + test('Bumps the version properly', () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.1.1' }); + + manifest.bumpSemanticVersion(); + + expect(manifest.getSemanticVersion()).toBe('1.1.2'); + }); + }); + + describe('addEntry', () => { + test('Adds default artifact', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + + expect(manifest.getAllArtifacts()).toStrictEqual([ARTIFACT_EXCEPTIONS_MACOS]); + expect(manifest.isDefaultArtifact(ARTIFACT_EXCEPTIONS_MACOS)).toBe(true); + expect(manifest.getArtifactTargetPolicies(ARTIFACT_EXCEPTIONS_MACOS)).toStrictEqual( + new Set() + ); + }); + + test('Adds policy specific artifact', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS, TEST_POLICY_ID_1); + + expect(manifest.getAllArtifacts()).toStrictEqual([ARTIFACT_EXCEPTIONS_MACOS]); + expect(manifest.isDefaultArtifact(ARTIFACT_EXCEPTIONS_MACOS)).toBe(false); + expect(manifest.getArtifactTargetPolicies(ARTIFACT_EXCEPTIONS_MACOS)).toStrictEqual( + new Set([TEST_POLICY_ID_1]) + ); + }); + + test('Adds same artifact as default and policy specific', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS, TEST_POLICY_ID_1); + + expect(manifest.getAllArtifacts()).toStrictEqual([ARTIFACT_EXCEPTIONS_MACOS]); + expect(manifest.isDefaultArtifact(ARTIFACT_EXCEPTIONS_MACOS)).toBe(true); + expect(manifest.getArtifactTargetPolicies(ARTIFACT_EXCEPTIONS_MACOS)).toStrictEqual( + new Set([TEST_POLICY_ID_1]) + ); + }); + + test('Adds multiple artifacts as default and policy specific', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS, TEST_POLICY_ID_1); + manifest.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_2); + manifest.addEntry(ARTIFACT_TRUSTED_APPS_MACOS); + manifest.addEntry(ARTIFACT_TRUSTED_APPS_MACOS, TEST_POLICY_ID_1); + manifest.addEntry(ARTIFACT_TRUSTED_APPS_MACOS, TEST_POLICY_ID_2); + manifest.addEntry(ARTIFACT_TRUSTED_APPS_WINDOWS); + + expect(manifest.getAllArtifacts()).toStrictEqual(ARTIFACTS.slice(0, 4)); + expect(manifest.isDefaultArtifact(ARTIFACT_EXCEPTIONS_MACOS)).toBe(true); + expect(manifest.isDefaultArtifact(ARTIFACT_EXCEPTIONS_WINDOWS)).toBe(false); + expect(manifest.isDefaultArtifact(ARTIFACT_TRUSTED_APPS_MACOS)).toBe(true); + expect(manifest.isDefaultArtifact(ARTIFACT_TRUSTED_APPS_WINDOWS)).toBe(true); + expect(manifest.getArtifactTargetPolicies(ARTIFACT_EXCEPTIONS_MACOS)).toStrictEqual( + new Set([TEST_POLICY_ID_1]) + ); + expect(manifest.getArtifactTargetPolicies(ARTIFACT_EXCEPTIONS_WINDOWS)).toStrictEqual( + new Set([TEST_POLICY_ID_2]) + ); + expect(manifest.getArtifactTargetPolicies(ARTIFACT_TRUSTED_APPS_MACOS)).toStrictEqual( + new Set([TEST_POLICY_ID_1, TEST_POLICY_ID_2]) + ); + expect(manifest.getArtifactTargetPolicies(ARTIFACT_TRUSTED_APPS_WINDOWS)).toStrictEqual( + new Set([]) + ); + }); + + test('Adding same artifact as default multiple times has no effect', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + + expect(manifest.getAllArtifacts()).toStrictEqual(ARTIFACTS.slice(0, 1)); + expect(manifest.isDefaultArtifact(ARTIFACT_EXCEPTIONS_MACOS)).toBe(true); + expect(manifest.getArtifactTargetPolicies(ARTIFACT_EXCEPTIONS_MACOS)).toStrictEqual( + new Set([]) + ); + }); + + test('Adding same artifact as policy specific for same policy multiple times has no effect', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS, TEST_POLICY_ID_1); + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS, TEST_POLICY_ID_1); + + expect(manifest.getAllArtifacts()).toStrictEqual(ARTIFACTS.slice(0, 1)); + expect(manifest.isDefaultArtifact(ARTIFACT_EXCEPTIONS_MACOS)).toBe(false); + expect(manifest.getArtifactTargetPolicies(ARTIFACT_EXCEPTIONS_MACOS)).toStrictEqual( + new Set([TEST_POLICY_ID_1]) + ); + }); + }); + + describe('getAllArtifacts', () => { + test('Returns empty list initially', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + expect(manifest.getAllArtifacts()).toStrictEqual([]); + }); + + test('Returns only unique artifacts', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS, TEST_POLICY_ID_1); + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS, TEST_POLICY_ID_2); + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + manifest.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS); + + expect(manifest.getAllArtifacts()).toStrictEqual(ARTIFACTS.slice(0, 2)); + }); + }); + + describe('getArtifact', () => { + test('Returns undefined for non existing artifact id', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + + expect(manifest.getArtifact('non-existing-artifact-macos-v1')).toBeUndefined(); + }); + + test('Returns default artifact', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + manifest.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS); + + expect(manifest.getArtifact(getArtifactId(ARTIFACT_EXCEPTIONS_MACOS))).toStrictEqual( + ARTIFACT_EXCEPTIONS_MACOS + ); + }); + + test('Returns policy specific artifact', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS, TEST_POLICY_ID_1); + manifest.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_2); + + expect(manifest.getArtifact(getArtifactId(ARTIFACT_EXCEPTIONS_MACOS))).toStrictEqual( + ARTIFACT_EXCEPTIONS_MACOS + ); + }); + }); + + describe('containsArtifact', () => { + test('Returns false for artifact that is not in the manifest', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + + expect(manifest.containsArtifact(ARTIFACT_EXCEPTIONS_WINDOWS)).toBe(false); + }); + + test('Returns true for default artifact that is in the manifest', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + manifest.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS); + + expect(manifest.containsArtifact(ARTIFACT_EXCEPTIONS_WINDOWS)).toBe(true); + }); + + test('Returns true for policy specific artifact that is in the manifest', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS, TEST_POLICY_ID_1); + manifest.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_2); + + expect(manifest.containsArtifact(ARTIFACT_EXCEPTIONS_WINDOWS)).toBe(true); + }); + + test('Returns true for different instances but same ids', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS, TEST_POLICY_ID_1); + + expect(manifest.containsArtifact(ARTIFACT_COPY_EXCEPTIONS_MACOS)).toBe(true); + }); + }); + + describe('isDefaultArtifact', () => { + test('Returns undefined for artifact that is not in the manifest', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + + expect(manifest.isDefaultArtifact(ARTIFACT_EXCEPTIONS_WINDOWS)).toBeUndefined(); + }); + + test('Returns true for default artifact that is in the manifest', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + + expect(manifest.isDefaultArtifact(ARTIFACT_EXCEPTIONS_MACOS)).toBe(true); + }); + + test('Returns false for policy specific artifact that is in the manifest', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS, TEST_POLICY_ID_1); + + expect(manifest.isDefaultArtifact(ARTIFACT_EXCEPTIONS_MACOS)).toBe(false); + }); + + test('Returns true for different instances but same ids', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + + expect(manifest.isDefaultArtifact(ARTIFACT_COPY_EXCEPTIONS_MACOS)).toBe(true); + }); + }); + + describe('getArtifactTargetPolicies', () => { + test('Returns undefined for artifact that is not in the manifest', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS, TEST_POLICY_ID_1); + + expect(manifest.getArtifactTargetPolicies(ARTIFACT_EXCEPTIONS_WINDOWS)).toBeUndefined(); + }); + + test('Returns empty set for default artifact that is in the manifest', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + + expect(manifest.getArtifactTargetPolicies(ARTIFACT_EXCEPTIONS_MACOS)).toStrictEqual( + new Set() + ); + }); + + test('Returns policy set for policy specific artifact that is in the manifest', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS, TEST_POLICY_ID_1); + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS, TEST_POLICY_ID_1); + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS, TEST_POLICY_ID_2); + + expect(manifest.getArtifactTargetPolicies(ARTIFACT_EXCEPTIONS_MACOS)).toStrictEqual( + new Set([TEST_POLICY_ID_1, TEST_POLICY_ID_2]) + ); + }); + + test('Returns policy set for different instances but same ids', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS, TEST_POLICY_ID_1); + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS, TEST_POLICY_ID_1); + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS, TEST_POLICY_ID_2); + + expect(manifest.getArtifactTargetPolicies(ARTIFACT_COPY_EXCEPTIONS_MACOS)).toStrictEqual( + new Set([TEST_POLICY_ID_1, TEST_POLICY_ID_2]) + ); + }); + }); + + describe('diff', () => { + test('Returns empty diff between empty manifests', async () => { + expect(Manifest.getDefault().diff(Manifest.getDefault())).toStrictEqual({ + additions: [], + removals: [], + transitions: [], + }); + }); + + test('Returns diff from empty manifest', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.1' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + manifest.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_1); + manifest.addEntry(ARTIFACT_TRUSTED_APPS_MACOS, TEST_POLICY_ID_2); + + expect(manifest.diff(Manifest.getDefault())).toStrictEqual({ + additions: ARTIFACTS.slice(0, 3), + removals: [], + transitions: [], + }); + }); + + test('Returns empty diff for equal manifests', async () => { + const manifest1 = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest1.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + manifest1.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_1); + manifest1.addEntry(ARTIFACT_TRUSTED_APPS_MACOS, TEST_POLICY_ID_1); + manifest1.addEntry(ARTIFACT_TRUSTED_APPS_MACOS, TEST_POLICY_ID_2); + manifest1.addEntry(ARTIFACT_TRUSTED_APPS_WINDOWS); + manifest1.addEntry(ARTIFACT_TRUSTED_APPS_WINDOWS, TEST_POLICY_ID_2); + + const manifest2 = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.1' }); + + manifest2.addEntry(ARTIFACT_COPY_EXCEPTIONS_MACOS); + manifest2.addEntry(ARTIFACT_COPY_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_1); + manifest2.addEntry(ARTIFACT_COPY_TRUSTED_APPS_MACOS, TEST_POLICY_ID_1); + manifest2.addEntry(ARTIFACT_COPY_TRUSTED_APPS_MACOS, TEST_POLICY_ID_2); + manifest2.addEntry(ARTIFACT_COPY_TRUSTED_APPS_WINDOWS); + manifest2.addEntry(ARTIFACT_COPY_TRUSTED_APPS_WINDOWS, TEST_POLICY_ID_2); + + expect(manifest2.diff(manifest1)).toStrictEqual({ + additions: [], + removals: [], + transitions: [], + }); + }); + + test('Returns additions diff properly', async () => { + const manifest1 = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest1.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + manifest1.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_1); + + const manifest2 = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.1' }); + + manifest2.addEntry(ARTIFACT_COPY_EXCEPTIONS_MACOS); + manifest2.addEntry(ARTIFACT_COPY_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_1); + manifest2.addEntry(ARTIFACT_COPY_TRUSTED_APPS_MACOS, TEST_POLICY_ID_2); + manifest2.addEntry(ARTIFACT_COPY_TRUSTED_APPS_WINDOWS); + + expect(manifest2.diff(manifest1)).toStrictEqual({ + additions: [ARTIFACT_COPY_TRUSTED_APPS_MACOS, ARTIFACT_COPY_TRUSTED_APPS_WINDOWS], + removals: [], + transitions: [], + }); + }); + + test('Returns removals diff properly', async () => { + const manifest1 = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest1.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + manifest1.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_1); + manifest1.addEntry(ARTIFACT_TRUSTED_APPS_MACOS, TEST_POLICY_ID_2); + manifest1.addEntry(ARTIFACT_TRUSTED_APPS_WINDOWS); + + const manifest2 = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.1' }); + + manifest2.addEntry(ARTIFACT_COPY_EXCEPTIONS_MACOS); + manifest2.addEntry(ARTIFACT_COPY_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_1); + + expect(manifest2.diff(manifest1)).toStrictEqual({ + additions: [], + removals: [ARTIFACT_COPY_TRUSTED_APPS_MACOS, ARTIFACT_COPY_TRUSTED_APPS_WINDOWS], + transitions: [], + }); + }); + + test('Returns transitions from one policy to another in diff properly', async () => { + const manifest1 = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest1.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + manifest1.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_1); + manifest1.addEntry(ARTIFACT_TRUSTED_APPS_MACOS, TEST_POLICY_ID_1); + + const manifest2 = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.1' }); + + manifest2.addEntry(ARTIFACT_COPY_EXCEPTIONS_MACOS); + manifest2.addEntry(ARTIFACT_COPY_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_1); + // policy transition + manifest2.addEntry(ARTIFACT_COPY_TRUSTED_APPS_MACOS, TEST_POLICY_ID_2); + + expect(manifest2.diff(manifest1)).toStrictEqual({ + additions: [], + removals: [], + transitions: [ARTIFACT_COPY_TRUSTED_APPS_MACOS], + }); + }); + + test('Returns transitions from policy to default in diff properly', async () => { + const manifest1 = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest1.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + manifest1.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_1); + manifest1.addEntry(ARTIFACT_TRUSTED_APPS_MACOS, TEST_POLICY_ID_1); + + const manifest2 = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.1' }); + + manifest2.addEntry(ARTIFACT_COPY_EXCEPTIONS_MACOS); + manifest2.addEntry(ARTIFACT_COPY_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_1); + // transition to default + manifest2.addEntry(ARTIFACT_COPY_TRUSTED_APPS_MACOS); + + expect(manifest2.diff(manifest1)).toStrictEqual({ + additions: [], + removals: [], + transitions: [ARTIFACT_COPY_TRUSTED_APPS_MACOS], + }); + }); + + test('Returns transitions from default to specific policy in diff properly', async () => { + const manifest1 = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest1.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + manifest1.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_1); + manifest1.addEntry(ARTIFACT_TRUSTED_APPS_MACOS); + + const manifest2 = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.1' }); + + manifest2.addEntry(ARTIFACT_COPY_EXCEPTIONS_MACOS); + manifest2.addEntry(ARTIFACT_COPY_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_1); + // transition to specific policy + manifest2.addEntry(ARTIFACT_COPY_TRUSTED_APPS_MACOS, TEST_POLICY_ID_1); + + expect(manifest2.diff(manifest1)).toStrictEqual({ + additions: [], + removals: [], + transitions: [ARTIFACT_COPY_TRUSTED_APPS_MACOS], + }); + }); + + test('Returns complex transitions diff properly', async () => { + const manifest1 = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest1.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + manifest1.addEntry(ARTIFACT_EXCEPTIONS_MACOS, TEST_POLICY_ID_1); + manifest1.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_1); + manifest1.addEntry(ARTIFACT_TRUSTED_APPS_MACOS, TEST_POLICY_ID_1); + manifest1.addEntry(ARTIFACT_TRUSTED_APPS_MACOS, TEST_POLICY_ID_2); + + const manifest2 = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.1' }); + + // transition to default policy only + manifest2.addEntry(ARTIFACT_COPY_EXCEPTIONS_MACOS); + manifest2.addEntry(ARTIFACT_COPY_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_1); + // transition to second policy + manifest2.addEntry(ARTIFACT_COPY_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_2); + // transition to one policy only + manifest2.addEntry(ARTIFACT_COPY_TRUSTED_APPS_MACOS, TEST_POLICY_ID_1); + + expect(manifest2.diff(manifest1)).toStrictEqual({ + additions: [], + removals: [], + transitions: ARTIFACTS_COPY.slice(0, 3), + }); + }); + + test('Returns complex diff properly', async () => { + const manifest1 = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest1.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + manifest1.addEntry(ARTIFACT_EXCEPTIONS_MACOS, TEST_POLICY_ID_1); + manifest1.addEntry(ARTIFACT_TRUSTED_APPS_MACOS, TEST_POLICY_ID_1); + manifest1.addEntry(ARTIFACT_TRUSTED_APPS_MACOS, TEST_POLICY_ID_2); + manifest1.addEntry(ARTIFACT_TRUSTED_APPS_WINDOWS); + + const manifest2 = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.1' }); + + manifest2.addEntry(ARTIFACT_COPY_EXCEPTIONS_MACOS); + manifest2.addEntry(ARTIFACT_COPY_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_1); + manifest2.addEntry(ARTIFACT_COPY_TRUSTED_APPS_MACOS, TEST_POLICY_ID_1); + + expect(manifest2.diff(manifest1)).toStrictEqual({ + additions: [ARTIFACT_COPY_EXCEPTIONS_WINDOWS], + removals: [ARTIFACT_TRUSTED_APPS_WINDOWS], + transitions: [ARTIFACT_COPY_EXCEPTIONS_MACOS, ARTIFACT_COPY_TRUSTED_APPS_MACOS], + }); + }); + }); + + describe('toPackagePolicyManifest', () => { + test('Returns empty manifest', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + expect(manifest.toPackagePolicyManifest()).toStrictEqual({ + schema_version: 'v1', manifest_version: '1.0.0', + artifacts: {}, + }); + }); + + test('Returns default policy manifest when no policy id provided', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + manifest.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS); + manifest.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_1); + manifest.addEntry(ARTIFACT_TRUSTED_APPS_MACOS, TEST_POLICY_ID_1); + + expect(manifest.toPackagePolicyManifest()).toStrictEqual({ schema_version: 'v1', + manifest_version: '1.0.0', + artifacts: toArtifactRecords({ + 'endpoint-exceptionlist-windows-v1': ARTIFACT_EXCEPTIONS_MACOS, + 'endpoint-exceptionlist-macos-v1': ARTIFACT_EXCEPTIONS_WINDOWS, + }), }); }); - test('Manifest transforms correctly to expected endpoint format', async () => { - expect(manifest1.toEndpointFormat()).toStrictEqual({ - artifacts: { - 'endpoint-exceptionlist-macos-v1': { - compression_algorithm: 'zlib', - encryption_algorithm: 'none', - decoded_sha256: '96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - encoded_sha256: '975382ab55d019cbab0bbac207a54e2a7d489fad6e8f6de34fc6402e5ef37b1e', - decoded_size: 432, - encoded_size: 147, - relative_url: - '/api/endpoint/artifacts/download/endpoint-exceptionlist-macos-v1/96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - }, - 'endpoint-exceptionlist-windows-v1': { - compression_algorithm: 'zlib', - encryption_algorithm: 'none', - decoded_sha256: '96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - encoded_sha256: '975382ab55d019cbab0bbac207a54e2a7d489fad6e8f6de34fc6402e5ef37b1e', - decoded_size: 432, - encoded_size: 147, - relative_url: - '/api/endpoint/artifacts/download/endpoint-exceptionlist-windows-v1/96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - }, - 'endpoint-trustlist-linux-v1': { - compression_algorithm: 'zlib', - decoded_sha256: '96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - decoded_size: 432, - encoded_sha256: '975382ab55d019cbab0bbac207a54e2a7d489fad6e8f6de34fc6402e5ef37b1e', - encoded_size: 147, - encryption_algorithm: 'none', - relative_url: - '/api/endpoint/artifacts/download/endpoint-trustlist-linux-v1/96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - }, - 'endpoint-trustlist-macos-v1': { - compression_algorithm: 'zlib', - decoded_sha256: '96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - decoded_size: 432, - encoded_sha256: '975382ab55d019cbab0bbac207a54e2a7d489fad6e8f6de34fc6402e5ef37b1e', - encoded_size: 147, - encryption_algorithm: 'none', - relative_url: - '/api/endpoint/artifacts/download/endpoint-trustlist-macos-v1/96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - }, - 'endpoint-trustlist-windows-v1': { - compression_algorithm: 'zlib', - decoded_sha256: '96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - decoded_size: 432, - encoded_sha256: '975382ab55d019cbab0bbac207a54e2a7d489fad6e8f6de34fc6402e5ef37b1e', - encoded_size: 147, - encryption_algorithm: 'none', - relative_url: - '/api/endpoint/artifacts/download/endpoint-trustlist-windows-v1/96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - }, - }, + test('Returns default policy manifest when no policy specific artifacts present', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + manifest.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS); + manifest.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_1); + manifest.addEntry(ARTIFACT_TRUSTED_APPS_MACOS, TEST_POLICY_ID_1); + + expect(manifest.toPackagePolicyManifest(TEST_POLICY_ID_2)).toStrictEqual({ + schema_version: 'v1', manifest_version: '1.0.0', + artifacts: toArtifactRecords({ + 'endpoint-exceptionlist-windows-v1': ARTIFACT_EXCEPTIONS_MACOS, + 'endpoint-exceptionlist-macos-v1': ARTIFACT_EXCEPTIONS_WINDOWS, + }), + }); + }); + + test('Returns policy specific manifest when policy specific artifacts present', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + manifest.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS); + manifest.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_1); + manifest.addEntry(ARTIFACT_TRUSTED_APPS_MACOS, TEST_POLICY_ID_1); + + expect(manifest.toPackagePolicyManifest(TEST_POLICY_ID_2)).toStrictEqual({ schema_version: 'v1', + manifest_version: '1.0.0', + artifacts: toArtifactRecords({ + 'endpoint-exceptionlist-windows-v1': ARTIFACT_TRUSTED_APPS_MACOS, + 'endpoint-exceptionlist-macos-v1': ARTIFACT_EXCEPTIONS_WINDOWS, + }), }); }); + }); + + describe('toSavedObject', () => { + test('Returns empty saved object', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + expect(manifest.toSavedObject()).toStrictEqual({ + schemaVersion: 'v1', + semanticVersion: '1.0.0', + artifacts: [], + }); + }); + + test('Returns populated saved object', async () => { + const manifest = new Manifest({ schemaVersion: 'v1', semanticVersion: '1.0.0' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + manifest.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS); + manifest.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_1); + manifest.addEntry(ARTIFACT_TRUSTED_APPS_MACOS, TEST_POLICY_ID_1); + manifest.addEntry(ARTIFACT_TRUSTED_APPS_WINDOWS, TEST_POLICY_ID_1); + manifest.addEntry(ARTIFACT_TRUSTED_APPS_WINDOWS, TEST_POLICY_ID_2); - test('Manifest transforms correctly to expected saved object format', async () => { - expect(manifest1.toSavedObject()).toStrictEqual({ + expect(manifest.toSavedObject()).toStrictEqual({ schemaVersion: 'v1', semanticVersion: '1.0.0', - ids: [ - 'endpoint-exceptionlist-macos-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - 'endpoint-exceptionlist-windows-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - 'endpoint-trustlist-macos-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - 'endpoint-trustlist-windows-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - 'endpoint-trustlist-linux-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', + artifacts: [ + { artifactId: ARTIFACT_ID_EXCEPTIONS_MACOS, policyId: undefined }, + { artifactId: ARTIFACT_ID_EXCEPTIONS_WINDOWS, policyId: undefined }, + { artifactId: ARTIFACT_ID_EXCEPTIONS_WINDOWS, policyId: TEST_POLICY_ID_1 }, + { artifactId: ARTIFACT_ID_TRUSTED_APPS_MACOS, policyId: TEST_POLICY_ID_1 }, + { artifactId: ARTIFACT_ID_TRUSTED_APPS_WINDOWS, policyId: TEST_POLICY_ID_1 }, + { artifactId: ARTIFACT_ID_TRUSTED_APPS_WINDOWS, policyId: TEST_POLICY_ID_2 }, ], }); }); + }); - test('Manifest returns diffs since supplied manifest', async () => { - const diffs = manifest2.diff(manifest1); - expect(diffs).toEqual([ - { - id: - 'endpoint-exceptionlist-macos-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - type: 'delete', - }, - { - id: - 'endpoint-trustlist-macos-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - type: 'delete', - }, - { - id: - 'endpoint-trustlist-windows-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - type: 'delete', - }, - { - id: - 'endpoint-trustlist-linux-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - type: 'delete', - }, - { - id: - 'endpoint-exceptionlist-macos-v1-0a5a2013a79f9e60682472284a1be45ab1ff68b9b43426d00d665016612c15c8', - type: 'add', - }, - ]); - }); - - test('Manifest returns data for given artifact', async () => { - const artifact = artifacts[0]; - const returned = manifest1.getArtifact(getArtifactId(artifact)); - expect(returned).toEqual(artifact); - }); - - test('Manifest returns entries map', async () => { - const entries = manifest1.getEntries(); - const keys = Object.keys(entries); - expect(keys).toEqual([ - 'endpoint-exceptionlist-macos-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - 'endpoint-exceptionlist-windows-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - 'endpoint-trustlist-macos-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - 'endpoint-trustlist-windows-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - 'endpoint-trustlist-linux-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - ]); - }); - - test('Manifest returns true if contains artifact', async () => { - const found = manifest1.contains( - 'endpoint-exceptionlist-macos-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3' - ); - expect(found).toEqual(true); - }); - - test('Manifest can be created from list of artifacts', async () => { - const oldManifest = new Manifest(); - const manifest = Manifest.fromArtifacts(artifacts, oldManifest); - expect( - manifest.contains( - 'endpoint-exceptionlist-macos-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3' - ) - ).toEqual(true); - expect( - manifest.contains( - 'endpoint-exceptionlist-windows-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3' - ) - ).toEqual(true); + describe('isEmptyManifestDiff', () => { + test('Returns true when no additions, removals or transitions', async () => { + expect(isEmptyManifestDiff({ additions: [], removals: [], transitions: [] })).toBe(true); + }); + + test('Returns false when there are additions', async () => { + const diff = { additions: [ARTIFACT_EXCEPTIONS_MACOS], removals: [], transitions: [] }; + + expect(isEmptyManifestDiff(diff)).toBe(false); + }); + + test('Returns false when there are removals', async () => { + const diff = { additions: [], removals: [ARTIFACT_EXCEPTIONS_MACOS], transitions: [] }; + + expect(isEmptyManifestDiff(diff)).toBe(false); + }); + + test('Returns false when there are transitions', async () => { + const diff = { additions: [], removals: [], transitions: [ARTIFACT_EXCEPTIONS_MACOS] }; + + expect(isEmptyManifestDiff(diff)).toBe(false); + }); + + test('Returns false when there are all typesof changes', async () => { + const diff = { + additions: [ARTIFACT_EXCEPTIONS_MACOS], + removals: [ARTIFACT_EXCEPTIONS_WINDOWS], + transitions: [ARTIFACT_TRUSTED_APPS_MACOS], + }; + + expect(isEmptyManifestDiff(diff)).toBe(false); }); }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest.ts index e2065b6bbc37..7e1accac37cf 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest.ts @@ -5,13 +5,13 @@ * 2.0. */ +import { flatMap, isEqual } from 'lodash'; import semver from 'semver'; -import { validate } from '../../../../common/validate'; +import { validate } from '../../../../common'; import { InternalArtifactSchema, InternalManifestSchema, - internalArtifactCompleteSchema, - InternalArtifactCompleteSchema, + InternalManifestEntrySchema, } from '../../schemas/artifacts'; import { ManifestSchemaVersion, @@ -20,21 +20,46 @@ import { } from '../../../../common/endpoint/schema/common'; import { manifestSchema, ManifestSchema } from '../../../../common/endpoint/schema/manifest'; import { ManifestEntry } from './manifest_entry'; -import { maybeCompressArtifact, isCompressed } from './lists'; import { getArtifactId } from './common'; import { ManifestVersion, manifestVersion } from '../../schemas/artifacts/manifest'; +function createInternalManifestEntries( + artifactIds: string[], + policyId?: string +): InternalManifestEntrySchema[] { + return artifactIds.map((artifactId) => ({ policyId, artifactId })); +} + export interface ManifestDiff { - type: string; - id: string; + additions: InternalArtifactSchema[]; + removals: InternalArtifactSchema[]; + transitions: InternalArtifactSchema[]; +} + +export function isEmptyManifestDiff(diff: ManifestDiff) { + return diff.additions.length === 0 && diff.removals.length === 0 && diff.transitions.length === 0; +} + +interface ManifestEntryDescriptor { + isDefaultEntry: boolean; + specificTargetPolicies: Set; + entry: ManifestEntry; +} + +function addValueToSet(set?: Set, value?: T) { + return new Set([...(set?.values() || []), ...(value !== undefined ? [value] : [])]); } export class Manifest { - private entries: Record; + private readonly allEntries: Map; + private readonly defaultEntries: Map; + private readonly policySpecificEntries: Map>; private version: ManifestVersion; constructor(version?: Partial) { - this.entries = {}; + this.allEntries = new Map(); + this.defaultEntries = new Map(); + this.policySpecificEntries = new Map(); const decodedVersion = { schemaVersion: version?.schemaVersion ?? 'v1', @@ -54,28 +79,6 @@ export class Manifest { return new Manifest({ schemaVersion, semanticVersion: '1.0.0' }); } - public static fromArtifacts( - artifacts: InternalArtifactCompleteSchema[], - oldManifest: Manifest, - schemaVersion?: ManifestSchemaVersion - ): Manifest { - const manifest = new Manifest({ - schemaVersion, - semanticVersion: oldManifest.getSemanticVersion(), - soVersion: oldManifest.getSavedObjectVersion(), - }); - artifacts.forEach((artifact) => { - const id = getArtifactId(artifact); - const existingArtifact = oldManifest.getArtifact(id); - if (existingArtifact) { - manifest.addEntry(existingArtifact); - } else { - manifest.addEntry(artifact); - } - }); - return manifest; - } - public bumpSemanticVersion() { const newSemanticVersion = semver.inc(this.getSemanticVersion(), 'patch'); if (!semanticVersion.is(newSemanticVersion)) { @@ -84,26 +87,6 @@ export class Manifest { this.version.semanticVersion = newSemanticVersion; } - public async compressArtifact(id: string): Promise { - try { - const artifact = this.getArtifact(id); - if (artifact == null) { - throw new Error(`Corrupted manifest detected. Artifact ${id} not in manifest.`); - } - - const compressedArtifact = await maybeCompressArtifact(artifact); - if (!isCompressed(compressedArtifact)) { - throw new Error(`Unable to compress artifact: ${id}`); - } else if (!internalArtifactCompleteSchema.is(compressedArtifact)) { - throw new Error(`Incomplete artifact detected: ${id}`); - } - this.addEntry(compressedArtifact); - } catch (err) { - return err; - } - return null; - } - public getSchemaVersion(): ManifestSchemaVersion { return this.version.schemaVersion; } @@ -116,53 +99,85 @@ export class Manifest { return this.version.semanticVersion; } - public addEntry(artifact: InternalArtifactSchema) { - const entry = new ManifestEntry(artifact); - this.entries[entry.getDocId()] = entry; + public addEntry(artifact: InternalArtifactSchema, policyId?: string) { + const existingDescriptor = this.allEntries.get(getArtifactId(artifact)); + const descriptor = { + isDefaultEntry: existingDescriptor?.isDefaultEntry || policyId === undefined, + specificTargetPolicies: addValueToSet(existingDescriptor?.specificTargetPolicies, policyId), + entry: existingDescriptor?.entry || new ManifestEntry(artifact), + }; + + this.allEntries.set(descriptor.entry.getDocId(), descriptor); + + if (policyId) { + const entries = this.policySpecificEntries.get(policyId) || new Map(); + entries.set(descriptor.entry.getDocId(), descriptor.entry); + + this.policySpecificEntries.set(policyId, entries); + } else { + this.defaultEntries.set(descriptor.entry.getDocId(), descriptor.entry); + } } - public contains(artifactId: string): boolean { - return artifactId in this.entries; + public getAllArtifacts(): InternalArtifactSchema[] { + return [...this.allEntries.values()].map((descriptor) => descriptor.entry.getArtifact()); } - public getEntries(): Record { - return this.entries; + public getArtifact(artifactId: string): InternalArtifactSchema | undefined { + return this.allEntries.get(artifactId)?.entry.getArtifact(); } - public getEntry(artifactId: string): ManifestEntry | undefined { - return this.entries[artifactId]; + public containsArtifact(artifact: InternalArtifactSchema): boolean { + return this.allEntries.has(getArtifactId(artifact)); } - public getArtifact(artifactId: string): InternalArtifactSchema | undefined { - return this.getEntry(artifactId)?.getArtifact(); + public isDefaultArtifact(artifact: InternalArtifactSchema): boolean | undefined { + return this.allEntries.get(getArtifactId(artifact))?.isDefaultEntry; } - public diff(manifest: Manifest): ManifestDiff[] { - const diffs: ManifestDiff[] = []; + public getArtifactTargetPolicies(artifact: InternalArtifactSchema): Set | undefined { + return this.allEntries.get(getArtifactId(artifact))?.specificTargetPolicies; + } + + public diff(manifest: Manifest): ManifestDiff { + const diff: ManifestDiff = { + additions: [], + removals: [], + transitions: [], + }; - for (const id in manifest.getEntries()) { - if (!this.contains(id)) { - diffs.push({ type: 'delete', id }); + for (const artifact of manifest.getAllArtifacts()) { + if (!this.containsArtifact(artifact)) { + diff.removals.push(artifact); + } else if ( + this.isDefaultArtifact(artifact) !== manifest.isDefaultArtifact(artifact) || + !isEqual( + this.getArtifactTargetPolicies(artifact), + manifest.getArtifactTargetPolicies(artifact) + ) + ) { + diff.transitions.push(artifact); } } - for (const id in this.entries) { - if (!manifest.contains(id)) { - diffs.push({ type: 'add', id }); + for (const artifact of this.getAllArtifacts()) { + if (!manifest.containsArtifact(artifact)) { + diff.additions.push(artifact); } } - return diffs; + return diff; } - public toEndpointFormat(): ManifestSchema { + public toPackagePolicyManifest(policyId?: string): ManifestSchema { + const entries = (!!policyId && this.policySpecificEntries.get(policyId)) || this.defaultEntries; const manifestObj: ManifestSchema = { manifest_version: this.getSemanticVersion(), schema_version: this.getSchemaVersion(), artifacts: {}, }; - for (const entry of Object.values(this.entries)) { + for (const entry of entries.values()) { manifestObj.artifacts[entry.getIdentifier()] = entry.getRecord(); } @@ -176,7 +191,15 @@ export class Manifest { public toSavedObject(): InternalManifestSchema { return { - ids: Object.keys(this.getEntries()), + artifacts: [ + ...createInternalManifestEntries([...this.defaultEntries.keys()]), + ...flatMap([...this.policySpecificEntries.keys()], (policyId) => + createInternalManifestEntries( + [...(this.policySpecificEntries.get(policyId)?.keys() || [])], + policyId + ) + ), + ], schemaVersion: this.getSchemaVersion(), semanticVersion: this.getSemanticVersion(), }; diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/migrations.test.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/migrations.test.ts new file mode 100644 index 000000000000..814a9880014c --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/migrations.test.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectUnsanitizedDoc } from 'kibana/server'; +import { migrationMocks } from 'src/core/server/mocks'; +import { ManifestConstants } from './common'; +import { migrations, OldInternalManifestSchema } from './migrations'; + +describe('7.12.0 manifest migrations', () => { + const ARTIFACT_ID_0 = + 'endpoint-exceptionlist-macos-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3'; + const ARTIFACT_ID_1 = + 'endpoint-exceptionlist-windows-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3'; + const ARTIFACT_ID_2 = + 'endpoint-trustlist-macos-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3'; + const ARTIFACT_ID_3 = + 'endpoint-trustlist-windows-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3'; + + const migration = migrations['7.12.0']; + + test('Migrates ids property', () => { + const doc: SavedObjectUnsanitizedDoc = { + attributes: { + ids: [ARTIFACT_ID_0, ARTIFACT_ID_1, ARTIFACT_ID_2, ARTIFACT_ID_3], + schemaVersion: 'v1', + semanticVersion: '1.0.1', + }, + id: 'endpoint-manifest-v1', + migrationVersion: {}, + references: [], + type: ManifestConstants.SAVED_OBJECT_TYPE, + updated_at: '2020-06-09T20:18:20.349Z', + }; + + expect(migration(doc, migrationMocks.createContext())).toStrictEqual({ + attributes: { + artifacts: [ + { artifactId: ARTIFACT_ID_0, policyId: undefined }, + { artifactId: ARTIFACT_ID_1, policyId: undefined }, + { artifactId: ARTIFACT_ID_2, policyId: undefined }, + { artifactId: ARTIFACT_ID_3, policyId: undefined }, + ], + schemaVersion: 'v1', + semanticVersion: '1.0.1', + }, + id: 'endpoint-manifest-v1', + migrationVersion: {}, + references: [], + type: ManifestConstants.SAVED_OBJECT_TYPE, + updated_at: '2020-06-09T20:18:20.349Z', + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/migrations.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/migrations.ts new file mode 100644 index 000000000000..e419c4297b23 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/migrations.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + SavedObjectMigrationMap, + SavedObjectSanitizedDoc, + SavedObjectUnsanitizedDoc, +} from 'kibana/server'; + +import { InternalManifestSchema } from '../../schemas/artifacts'; + +export type OldInternalManifestSchema = Omit & { + ids: string[]; +}; + +export const migrations: SavedObjectMigrationMap = { + '7.12.0': ( + doc: SavedObjectUnsanitizedDoc + ): SavedObjectSanitizedDoc => { + const { ids, ...rest } = doc.attributes; + + return { + ...doc, + references: doc.references || [], + attributes: { + ...rest, + artifacts: (ids || []).map((artifactId) => ({ artifactId, policyId: undefined })), + }, + }; + }, +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/mocks.ts index 738a995f9fc6..1a582a51c52c 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/mocks.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/mocks.ts @@ -5,7 +5,8 @@ * 2.0. */ -import { PackagePolicy } from '../../../../../fleet/common'; +import { mapValues } from 'lodash'; +import { PackagePolicy, PackagePolicyConfigRecord } from '../../../../../fleet/common'; import { createPackagePolicyMock } from '../../../../../fleet/common/mocks'; import { InternalArtifactCompleteSchema } from '../../schemas/artifacts'; import { @@ -63,83 +64,94 @@ export const getMockManifest = async (opts?: { compress: boolean }) => { return manifest; }; -export const getMockManifestWithDiffs = async (opts?: { compress: boolean }) => { - const manifest = new Manifest(); - const artifacts = await getMockArtifactsWithDiff(opts); - artifacts.forEach((artifact) => manifest.addEntry(artifact)); - return manifest; -}; +const toArtifactRecord = (artifactName: string, artifact: InternalArtifactCompleteSchema) => ({ + compression_algorithm: artifact.compressionAlgorithm, + decoded_sha256: artifact.decodedSha256, + decoded_size: artifact.decodedSize, + encoded_sha256: artifact.encodedSha256, + encoded_size: artifact.encodedSize, + encryption_algorithm: artifact.encryptionAlgorithm, + relative_url: `/api/endpoint/artifacts/download/${artifactName}/${artifact.decodedSha256}`, +}); -export const getEmptyMockManifest = async (opts?: { compress: boolean }) => { - const manifest = new Manifest(); - const artifacts = await getEmptyMockArtifacts(opts); - artifacts.forEach((artifact) => manifest.addEntry(artifact)); - return manifest; +export const toArtifactRecords = (artifacts: Record) => + mapValues(artifacts, (artifact, key) => toArtifactRecord(key, artifact)); + +export const createPackagePolicyWithConfigMock = ( + options: Partial & { config?: PackagePolicyConfigRecord } +): PackagePolicy => { + const { config, ...packagePolicyOverrides } = options; + const packagePolicy = createPackagePolicyMock(); + packagePolicy.inputs[0].config = options.config; + return { ...packagePolicy, ...packagePolicyOverrides }; }; export const createPackagePolicyWithInitialManifestMock = (): PackagePolicy => { - const packagePolicy = createPackagePolicyMock(); - packagePolicy.inputs[0].config!.artifact_manifest = { - value: { - artifacts: { - 'endpoint-exceptionlist-macos-v1': { - compression_algorithm: 'zlib', - encryption_algorithm: 'none', - decoded_sha256: 'd801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', - encoded_sha256: 'f8e6afa1d5662f5b37f83337af774b5785b5b7f1daee08b7b00c2d6813874cda', - decoded_size: 14, - encoded_size: 22, - relative_url: - '/api/endpoint/artifacts/download/endpoint-exceptionlist-macos-v1/d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', - }, - 'endpoint-exceptionlist-windows-v1': { - compression_algorithm: 'zlib', - encryption_algorithm: 'none', - decoded_sha256: 'd801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', - encoded_sha256: 'f8e6afa1d5662f5b37f83337af774b5785b5b7f1daee08b7b00c2d6813874cda', - decoded_size: 14, - encoded_size: 22, - relative_url: - '/api/endpoint/artifacts/download/endpoint-exceptionlist-windows-v1/d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', + return createPackagePolicyWithConfigMock({ + config: { + artifact_manifest: { + value: { + artifacts: { + 'endpoint-exceptionlist-macos-v1': { + compression_algorithm: 'zlib', + encryption_algorithm: 'none', + decoded_sha256: 'd801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', + encoded_sha256: 'f8e6afa1d5662f5b37f83337af774b5785b5b7f1daee08b7b00c2d6813874cda', + decoded_size: 14, + encoded_size: 22, + relative_url: + '/api/endpoint/artifacts/download/endpoint-exceptionlist-macos-v1/d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', + }, + 'endpoint-exceptionlist-windows-v1': { + compression_algorithm: 'zlib', + encryption_algorithm: 'none', + decoded_sha256: 'd801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', + encoded_sha256: 'f8e6afa1d5662f5b37f83337af774b5785b5b7f1daee08b7b00c2d6813874cda', + decoded_size: 14, + encoded_size: 22, + relative_url: + '/api/endpoint/artifacts/download/endpoint-exceptionlist-windows-v1/d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', + }, + }, + manifest_version: '1.0.0', + schema_version: 'v1', }, }, - manifest_version: '1.0.0', - schema_version: 'v1', }, - }; - return packagePolicy; + }); }; export const createPackagePolicyWithManifestMock = (): PackagePolicy => { - const packagePolicy = createPackagePolicyMock(); - packagePolicy.inputs[0].config!.artifact_manifest = { - value: { - artifacts: { - 'endpoint-exceptionlist-macos-v1': { - compression_algorithm: 'zlib', - encryption_algorithm: 'none', - decoded_sha256: '96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - encoded_sha256: '975382ab55d019cbab0bbac207a54e2a7d489fad6e8f6de34fc6402e5ef37b1e', - decoded_size: 432, - encoded_size: 147, - relative_url: - '/api/endpoint/artifacts/download/endpoint-exceptionlist-macos-v1/96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - }, - 'endpoint-exceptionlist-windows-v1': { - compression_algorithm: 'zlib', - encryption_algorithm: 'none', - decoded_sha256: '96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - encoded_sha256: '975382ab55d019cbab0bbac207a54e2a7d489fad6e8f6de34fc6402e5ef37b1e', - decoded_size: 432, - encoded_size: 147, - relative_url: - '/api/endpoint/artifacts/download/endpoint-exceptionlist-windows-v1/96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', + return createPackagePolicyWithConfigMock({ + config: { + artifact_manifest: { + value: { + artifacts: { + 'endpoint-exceptionlist-macos-v1': { + compression_algorithm: 'zlib', + encryption_algorithm: 'none', + decoded_sha256: '96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', + encoded_sha256: '975382ab55d019cbab0bbac207a54e2a7d489fad6e8f6de34fc6402e5ef37b1e', + decoded_size: 432, + encoded_size: 147, + relative_url: + '/api/endpoint/artifacts/download/endpoint-exceptionlist-macos-v1/96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', + }, + 'endpoint-exceptionlist-windows-v1': { + compression_algorithm: 'zlib', + encryption_algorithm: 'none', + decoded_sha256: '96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', + encoded_sha256: '975382ab55d019cbab0bbac207a54e2a7d489fad6e8f6de34fc6402e5ef37b1e', + decoded_size: 432, + encoded_size: 147, + relative_url: + '/api/endpoint/artifacts/download/endpoint-exceptionlist-windows-v1/96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', + }, + }, + manifest_version: '1.0.1', + schema_version: 'v1', }, }, - manifest_version: '1.0.1', - schema_version: 'v1', }, - }; - - return packagePolicy; + }); }; diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/saved_object_mappings.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/saved_object_mappings.ts index 8596e6b9917a..2202336ef451 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/saved_object_mappings.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/saved_object_mappings.ts @@ -8,6 +8,7 @@ import { SavedObjectsType } from '../../../../../../../src/core/server'; import { ArtifactConstants, ManifestConstants } from './common'; +import { migrations } from './migrations'; export const exceptionsArtifactSavedObjectType = ArtifactConstants.SAVED_OBJECT_TYPE; export const manifestSavedObjectType = ManifestConstants.SAVED_OBJECT_TYPE; @@ -63,9 +64,18 @@ export const manifestSavedObjectMappings: SavedObjectsType['mappings'] = { type: 'keyword', index: false, }, - ids: { - type: 'keyword', - index: false, + artifacts: { + type: 'nested', + properties: { + policyId: { + type: 'keyword', + index: false, + }, + artifactId: { + type: 'keyword', + index: false, + }, + }, }, }, }; @@ -82,4 +92,5 @@ export const manifestType: SavedObjectsType = { hidden: false, namespaceType: 'agnostic', mappings: manifestSavedObjectMappings, + migrations, }; diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/task.test.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/task.test.ts index 08f835951801..9fac617f1f06 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/task.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/task.test.ts @@ -12,8 +12,27 @@ import { createMockEndpointAppContext } from '../../mocks'; import { ManifestTaskConstants, ManifestTask } from './task'; import { MockManifestTask } from './task.mock'; +import { ManifestManager } from '../../services/artifacts/manifest_manager'; +import { buildManifestManagerMock } from '../../services/artifacts/manifest_manager/manifest_manager.mock'; +import { InternalArtifactCompleteSchema } from '../../schemas/artifacts'; +import { getMockArtifacts } from './mocks'; +import { Manifest } from './manifest'; describe('task', () => { + const MOCK_TASK_INSTANCE = { + id: `${ManifestTaskConstants.TYPE}:1.0.0`, + runAt: new Date(), + attempts: 0, + ownerId: '', + status: TaskStatus.Running, + startedAt: new Date(), + scheduledAt: new Date(), + retryAt: new Date(), + params: {}, + state: {}, + taskType: ManifestTaskConstants.TYPE, + }; + describe('Periodic task sanity checks', () => { test('can create task', () => { const manifestTask = new ManifestTask({ @@ -50,25 +69,255 @@ describe('task', () => { endpointAppContext: mockContext, taskManager: mockTaskManager, }); - const mockTaskInstance = { - id: ManifestTaskConstants.TYPE, - runAt: new Date(), - attempts: 0, - ownerId: '', - status: TaskStatus.Running, - startedAt: new Date(), - scheduledAt: new Date(), - retryAt: new Date(), - params: {}, - state: {}, - taskType: ManifestTaskConstants.TYPE, - }; const createTaskRunner = mockTaskManager.registerTaskDefinitions.mock.calls[0][0][ManifestTaskConstants.TYPE] .createTaskRunner; - const taskRunner = createTaskRunner({ taskInstance: mockTaskInstance }); + const taskRunner = createTaskRunner({ taskInstance: MOCK_TASK_INSTANCE }); await taskRunner.run(); expect(mockManifestTask.runTask).toHaveBeenCalled(); }); }); + + describe('Artifacts generation flow tests', () => { + const runTask = async (manifestManager: ManifestManager) => { + const mockContext = createMockEndpointAppContext(); + const mockTaskManager = taskManagerMock.createSetup(); + + new ManifestTask({ + endpointAppContext: mockContext, + taskManager: mockTaskManager, + }); + + mockContext.service.getManifestManager = jest.fn().mockReturnValue(manifestManager); + + const createTaskRunner = + mockTaskManager.registerTaskDefinitions.mock.calls[0][0][ManifestTaskConstants.TYPE] + .createTaskRunner; + const taskRunner = createTaskRunner({ taskInstance: MOCK_TASK_INSTANCE }); + await taskRunner.run(); + }; + + const TEST_POLICY_ID_1 = 'c6d16e42-c32d-4dce-8a88-113cfe276ad1'; + const TEST_POLICY_ID_2 = '93c46720-c217-11ea-9906-b5b8a21b268e'; + const ARTIFACT_ID_1 = + 'endpoint-exceptionlist-windows-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3'; + let ARTIFACT_EXCEPTIONS_MACOS: InternalArtifactCompleteSchema; + let ARTIFACT_EXCEPTIONS_WINDOWS: InternalArtifactCompleteSchema; + let ARTIFACT_TRUSTED_APPS_MACOS: InternalArtifactCompleteSchema; + + beforeAll(async () => { + const artifacts = await getMockArtifacts({ compress: true }); + ARTIFACT_EXCEPTIONS_MACOS = artifacts[0]; + ARTIFACT_EXCEPTIONS_WINDOWS = artifacts[1]; + ARTIFACT_TRUSTED_APPS_MACOS = artifacts[2]; + }); + + test('Should not run the process when no current manifest manager', async () => { + const manifestManager = buildManifestManagerMock(); + + manifestManager.getLastComputedManifest = jest.fn().mockReturnValue(null); + + await runTask(manifestManager); + + expect(manifestManager.getLastComputedManifest).toHaveBeenCalled(); + expect(manifestManager.buildNewManifest).not.toHaveBeenCalled(); + expect(manifestManager.pushArtifacts).not.toHaveBeenCalled(); + expect(manifestManager.commit).not.toHaveBeenCalled(); + expect(manifestManager.tryDispatch).not.toHaveBeenCalled(); + expect(manifestManager.deleteArtifacts).not.toHaveBeenCalled(); + }); + + test('Should stop the process when no building new manifest throws error', async () => { + const manifestManager = buildManifestManagerMock(); + const lastManifest = Manifest.getDefault(); + + manifestManager.getLastComputedManifest = jest.fn().mockReturnValue(lastManifest); + manifestManager.buildNewManifest = jest.fn().mockRejectedValue(new Error()); + + await runTask(manifestManager); + + expect(manifestManager.getLastComputedManifest).toHaveBeenCalled(); + expect(manifestManager.buildNewManifest).toHaveBeenCalledWith(lastManifest); + expect(manifestManager.pushArtifacts).not.toHaveBeenCalled(); + expect(manifestManager.commit).not.toHaveBeenCalled(); + expect(manifestManager.tryDispatch).not.toHaveBeenCalled(); + expect(manifestManager.deleteArtifacts).not.toHaveBeenCalled(); + }); + + test('Should not bump version and commit manifest when no diff in the manifest', async () => { + const manifestManager = buildManifestManagerMock(); + + const lastManifest = Manifest.getDefault(); + lastManifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + lastManifest.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS); + + const newManifest = Manifest.getDefault(); + newManifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + newManifest.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS); + + manifestManager.getLastComputedManifest = jest.fn().mockReturnValue(lastManifest); + manifestManager.buildNewManifest = jest.fn().mockResolvedValue(newManifest); + manifestManager.pushArtifacts = jest.fn().mockResolvedValue([]); + manifestManager.tryDispatch = jest.fn().mockResolvedValue([]); + manifestManager.deleteArtifacts = jest.fn().mockResolvedValue([]); + + await runTask(manifestManager); + + expect(newManifest.getSemanticVersion()).toBe('1.0.0'); + + expect(manifestManager.getLastComputedManifest).toHaveBeenCalled(); + expect(manifestManager.buildNewManifest).toHaveBeenCalledWith(lastManifest); + expect(manifestManager.pushArtifacts).toHaveBeenCalledWith([]); + expect(manifestManager.commit).not.toHaveBeenCalled(); + expect(manifestManager.tryDispatch).toHaveBeenCalledWith(newManifest); + expect(manifestManager.deleteArtifacts).toHaveBeenCalledWith([]); + }); + + test('Should stop the process when there are errors pushing new artifacts', async () => { + const manifestManager = buildManifestManagerMock(); + + const lastManifest = Manifest.getDefault(); + + const newManifest = Manifest.getDefault(); + newManifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + newManifest.addEntry(ARTIFACT_TRUSTED_APPS_MACOS); + + manifestManager.getLastComputedManifest = jest.fn().mockReturnValue(lastManifest); + manifestManager.buildNewManifest = jest.fn().mockResolvedValue(newManifest); + manifestManager.pushArtifacts = jest.fn().mockResolvedValue([new Error()]); + + await runTask(manifestManager); + + expect(newManifest.getSemanticVersion()).toBe('1.0.0'); + + expect(manifestManager.getLastComputedManifest).toHaveBeenCalled(); + expect(manifestManager.buildNewManifest).toHaveBeenCalledWith(lastManifest); + expect(manifestManager.pushArtifacts).toHaveBeenCalledWith([ + ARTIFACT_EXCEPTIONS_MACOS, + ARTIFACT_TRUSTED_APPS_MACOS, + ]); + expect(manifestManager.commit).not.toHaveBeenCalled(); + expect(manifestManager.tryDispatch).not.toHaveBeenCalled(); + expect(manifestManager.deleteArtifacts).not.toHaveBeenCalled(); + }); + + test('Should stop the process when there are errors committing manifest', async () => { + const manifestManager = buildManifestManagerMock(); + + const lastManifest = Manifest.getDefault(); + + const newManifest = Manifest.getDefault(); + newManifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + newManifest.addEntry(ARTIFACT_TRUSTED_APPS_MACOS); + + manifestManager.getLastComputedManifest = jest.fn().mockReturnValue(lastManifest); + manifestManager.buildNewManifest = jest.fn().mockResolvedValue(newManifest); + manifestManager.pushArtifacts = jest.fn().mockResolvedValue([]); + manifestManager.commit = jest.fn().mockRejectedValue(new Error()); + + await runTask(manifestManager); + + expect(newManifest.getSemanticVersion()).toBe('1.0.1'); + + expect(manifestManager.getLastComputedManifest).toHaveBeenCalled(); + expect(manifestManager.buildNewManifest).toHaveBeenCalledWith(lastManifest); + expect(manifestManager.pushArtifacts).toHaveBeenCalledWith([ + ARTIFACT_EXCEPTIONS_MACOS, + ARTIFACT_TRUSTED_APPS_MACOS, + ]); + expect(manifestManager.commit).toHaveBeenCalledWith(newManifest); + expect(manifestManager.tryDispatch).not.toHaveBeenCalled(); + expect(manifestManager.deleteArtifacts).not.toHaveBeenCalled(); + }); + + test('Should stop the process when there are errors dispatching manifest', async () => { + const manifestManager = buildManifestManagerMock(); + + const lastManifest = Manifest.getDefault(); + + const newManifest = Manifest.getDefault(); + newManifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + newManifest.addEntry(ARTIFACT_TRUSTED_APPS_MACOS); + + manifestManager.getLastComputedManifest = jest.fn().mockReturnValue(lastManifest); + manifestManager.buildNewManifest = jest.fn().mockResolvedValue(newManifest); + manifestManager.pushArtifacts = jest.fn().mockResolvedValue([]); + manifestManager.commit = jest.fn().mockResolvedValue(null); + manifestManager.tryDispatch = jest.fn().mockResolvedValue([new Error()]); + + await runTask(manifestManager); + + expect(newManifest.getSemanticVersion()).toBe('1.0.1'); + + expect(manifestManager.getLastComputedManifest).toHaveBeenCalled(); + expect(manifestManager.buildNewManifest).toHaveBeenCalledWith(lastManifest); + expect(manifestManager.pushArtifacts).toHaveBeenCalledWith([ + ARTIFACT_EXCEPTIONS_MACOS, + ARTIFACT_TRUSTED_APPS_MACOS, + ]); + expect(manifestManager.commit).toHaveBeenCalledWith(newManifest); + expect(manifestManager.tryDispatch).toHaveBeenCalledWith(newManifest); + expect(manifestManager.deleteArtifacts).not.toHaveBeenCalled(); + }); + + test('Should succeed the process and delete old artifacts', async () => { + const manifestManager = buildManifestManagerMock(); + + const lastManifest = Manifest.getDefault(); + lastManifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + lastManifest.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS); + + const newManifest = Manifest.getDefault(); + newManifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + newManifest.addEntry(ARTIFACT_TRUSTED_APPS_MACOS); + + manifestManager.getLastComputedManifest = jest.fn().mockReturnValue(lastManifest); + manifestManager.buildNewManifest = jest.fn().mockResolvedValue(newManifest); + manifestManager.pushArtifacts = jest.fn().mockResolvedValue([]); + manifestManager.commit = jest.fn().mockResolvedValue(null); + manifestManager.tryDispatch = jest.fn().mockResolvedValue([]); + manifestManager.deleteArtifacts = jest.fn().mockResolvedValue([]); + + await runTask(manifestManager); + + expect(newManifest.getSemanticVersion()).toBe('1.0.1'); + + expect(manifestManager.getLastComputedManifest).toHaveBeenCalled(); + expect(manifestManager.buildNewManifest).toHaveBeenCalledWith(lastManifest); + expect(manifestManager.pushArtifacts).toHaveBeenCalledWith([ARTIFACT_TRUSTED_APPS_MACOS]); + expect(manifestManager.commit).toHaveBeenCalledWith(newManifest); + expect(manifestManager.tryDispatch).toHaveBeenCalledWith(newManifest); + expect(manifestManager.deleteArtifacts).toHaveBeenCalledWith([ARTIFACT_ID_1]); + }); + + test('Should succeed the process but not add or delete artifacts when there are only transitions', async () => { + const manifestManager = buildManifestManagerMock(); + + const lastManifest = Manifest.getDefault(); + lastManifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + lastManifest.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_1); + + const newManifest = Manifest.getDefault(); + newManifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS, TEST_POLICY_ID_1); + newManifest.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_2); + + manifestManager.getLastComputedManifest = jest.fn().mockReturnValue(lastManifest); + manifestManager.buildNewManifest = jest.fn().mockResolvedValue(newManifest); + manifestManager.pushArtifacts = jest.fn().mockResolvedValue([]); + manifestManager.commit = jest.fn().mockResolvedValue(null); + manifestManager.tryDispatch = jest.fn().mockResolvedValue([]); + manifestManager.deleteArtifacts = jest.fn().mockResolvedValue([]); + + await runTask(manifestManager); + + expect(newManifest.getSemanticVersion()).toBe('1.0.1'); + + expect(manifestManager.getLastComputedManifest).toHaveBeenCalled(); + expect(manifestManager.buildNewManifest).toHaveBeenCalledWith(lastManifest); + expect(manifestManager.pushArtifacts).toHaveBeenCalledWith([]); + expect(manifestManager.commit).toHaveBeenCalledWith(newManifest); + expect(manifestManager.tryDispatch).toHaveBeenCalledWith(newManifest); + expect(manifestManager.deleteArtifacts).toHaveBeenCalledWith([]); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/task.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/task.ts index 4f0d8671fb17..04dcb36bf4ea 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/task.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/task.ts @@ -12,8 +12,9 @@ import { TaskManagerStartContract, } from '../../../../../task_manager/server'; import { EndpointAppContext } from '../../types'; -import { reportErrors } from './common'; +import { getArtifactId, reportErrors } from './common'; import { InternalArtifactCompleteSchema } from '../../schemas/artifacts'; +import { isEmptyManifestDiff } from './manifest'; export const ManifestTaskConstants = { TIMEOUT: '1m', @@ -114,39 +115,23 @@ export class ManifestTask { return; } - // New computed manifest based on current state of exception list + // New computed manifest based on current manifest const newManifest = await manifestManager.buildNewManifest(oldManifest); - const diffs = newManifest.diff(oldManifest); - - // Compress new artifacts - const adds = diffs.filter((diff) => diff.type === 'add').map((diff) => diff.id); - for (const artifactId of adds) { - const compressError = await newManifest.compressArtifact(artifactId); - if (compressError) { - throw compressError; - } - } - // Persist new artifacts - const artifacts = adds - .map((artifactId) => newManifest.getArtifact(artifactId)) - .filter((artifact): artifact is InternalArtifactCompleteSchema => artifact !== undefined); - if (artifacts.length !== adds.length) { - throw new Error('Invalid artifact encountered.'); - } - const persistErrors = await manifestManager.pushArtifacts(artifacts); + const diff = newManifest.diff(oldManifest); + + const persistErrors = await manifestManager.pushArtifacts( + diff.additions as InternalArtifactCompleteSchema[] + ); if (persistErrors.length) { reportErrors(this.logger, persistErrors); throw new Error('Unable to persist new artifacts.'); } - // Commit latest manifest state, if different - if (diffs.length) { + if (!isEmptyManifestDiff(diff)) { + // Commit latest manifest state newManifest.bumpSemanticVersion(); - const error = await manifestManager.commit(newManifest); - if (error) { - throw error; - } + await manifestManager.commit(newManifest); } // Try dispatching to ingest-manager package policies @@ -157,8 +142,9 @@ export class ManifestTask { } // Try to clean up superceded artifacts - const deletes = diffs.filter((diff) => diff.type === 'delete').map((diff) => diff.id); - const deleteErrors = await manifestManager.deleteArtifacts(deletes); + const deleteErrors = await manifestManager.deleteArtifacts( + diff.removals.map((artifact) => getArtifactId(artifact)) + ); if (deleteErrors.length) { reportErrors(this.logger, deleteErrors); } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/artifacts/download_exception_list.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/artifacts/download_artifact.test.ts similarity index 98% rename from x-pack/plugins/security_solution/server/endpoint/routes/artifacts/download_exception_list.test.ts rename to x-pack/plugins/security_solution/server/endpoint/routes/artifacts/download_artifact.test.ts index a2aff41b68df..32bd7379ade2 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/artifacts/download_exception_list.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/artifacts/download_artifact.test.ts @@ -25,7 +25,7 @@ import { loggingSystemMock, } from 'src/core/server/mocks'; import { ArtifactConstants } from '../../lib/artifacts'; -import { registerDownloadExceptionListRoute } from './download_exception_list'; +import { registerDownloadArtifactRoute } from './download_artifact'; import { EndpointAppContextService } from '../../endpoint_app_context_services'; import { createMockEndpointAppContextServiceStartContract } from '../../mocks'; import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__'; @@ -121,7 +121,7 @@ describe('test alerts route', () => { ); endpointAppContextService.start(startContract); - registerDownloadExceptionListRoute( + registerDownloadArtifactRoute( routerMock, { logFactory: loggingSystemMock.create(), diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/artifacts/download_exception_list.ts b/x-pack/plugins/security_solution/server/endpoint/routes/artifacts/download_artifact.ts similarity index 94% rename from x-pack/plugins/security_solution/server/endpoint/routes/artifacts/download_exception_list.ts rename to x-pack/plugins/security_solution/server/endpoint/routes/artifacts/download_artifact.ts index 3dbaa137bb92..020b70ca0553 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/artifacts/download_exception_list.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/artifacts/download_artifact.ts @@ -29,9 +29,9 @@ import { EndpointAppContext } from '../../types'; const allowlistBaseRoute: string = '/api/endpoint/artifacts'; /** - * Registers the exception list route to enable sensors to download an allowlist artifact + * Registers the artifact download route to enable sensors to download an allowlist artifact */ -export function registerDownloadExceptionListRoute( +export function registerDownloadArtifactRoute( router: IRouter, endpointContext: EndpointAppContext, cache: LRU @@ -49,7 +49,7 @@ export function registerDownloadExceptionListRoute( }, async (context, req, res) => { let scopedSOClient: SavedObjectsClientContract; - const logger = endpointContext.logFactory.get('download_exception_list'); + const logger = endpointContext.logFactory.get('download_artifact'); // The ApiKey must be associated with an enrolled Fleet agent try { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/artifacts/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/artifacts/index.ts index a279a9546d3e..a651f93cab09 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/artifacts/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/artifacts/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export * from './download_exception_list'; +export * from './download_artifact'; diff --git a/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/saved_objects.mock.ts b/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/saved_objects.mock.ts index 46d6e36d3deb..dedbcc25e237 100644 --- a/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/saved_objects.mock.ts +++ b/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/saved_objects.mock.ts @@ -46,14 +46,10 @@ export const getInternalArtifactMock = async ( export const getEmptyInternalArtifactMock = async ( os: string, schemaVersion: string, - opts?: { compress: boolean } + opts?: { compress: boolean }, + artifactName: string = ArtifactConstants.GLOBAL_ALLOWLIST_NAME ): Promise => { - const artifact = await buildArtifact( - { entries: [] }, - os, - schemaVersion, - ArtifactConstants.GLOBAL_ALLOWLIST_NAME - ); + const artifact = await buildArtifact({ entries: [] }, os, schemaVersion, artifactName); return opts?.compress ? compressArtifact(artifact) : artifact; }; @@ -74,7 +70,7 @@ export const getInternalArtifactMockWithDiffs = async ( }; export const getInternalManifestMock = (): InternalManifestSchema => ({ - ids: [], + artifacts: [], schemaVersion: 'v1', semanticVersion: '1.0.0', }); diff --git a/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/saved_objects.ts b/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/saved_objects.ts index 47941c068657..675ed41e394a 100644 --- a/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/saved_objects.ts +++ b/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/saved_objects.ts @@ -58,9 +58,17 @@ export const internalArtifactCreateSchema = t.intersection([ ]); export type InternalArtifactCreateSchema = t.TypeOf; +export const internalManifestEntrySchema = t.exact( + t.type({ + policyId: t.union([identifier, t.undefined]), + artifactId: identifier, + }) +); +export type InternalManifestEntrySchema = t.TypeOf; + export const internalManifestSchema = t.exact( t.type({ - ids: t.array(identifier), + artifacts: t.array(internalManifestEntrySchema), schemaVersion: manifestSchemaVersion, semanticVersion, }) diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/artifact_client.mock.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/artifact_client.mock.ts deleted file mode 100644 index c16b10b965cc..000000000000 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/artifact_client.mock.ts +++ /dev/null @@ -1,19 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { savedObjectsClientMock } from 'src/core/server/mocks'; -import { SavedObjectsClientContract } from 'src/core/server'; -import { ArtifactClient } from './artifact_client'; - -export const getArtifactClientMock = ( - savedObjectsClient?: SavedObjectsClientContract -): ArtifactClient => { - if (savedObjectsClient !== undefined) { - return new ArtifactClient(savedObjectsClient); - } - return new ArtifactClient(savedObjectsClientMock.create()); -}; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/artifact_client.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/artifact_client.test.ts index a5fcd24dbc75..b3f098a96933 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/artifact_client.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/artifact_client.test.ts @@ -8,7 +8,6 @@ import { savedObjectsClientMock } from 'src/core/server/mocks'; import { ArtifactConstants, getArtifactId } from '../../lib/artifacts'; import { getInternalArtifactMock } from '../../schemas/artifacts/saved_objects.mock'; -import { getArtifactClientMock } from './artifact_client.mock'; import { ArtifactClient } from './artifact_client'; describe('artifact_client', () => { @@ -20,14 +19,14 @@ describe('artifact_client', () => { test('can get artifact', async () => { const savedObjectsClient = savedObjectsClientMock.create(); - const artifactClient = getArtifactClientMock(savedObjectsClient); + const artifactClient = new ArtifactClient(savedObjectsClient); await artifactClient.getArtifact('abcd'); expect(savedObjectsClient.get).toHaveBeenCalled(); }); test('can create artifact', async () => { const savedObjectsClient = savedObjectsClientMock.create(); - const artifactClient = getArtifactClientMock(savedObjectsClient); + const artifactClient = new ArtifactClient(savedObjectsClient); const artifact = await getInternalArtifactMock('linux', 'v1'); await artifactClient.createArtifact(artifact); expect(savedObjectsClient.create).toHaveBeenCalledWith( @@ -42,7 +41,7 @@ describe('artifact_client', () => { test('can delete artifact', async () => { const savedObjectsClient = savedObjectsClientMock.create(); - const artifactClient = getArtifactClientMock(savedObjectsClient); + const artifactClient = new ArtifactClient(savedObjectsClient); await artifactClient.deleteArtifact('abcd'); expect(savedObjectsClient.delete).toHaveBeenCalledWith( ArtifactConstants.SAVED_OBJECT_TYPE, diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.mock.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.mock.ts index 53d3bdfcb656..a8bbfca0d41e 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.mock.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.mock.ts @@ -5,16 +5,14 @@ * 2.0. */ +import LRU from 'lru-cache'; import { savedObjectsClientMock, loggingSystemMock } from 'src/core/server/mocks'; import { Logger } from 'src/core/server'; import { PackagePolicyServiceInterface } from '../../../../../../fleet/server'; import { createPackagePolicyServiceMock } from '../../../../../../fleet/server/mocks'; import { ExceptionListClient } from '../../../../../../lists/server'; import { listMock } from '../../../../../../lists/server/mocks'; -import LRU from 'lru-cache'; -import { getArtifactClientMock } from '../artifact_client.mock'; -import { getManifestClientMock } from '../manifest_client.mock'; -import { ManifestManager } from './manifest_manager'; +import { ExceptionListItemSchema } from '../../../../../../lists/common/schemas/response'; import { createPackagePolicyWithManifestMock, createPackagePolicyWithInitialManifestMock, @@ -22,6 +20,32 @@ import { getMockArtifactsWithDiff, getEmptyMockArtifacts, } from '../../../lib/artifacts/mocks'; +import { ArtifactClient } from '../artifact_client'; +import { getManifestClientMock } from '../manifest_client.mock'; +import { ManifestManager, ManifestManagerContext } from './manifest_manager'; + +export const createExceptionListResponse = (data: ExceptionListItemSchema[], total?: number) => ({ + data, + page: 1, + per_page: 100, + total: total || data.length, +}); + +type FindExceptionListItemOptions = Parameters[0]; + +const FILTER_REGEXP = /^exception-list-agnostic\.attributes\.os_types:"(\w+)"$/; + +export const mockFindExceptionListItemResponses = ( + responses: Record> +) => { + return jest.fn().mockImplementation((options: FindExceptionListItemOptions) => { + const os = FILTER_REGEXP.test(options.filter || '') + ? options.filter!.match(FILTER_REGEXP)![1] + : ''; + + return createExceptionListResponse(responses[options.listId]?.[os] || []); + }); +}; export enum ManifestManagerMockType { InitialSystemState, @@ -29,28 +53,54 @@ export enum ManifestManagerMockType { NormalFlow, } -export const getManifestManagerMock = (opts?: { - mockType?: ManifestManagerMockType; - cache?: LRU; - exceptionListClient?: ExceptionListClient; - packagePolicyService?: jest.Mocked; - savedObjectsClient?: ReturnType; -}): ManifestManager => { - let cache = new LRU({ max: 10, maxAge: 1000 * 60 * 60 }); - if (opts?.cache != null) { - cache = opts.cache; - } +export interface ManifestManagerMockOptions { + cache: LRU; + exceptionListClient: ExceptionListClient; + packagePolicyService: jest.Mocked; + savedObjectsClient: ReturnType; +} - let exceptionListClient = listMock.getExceptionListClient(); - if (opts?.exceptionListClient != null) { - exceptionListClient = opts.exceptionListClient; - } +export const buildManifestManagerMockOptions = ( + opts: Partial +): ManifestManagerMockOptions => ({ + cache: new LRU({ max: 10, maxAge: 1000 * 60 * 60 }), + exceptionListClient: listMock.getExceptionListClient(), + packagePolicyService: createPackagePolicyServiceMock(), + savedObjectsClient: savedObjectsClientMock.create(), + ...opts, +}); - let packagePolicyService = createPackagePolicyServiceMock(); - if (opts?.packagePolicyService != null) { - packagePolicyService = opts.packagePolicyService; - } - packagePolicyService.list = jest.fn().mockResolvedValue({ +export const buildManifestManagerContextMock = ( + opts: Partial +): ManifestManagerContext => { + const fullOpts = buildManifestManagerMockOptions(opts); + + return { + ...fullOpts, + artifactClient: new ArtifactClient(fullOpts.savedObjectsClient), + logger: loggingSystemMock.create().get() as jest.Mocked, + }; +}; + +export const buildManifestManagerMock = (opts?: Partial) => { + const manifestManager = new ManifestManager(buildManifestManagerContextMock(opts || {})); + manifestManager.getLastComputedManifest = jest.fn(); + manifestManager.buildNewManifest = jest.fn(); + manifestManager.pushArtifacts = jest.fn(); + manifestManager.deleteArtifacts = jest.fn(); + manifestManager.commit = jest.fn(); + manifestManager.tryDispatch = jest.fn(); + + return manifestManager; +}; + +export const getManifestManagerMock = ( + opts?: Partial & { mockType?: ManifestManagerMockType } +): ManifestManager => { + const { mockType = ManifestManagerMockType.NormalFlow, ...restOptions } = opts || {}; + const context = buildManifestManagerContextMock(restOptions); + + context.packagePolicyService.list = jest.fn().mockResolvedValue({ total: 1, items: [ { version: 'policy-1-version', ...createPackagePolicyWithManifestMock() }, @@ -59,19 +109,13 @@ export const getManifestManagerMock = (opts?: { ], }); - let savedObjectsClient = savedObjectsClientMock.create(); - if (opts?.savedObjectsClient != null) { - savedObjectsClient = opts.savedObjectsClient; - } - class ManifestManagerMock extends ManifestManager { protected buildExceptionListArtifacts = jest.fn().mockImplementation(() => { - const mockType = opts?.mockType ?? ManifestManagerMockType.NormalFlow; switch (mockType) { case ManifestManagerMockType.InitialSystemState: return getEmptyMockArtifacts(); case ManifestManagerMockType.ListClientPromiseRejection: - exceptionListClient.findExceptionListItem = jest + context.exceptionListClient.findExceptionListItem = jest .fn() .mockRejectedValue(new Error('unexpected thing happened')); return super.buildExceptionListArtifacts('v1'); @@ -81,7 +125,6 @@ export const getManifestManagerMock = (opts?: { }); public getLastComputedManifest = jest.fn().mockImplementation(() => { - const mockType = opts?.mockType ?? ManifestManagerMockType.NormalFlow; switch (mockType) { case ManifestManagerMockType.InitialSystemState: return null; @@ -95,14 +138,5 @@ export const getManifestManagerMock = (opts?: { .mockReturnValue(getManifestClientMock(this.savedObjectsClient)); } - const manifestManager = new ManifestManagerMock({ - artifactClient: getArtifactClientMock(savedObjectsClient), - cache, - packagePolicyService, - exceptionListClient, - logger: loggingSystemMock.create().get() as jest.Mocked, - savedObjectsClient, - }); - - return manifestManager; + return new ManifestManagerMock(context); }; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts index eedd3dad2cdb..52897f473189 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts @@ -6,326 +6,854 @@ */ import { inflateSync } from 'zlib'; +import { SavedObjectsErrorHelpers } from 'src/core/server'; import { savedObjectsClientMock } from 'src/core/server/mocks'; -import { createPackagePolicyServiceMock } from '../../../../../../fleet/server/mocks'; -import { ArtifactConstants, ManifestConstants, isCompleteArtifact } from '../../../lib/artifacts'; - -import { getManifestManagerMock, ManifestManagerMockType } from './manifest_manager.mock'; -import LRU from 'lru-cache'; - -describe('manifest_manager', () => { - describe('ManifestManager sanity checks', () => { - test('ManifestManager can retrieve and diff manifests', async () => { - const manifestManager = getManifestManagerMock(); - const oldManifest = await manifestManager.getLastComputedManifest(); - const newManifest = await manifestManager.buildNewManifest(oldManifest!); - expect(newManifest.diff(oldManifest!)).toEqual([ - { - id: - 'endpoint-exceptionlist-macos-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - type: 'delete', - }, - { - id: - 'endpoint-trustlist-macos-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - type: 'delete', - }, - { - id: - 'endpoint-trustlist-windows-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - type: 'delete', - }, - { - id: - 'endpoint-trustlist-linux-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - type: 'delete', - }, - { - id: - 'endpoint-exceptionlist-macos-v1-0a5a2013a79f9e60682472284a1be45ab1ff68b9b43426d00d665016612c15c8', - type: 'add', - }, - { - id: - 'endpoint-trustlist-macos-v1-1a8295e6ccb93022c6f5ceb8997b29f2912389b3b38f52a8f5a2ff7b0154b1bc', - type: 'add', - }, - { - id: - 'endpoint-trustlist-windows-v1-1a8295e6ccb93022c6f5ceb8997b29f2912389b3b38f52a8f5a2ff7b0154b1bc', - type: 'add', - }, - { - id: - 'endpoint-trustlist-linux-v1-1a8295e6ccb93022c6f5ceb8997b29f2912389b3b38f52a8f5a2ff7b0154b1bc', - type: 'add', - }, - ]); +import { ENDPOINT_LIST_ID } from '../../../../../../lists/common'; +import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../../../../../lists/common/constants'; +import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; +import { PackagePolicy } from '../../../../../../fleet/common/types/models'; +import { getEmptyInternalArtifactMock } from '../../../schemas/artifacts/saved_objects.mock'; +import { + InternalArtifactCompleteSchema, + InternalArtifactSchema, + InternalManifestSchema, +} from '../../../schemas/artifacts'; +import { + createPackagePolicyWithConfigMock, + getMockArtifacts, + toArtifactRecords, +} from '../../../lib/artifacts/mocks'; +import { + ArtifactConstants, + ManifestConstants, + getArtifactId, + isCompressed, + translateToEndpointExceptions, + Manifest, +} from '../../../lib/artifacts'; + +import { + buildManifestManagerContextMock, + mockFindExceptionListItemResponses, +} from './manifest_manager.mock'; + +import { ManifestManager } from './manifest_manager'; + +const uncompressData = async (data: Buffer) => JSON.parse(await inflateSync(data).toString()); + +const uncompressArtifact = async (artifact: InternalArtifactSchema) => + uncompressData(Buffer.from(artifact.body!, 'base64')); + +describe('ManifestManager', () => { + const TEST_POLICY_ID_1 = 'c6d16e42-c32d-4dce-8a88-113cfe276ad1'; + const TEST_POLICY_ID_2 = '93c46720-c217-11ea-9906-b5b8a21b268e'; + const ARTIFACT_ID_EXCEPTIONS_MACOS = + 'endpoint-exceptionlist-macos-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3'; + const ARTIFACT_ID_EXCEPTIONS_WINDOWS = + 'endpoint-exceptionlist-windows-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3'; + const ARTIFACT_ID_TRUSTED_APPS_MACOS = + 'endpoint-trustlist-macos-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3'; + const ARTIFACT_ID_TRUSTED_APPS_WINDOWS = + 'endpoint-trustlist-windows-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3'; + + const ARTIFACT_NAME_EXCEPTIONS_MACOS = 'endpoint-exceptionlist-macos-v1'; + const ARTIFACT_NAME_EXCEPTIONS_WINDOWS = 'endpoint-exceptionlist-windows-v1'; + const ARTIFACT_NAME_TRUSTED_APPS_MACOS = 'endpoint-trustlist-macos-v1'; + const ARTIFACT_NAME_TRUSTED_APPS_WINDOWS = 'endpoint-trustlist-windows-v1'; + const ARTIFACT_NAME_TRUSTED_APPS_LINUX = 'endpoint-trustlist-linux-v1'; + + let ARTIFACTS: InternalArtifactCompleteSchema[] = []; + let ARTIFACTS_BY_ID: { [K: string]: InternalArtifactCompleteSchema } = {}; + let ARTIFACT_EXCEPTIONS_MACOS: InternalArtifactCompleteSchema; + let ARTIFACT_EXCEPTIONS_WINDOWS: InternalArtifactCompleteSchema; + let ARTIFACT_TRUSTED_APPS_MACOS: InternalArtifactCompleteSchema; + let ARTIFACT_TRUSTED_APPS_WINDOWS: InternalArtifactCompleteSchema; + + beforeAll(async () => { + ARTIFACTS = await getMockArtifacts({ compress: true }); + ARTIFACTS_BY_ID = { + [ARTIFACT_ID_EXCEPTIONS_MACOS]: ARTIFACTS[0], + [ARTIFACT_ID_EXCEPTIONS_WINDOWS]: ARTIFACTS[1], + [ARTIFACT_ID_TRUSTED_APPS_MACOS]: ARTIFACTS[2], + [ARTIFACT_ID_TRUSTED_APPS_WINDOWS]: ARTIFACTS[3], + }; + ARTIFACT_EXCEPTIONS_MACOS = ARTIFACTS[0]; + ARTIFACT_EXCEPTIONS_WINDOWS = ARTIFACTS[1]; + ARTIFACT_TRUSTED_APPS_MACOS = ARTIFACTS[2]; + ARTIFACT_TRUSTED_APPS_WINDOWS = ARTIFACTS[3]; + }); + + describe('getLastComputedManifest', () => { + test('Returns null when saved object not found', async () => { + const savedObjectsClient = savedObjectsClientMock.create(); + const manifestManager = new ManifestManager( + buildManifestManagerContextMock({ savedObjectsClient }) + ); + + savedObjectsClient.get = jest.fn().mockRejectedValue({ output: { statusCode: 404 } }); + + expect(await manifestManager.getLastComputedManifest()).toBe(null); }); - test('ManifestManager populates cache properly', async () => { - const cache = new LRU({ max: 10, maxAge: 1000 * 60 * 60 }); - const manifestManager = getManifestManagerMock({ cache }); - const oldManifest = await manifestManager.getLastComputedManifest(); - const newManifest = await manifestManager.buildNewManifest(oldManifest!); - const diffs = newManifest.diff(oldManifest!); - expect(diffs).toEqual([ - { - id: - 'endpoint-exceptionlist-macos-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - type: 'delete', - }, - { - id: - 'endpoint-trustlist-macos-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - type: 'delete', - }, - { - id: - 'endpoint-trustlist-windows-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - type: 'delete', - }, - { - id: - 'endpoint-trustlist-linux-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - type: 'delete', - }, - { - id: - 'endpoint-exceptionlist-macos-v1-0a5a2013a79f9e60682472284a1be45ab1ff68b9b43426d00d665016612c15c8', - type: 'add', - }, - { - id: - 'endpoint-trustlist-macos-v1-1a8295e6ccb93022c6f5ceb8997b29f2912389b3b38f52a8f5a2ff7b0154b1bc', - type: 'add', - }, - { - id: - 'endpoint-trustlist-windows-v1-1a8295e6ccb93022c6f5ceb8997b29f2912389b3b38f52a8f5a2ff7b0154b1bc', - type: 'add', - }, - { - id: - 'endpoint-trustlist-linux-v1-1a8295e6ccb93022c6f5ceb8997b29f2912389b3b38f52a8f5a2ff7b0154b1bc', - type: 'add', - }, - ]); + test('Throws error when saved object client responds with 500', async () => { + const savedObjectsClient = savedObjectsClientMock.create(); + const manifestManager = new ManifestManager( + buildManifestManagerContextMock({ savedObjectsClient }) + ); + const error = { output: { statusCode: 500 } }; - const firstNewArtifactId = diffs.find((diff) => diff.type === 'add')!.id; + savedObjectsClient.get = jest.fn().mockRejectedValue(error); - // Compress all `add` artifacts - for (const artifactDiff of diffs) { - if (artifactDiff.type === 'add') { - await newManifest.compressArtifact(artifactDiff.id); - } - } + await expect(manifestManager.getLastComputedManifest()).rejects.toStrictEqual(error); + }); + + test('Throws error when no version on the manifest', async () => { + const savedObjectsClient = savedObjectsClientMock.create(); + const manifestManager = new ManifestManager( + buildManifestManagerContextMock({ savedObjectsClient }) + ); - const artifact = newManifest.getArtifact(firstNewArtifactId)!; + savedObjectsClient.get = jest.fn().mockResolvedValue({}); - if (isCompleteArtifact(artifact)) { - await manifestManager.pushArtifacts([artifact]); // caches the artifact - } else { - throw new Error('Artifact is missing a body.'); - } + await expect(manifestManager.getLastComputedManifest()).rejects.toStrictEqual( + new Error('No version returned for manifest.') + ); + }); + + test('Retrieves empty manifest successfully', async () => { + const savedObjectsClient = savedObjectsClientMock.create(); + const manifestManager = new ManifestManager( + buildManifestManagerContextMock({ savedObjectsClient }) + ); + + savedObjectsClient.get = jest.fn().mockResolvedValue({ + attributes: { + created: '20-01-2020 10:00:00.000Z', + schemaVersion: 'v2', + semanticVersion: '1.0.0', + artifacts: [], + }, + version: '2.0.0', + }); + + const manifest = await manifestManager.getLastComputedManifest(); + + expect(manifest?.getSchemaVersion()).toStrictEqual('v1'); + expect(manifest?.getSemanticVersion()).toStrictEqual('1.0.0'); + expect(manifest?.getSavedObjectVersion()).toStrictEqual('2.0.0'); + expect(manifest?.getAllArtifacts()).toStrictEqual([]); + }); + + test('Retrieves non empty manifest successfully', async () => { + const savedObjectsClient = savedObjectsClientMock.create(); + const manifestManager = new ManifestManager( + buildManifestManagerContextMock({ savedObjectsClient }) + ); - const entry = JSON.parse(inflateSync(cache.get(firstNewArtifactId)! as Buffer).toString()); - expect(entry).toEqual({ - entries: [ - { - type: 'simple', - entries: [ - { - entries: [ - { - field: 'some.nested.field', - operator: 'included', - type: 'exact_cased', - value: 'some value', - }, + savedObjectsClient.get = jest + .fn() + .mockImplementation(async (objectType: string, id: string) => { + if (objectType === ManifestConstants.SAVED_OBJECT_TYPE) { + return { + attributes: { + created: '20-01-2020 10:00:00.000Z', + schemaVersion: 'v2', + semanticVersion: '1.0.0', + artifacts: [ + { artifactId: ARTIFACT_ID_EXCEPTIONS_MACOS, policyId: undefined }, + { artifactId: ARTIFACT_ID_EXCEPTIONS_WINDOWS, policyId: undefined }, + { artifactId: ARTIFACT_ID_EXCEPTIONS_WINDOWS, policyId: TEST_POLICY_ID_1 }, + { artifactId: ARTIFACT_ID_TRUSTED_APPS_MACOS, policyId: TEST_POLICY_ID_1 }, + { artifactId: ARTIFACT_ID_TRUSTED_APPS_WINDOWS, policyId: TEST_POLICY_ID_1 }, + { artifactId: ARTIFACT_ID_TRUSTED_APPS_WINDOWS, policyId: TEST_POLICY_ID_2 }, ], - field: 'some.parentField', - type: 'nested', - }, - { - field: 'some.not.nested.field', - operator: 'included', - type: 'exact_cased', - value: 'some value', }, - ], - }, - ], - }); + version: '2.0.0', + }; + } else if (objectType === ArtifactConstants.SAVED_OBJECT_TYPE) { + return { attributes: ARTIFACTS_BY_ID[id], version: '2.1.1' }; + } else { + return null; + } + }); + + const manifest = await manifestManager.getLastComputedManifest(); + + expect(manifest?.getSchemaVersion()).toStrictEqual('v1'); + expect(manifest?.getSemanticVersion()).toStrictEqual('1.0.0'); + expect(manifest?.getSavedObjectVersion()).toStrictEqual('2.0.0'); + expect(manifest?.getAllArtifacts()).toStrictEqual(ARTIFACTS.slice(0, 4)); + expect(manifest?.isDefaultArtifact(ARTIFACT_EXCEPTIONS_MACOS)).toBe(true); + expect(manifest?.getArtifactTargetPolicies(ARTIFACT_EXCEPTIONS_MACOS)).toStrictEqual( + new Set() + ); + expect(manifest?.isDefaultArtifact(ARTIFACT_EXCEPTIONS_WINDOWS)).toBe(true); + expect(manifest?.getArtifactTargetPolicies(ARTIFACT_EXCEPTIONS_WINDOWS)).toStrictEqual( + new Set([TEST_POLICY_ID_1]) + ); + expect(manifest?.isDefaultArtifact(ARTIFACT_TRUSTED_APPS_MACOS)).toBe(false); + expect(manifest?.getArtifactTargetPolicies(ARTIFACT_TRUSTED_APPS_MACOS)).toStrictEqual( + new Set([TEST_POLICY_ID_1]) + ); + expect(manifest?.isDefaultArtifact(ARTIFACT_TRUSTED_APPS_WINDOWS)).toBe(false); + expect(manifest?.getArtifactTargetPolicies(ARTIFACT_TRUSTED_APPS_WINDOWS)).toStrictEqual( + new Set([TEST_POLICY_ID_1, TEST_POLICY_ID_2]) + ); }); + }); + + describe('buildNewManifest', () => { + const SUPPORTED_ARTIFACT_NAMES = [ + ARTIFACT_NAME_EXCEPTIONS_MACOS, + ARTIFACT_NAME_EXCEPTIONS_WINDOWS, + ARTIFACT_NAME_TRUSTED_APPS_MACOS, + ARTIFACT_NAME_TRUSTED_APPS_WINDOWS, + ARTIFACT_NAME_TRUSTED_APPS_LINUX, + ]; + + const getArtifactIds = (artifacts: InternalArtifactSchema[]) => + artifacts.map((artifact) => artifact.identifier); - test('ManifestManager cannot dispatch incomplete (uncompressed) artifact', async () => { - const packagePolicyService = createPackagePolicyServiceMock(); - const manifestManager = getManifestManagerMock({ packagePolicyService }); - const oldManifest = await manifestManager.getLastComputedManifest(); - const newManifest = await manifestManager.buildNewManifest(oldManifest!); - const dispatchErrors = await manifestManager.tryDispatch(newManifest); - expect(dispatchErrors.length).toEqual(1); - expect(dispatchErrors[0].message).toEqual('Invalid manifest'); + test('Fails when exception list list client fails', async () => { + const context = buildManifestManagerContextMock({}); + const manifestManager = new ManifestManager(context); + + context.exceptionListClient.findExceptionListItem = jest.fn().mockRejectedValue(new Error()); + + await expect(manifestManager.buildNewManifest()).rejects.toThrow(); }); - test('ManifestManager can dispatch manifest', async () => { - const packagePolicyService = createPackagePolicyServiceMock(); - const manifestManager = getManifestManagerMock({ packagePolicyService }); - const oldManifest = await manifestManager.getLastComputedManifest(); - const newManifest = await manifestManager.buildNewManifest(oldManifest!); - const diffs = newManifest.diff(oldManifest!); + test('Builds fully new manifest if no baseline parameter passed and no exception list items', async () => { + const context = buildManifestManagerContextMock({}); + const manifestManager = new ManifestManager(context); - for (const artifactDiff of diffs) { - if (artifactDiff.type === 'add') { - await newManifest.compressArtifact(artifactDiff.id); - } - } + context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({}); - newManifest.bumpSemanticVersion(); + const manifest = await manifestManager.buildNewManifest(); - const dispatchErrors = await manifestManager.tryDispatch(newManifest); + expect(manifest?.getSchemaVersion()).toStrictEqual('v1'); + expect(manifest?.getSemanticVersion()).toStrictEqual('1.0.0'); + expect(manifest?.getSavedObjectVersion()).toBeUndefined(); - expect(dispatchErrors).toEqual([]); + const artifacts = manifest.getAllArtifacts(); - // 2 policies updated... 1 is already up-to-date - expect(packagePolicyService.update.mock.calls.length).toEqual(2); + expect(getArtifactIds(artifacts)).toStrictEqual(SUPPORTED_ARTIFACT_NAMES); + expect(artifacts.every(isCompressed)).toBe(true); - expect( - packagePolicyService.update.mock.calls[0][3].inputs[0].config!.artifact_manifest.value - ).toEqual({ - manifest_version: '1.0.1', - schema_version: 'v1', - artifacts: { - 'endpoint-exceptionlist-macos-v1': { - compression_algorithm: 'zlib', - encryption_algorithm: 'none', - decoded_sha256: '0a5a2013a79f9e60682472284a1be45ab1ff68b9b43426d00d665016612c15c8', - encoded_sha256: '57941169bb2c5416f9bd7224776c8462cb9a2be0fe8b87e6213e77a1d29be824', - decoded_size: 292, - encoded_size: 131, - relative_url: - '/api/endpoint/artifacts/download/endpoint-exceptionlist-macos-v1/0a5a2013a79f9e60682472284a1be45ab1ff68b9b43426d00d665016612c15c8', - }, - 'endpoint-exceptionlist-windows-v1': { - compression_algorithm: 'zlib', - encryption_algorithm: 'none', - decoded_sha256: '96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - encoded_sha256: '975382ab55d019cbab0bbac207a54e2a7d489fad6e8f6de34fc6402e5ef37b1e', - decoded_size: 432, - encoded_size: 147, - relative_url: - '/api/endpoint/artifacts/download/endpoint-exceptionlist-windows-v1/96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - }, - 'endpoint-trustlist-linux-v1': { - compression_algorithm: 'zlib', - decoded_sha256: '1a8295e6ccb93022c6f5ceb8997b29f2912389b3b38f52a8f5a2ff7b0154b1bc', - decoded_size: 287, - encoded_sha256: 'c3dec543df1177561ab2aa74a37997ea3c1d748d532a597884f5a5c16670d56c', - encoded_size: 133, - encryption_algorithm: 'none', - relative_url: - '/api/endpoint/artifacts/download/endpoint-trustlist-linux-v1/1a8295e6ccb93022c6f5ceb8997b29f2912389b3b38f52a8f5a2ff7b0154b1bc', - }, - 'endpoint-trustlist-macos-v1': { - compression_algorithm: 'zlib', - decoded_sha256: '1a8295e6ccb93022c6f5ceb8997b29f2912389b3b38f52a8f5a2ff7b0154b1bc', - decoded_size: 287, - encoded_sha256: 'c3dec543df1177561ab2aa74a37997ea3c1d748d532a597884f5a5c16670d56c', - encoded_size: 133, - encryption_algorithm: 'none', - relative_url: - '/api/endpoint/artifacts/download/endpoint-trustlist-macos-v1/1a8295e6ccb93022c6f5ceb8997b29f2912389b3b38f52a8f5a2ff7b0154b1bc', - }, - 'endpoint-trustlist-windows-v1': { - compression_algorithm: 'zlib', - decoded_sha256: '1a8295e6ccb93022c6f5ceb8997b29f2912389b3b38f52a8f5a2ff7b0154b1bc', - decoded_size: 287, - encoded_sha256: 'c3dec543df1177561ab2aa74a37997ea3c1d748d532a597884f5a5c16670d56c', - encoded_size: 133, - encryption_algorithm: 'none', - relative_url: - '/api/endpoint/artifacts/download/endpoint-trustlist-windows-v1/1a8295e6ccb93022c6f5ceb8997b29f2912389b3b38f52a8f5a2ff7b0154b1bc', - }, - }, - }); + for (const artifact of artifacts) { + expect(await uncompressArtifact(artifact)).toStrictEqual({ entries: [] }); + } }); - test('ManifestManager fails to dispatch on conflict', async () => { - const packagePolicyService = createPackagePolicyServiceMock(); - const manifestManager = getManifestManagerMock({ packagePolicyService }); - const oldManifest = await manifestManager.getLastComputedManifest(); - const newManifest = await manifestManager.buildNewManifest(oldManifest!); - const diffs = newManifest.diff(oldManifest!); + test('Builds fully new manifest if no baseline parameter passed and present exception list items', async () => { + const exceptionListItem = getExceptionListItemSchemaMock({ os_types: ['macos'] }); + const trustedAppListItem = getExceptionListItemSchemaMock({ os_types: ['linux'] }); + const context = buildManifestManagerContextMock({}); + const manifestManager = new ManifestManager(context); + + context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({ + [ENDPOINT_LIST_ID]: { macos: [exceptionListItem] }, + [ENDPOINT_TRUSTED_APPS_LIST_ID]: { linux: [trustedAppListItem] }, + }); + + const manifest = await manifestManager.buildNewManifest(); + + expect(manifest?.getSchemaVersion()).toStrictEqual('v1'); + expect(manifest?.getSemanticVersion()).toStrictEqual('1.0.0'); + expect(manifest?.getSavedObjectVersion()).toBeUndefined(); + + const artifacts = manifest.getAllArtifacts(); - for (const artifactDiff of diffs) { - if (artifactDiff.type === 'add') { - await newManifest.compressArtifact(artifactDiff.id); + expect(getArtifactIds(artifacts)).toStrictEqual(SUPPORTED_ARTIFACT_NAMES); + expect(artifacts.every(isCompressed)).toBe(true); + + for (const artifact of artifacts) { + if (artifact.identifier === ARTIFACT_NAME_EXCEPTIONS_MACOS) { + expect(await uncompressArtifact(artifact)).toStrictEqual({ + entries: translateToEndpointExceptions([exceptionListItem], 'v1'), + }); + } else if (artifact.identifier === 'endpoint-trustlist-linux-v1') { + expect(await uncompressArtifact(artifact)).toStrictEqual({ + entries: translateToEndpointExceptions([trustedAppListItem], 'v1'), + }); + } else { + expect(await uncompressArtifact(artifact)).toStrictEqual({ entries: [] }); } } + }); - newManifest.bumpSemanticVersion(); + test('Reuses artifacts when baseline parameter passed and present exception list items', async () => { + const exceptionListItem = getExceptionListItemSchemaMock({ os_types: ['macos'] }); + const trustedAppListItem = getExceptionListItemSchemaMock({ os_types: ['linux'] }); + const context = buildManifestManagerContextMock({}); + const manifestManager = new ManifestManager(context); - packagePolicyService.update.mockRejectedValueOnce({ status: 409 }); - const dispatchErrors = await manifestManager.tryDispatch(newManifest); - expect(dispatchErrors).toEqual([{ status: 409 }]); - }); + context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({ + [ENDPOINT_LIST_ID]: { macos: [exceptionListItem] }, + }); - test('ManifestManager can commit manifest', async () => { - const savedObjectsClient: ReturnType< - typeof savedObjectsClientMock.create - > = savedObjectsClientMock.create(); - const manifestManager = getManifestManagerMock({ - savedObjectsClient, + const oldManifest = await manifestManager.buildNewManifest(); + + context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({ + [ENDPOINT_LIST_ID]: { macos: [exceptionListItem] }, + [ENDPOINT_TRUSTED_APPS_LIST_ID]: { linux: [trustedAppListItem] }, }); - const oldManifest = await manifestManager.getLastComputedManifest(); - const newManifest = await manifestManager.buildNewManifest(oldManifest!); - const diffs = newManifest.diff(oldManifest!); - const firstOldArtifactId = diffs.find((diff) => diff.type === 'delete')!.id; - const FirstNewArtifactId = diffs.find((diff) => diff.type === 'add')!.id; + const manifest = await manifestManager.buildNewManifest(oldManifest); + + expect(manifest?.getSchemaVersion()).toStrictEqual('v1'); + expect(manifest?.getSemanticVersion()).toStrictEqual('1.0.0'); + expect(manifest?.getSavedObjectVersion()).toBeUndefined(); + + const artifacts = manifest.getAllArtifacts(); - // Compress all new artifacts - for (const artifactDiff of diffs) { - if (artifactDiff.type === 'add') { - await newManifest.compressArtifact(artifactDiff.id); + expect(getArtifactIds(artifacts)).toStrictEqual(SUPPORTED_ARTIFACT_NAMES); + expect(artifacts.every(isCompressed)).toBe(true); + + for (const artifact of artifacts) { + if (artifact.identifier === ARTIFACT_NAME_EXCEPTIONS_MACOS) { + expect(artifact).toStrictEqual(oldManifest.getAllArtifacts()[0]); + } else if (artifact.identifier === 'endpoint-trustlist-linux-v1') { + expect(await uncompressArtifact(artifact)).toStrictEqual({ + entries: translateToEndpointExceptions([trustedAppListItem], 'v1'), + }); + } else { + expect(await uncompressArtifact(artifact)).toStrictEqual({ entries: [] }); } } + }); + }); - const artifact = newManifest.getArtifact(FirstNewArtifactId)!; - if (isCompleteArtifact(artifact)) { - await manifestManager.pushArtifacts([artifact]); - } else { - throw new Error('Artifact is missing a body.'); - } + describe('deleteArtifacts', () => { + test('Successfully invokes saved objects client', async () => { + const context = buildManifestManagerContextMock({}); + const manifestManager = new ManifestManager(context); + + context.savedObjectsClient.delete = jest.fn().mockResolvedValue({}); + + await expect( + manifestManager.deleteArtifacts([ + ARTIFACT_ID_EXCEPTIONS_MACOS, + ARTIFACT_ID_EXCEPTIONS_WINDOWS, + ]) + ).resolves.toStrictEqual([]); + + expect(context.savedObjectsClient.delete).toHaveBeenNthCalledWith( + 1, + ArtifactConstants.SAVED_OBJECT_TYPE, + ARTIFACT_ID_EXCEPTIONS_MACOS + ); + expect(context.savedObjectsClient.delete).toHaveBeenNthCalledWith( + 2, + ArtifactConstants.SAVED_OBJECT_TYPE, + ARTIFACT_ID_EXCEPTIONS_WINDOWS + ); + }); + + test('Returns errors for partial failures', async () => { + const context = buildManifestManagerContextMock({}); + const manifestManager = new ManifestManager(context); + const error = new Error(); + + context.savedObjectsClient.delete = jest + .fn() + .mockImplementation(async (type: string, id: string) => { + if (id === ARTIFACT_ID_EXCEPTIONS_WINDOWS) { + throw error; + } else { + return {}; + } + }); - await manifestManager.commit(newManifest); - await manifestManager.deleteArtifacts([firstOldArtifactId]); + await expect( + manifestManager.deleteArtifacts([ + ARTIFACT_ID_EXCEPTIONS_MACOS, + ARTIFACT_ID_EXCEPTIONS_WINDOWS, + ]) + ).resolves.toStrictEqual([error]); - // created new artifact - expect(savedObjectsClient.create.mock.calls[0][0]).toEqual( - ArtifactConstants.SAVED_OBJECT_TYPE + expect(context.savedObjectsClient.delete).toHaveBeenCalledTimes(2); + expect(context.savedObjectsClient.delete).toHaveBeenNthCalledWith( + 1, + ArtifactConstants.SAVED_OBJECT_TYPE, + ARTIFACT_ID_EXCEPTIONS_MACOS + ); + expect(context.savedObjectsClient.delete).toHaveBeenNthCalledWith( + 2, + ArtifactConstants.SAVED_OBJECT_TYPE, + ARTIFACT_ID_EXCEPTIONS_WINDOWS ); + }); + }); + + describe('pushArtifacts', () => { + test('Successfully invokes saved objects client and stores in the cache', async () => { + const context = buildManifestManagerContextMock({}); + const manifestManager = new ManifestManager(context); + + context.savedObjectsClient.create = jest + .fn() + .mockImplementation((type: string, artifact: InternalArtifactCompleteSchema) => artifact); + + await expect( + manifestManager.pushArtifacts([ARTIFACT_EXCEPTIONS_MACOS, ARTIFACT_EXCEPTIONS_WINDOWS]) + ).resolves.toStrictEqual([]); + + expect(context.savedObjectsClient.create).toHaveBeenCalledTimes(2); + expect(context.savedObjectsClient.create).toHaveBeenNthCalledWith( + 1, + ArtifactConstants.SAVED_OBJECT_TYPE, + { ...ARTIFACT_EXCEPTIONS_MACOS, created: expect.anything() }, + { id: ARTIFACT_ID_EXCEPTIONS_MACOS } + ); + expect(context.savedObjectsClient.create).toHaveBeenNthCalledWith( + 2, + ArtifactConstants.SAVED_OBJECT_TYPE, + { ...ARTIFACT_EXCEPTIONS_WINDOWS, created: expect.anything() }, + { id: ARTIFACT_ID_EXCEPTIONS_WINDOWS } + ); + expect( + await uncompressData(context.cache.get(getArtifactId(ARTIFACT_EXCEPTIONS_MACOS))!) + ).toStrictEqual(await uncompressArtifact(ARTIFACT_EXCEPTIONS_MACOS)); + expect( + await uncompressData(context.cache.get(getArtifactId(ARTIFACT_EXCEPTIONS_WINDOWS))!) + ).toStrictEqual(await uncompressArtifact(ARTIFACT_EXCEPTIONS_WINDOWS)); + }); + + test('Returns errors for partial failures', async () => { + const context = buildManifestManagerContextMock({}); + const manifestManager = new ManifestManager(context); + const error = new Error(); + const { body, ...incompleteArtifact } = ARTIFACT_TRUSTED_APPS_MACOS; + + context.savedObjectsClient.create = jest + .fn() + .mockImplementation(async (type: string, artifact: InternalArtifactCompleteSchema) => { + if (getArtifactId(artifact) === ARTIFACT_ID_EXCEPTIONS_WINDOWS) { + throw error; + } else { + return artifact; + } + }); + + await expect( + manifestManager.pushArtifacts([ + ARTIFACT_EXCEPTIONS_MACOS, + ARTIFACT_EXCEPTIONS_WINDOWS, + incompleteArtifact as InternalArtifactCompleteSchema, + ]) + ).resolves.toStrictEqual([ + error, + new Error(`Incomplete artifact: ${ARTIFACT_ID_TRUSTED_APPS_MACOS}`), + ]); - // committed new manifest - expect(savedObjectsClient.create.mock.calls[1][0]).toEqual( - ManifestConstants.SAVED_OBJECT_TYPE + expect(context.savedObjectsClient.create).toHaveBeenCalledTimes(2); + expect(context.savedObjectsClient.create).toHaveBeenNthCalledWith( + 1, + ArtifactConstants.SAVED_OBJECT_TYPE, + { ...ARTIFACT_EXCEPTIONS_MACOS, created: expect.anything() }, + { id: ARTIFACT_ID_EXCEPTIONS_MACOS } ); + expect( + await uncompressData(context.cache.get(getArtifactId(ARTIFACT_EXCEPTIONS_MACOS))!) + ).toStrictEqual(await uncompressArtifact(ARTIFACT_EXCEPTIONS_MACOS)); + expect(context.cache.get(getArtifactId(ARTIFACT_EXCEPTIONS_WINDOWS))).toBeUndefined(); + }); - // deleted old artifact - expect(savedObjectsClient.delete).toHaveBeenCalledWith( + test('Tolerates saved objects client conflict', async () => { + const context = buildManifestManagerContextMock({}); + const manifestManager = new ManifestManager(context); + + context.savedObjectsClient.create = jest + .fn() + .mockRejectedValue( + SavedObjectsErrorHelpers.createConflictError( + ArtifactConstants.SAVED_OBJECT_TYPE, + ARTIFACT_ID_EXCEPTIONS_MACOS + ) + ); + + await expect( + manifestManager.pushArtifacts([ARTIFACT_EXCEPTIONS_MACOS]) + ).resolves.toStrictEqual([]); + + expect(context.savedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(context.savedObjectsClient.create).toHaveBeenNthCalledWith( + 1, ArtifactConstants.SAVED_OBJECT_TYPE, - firstOldArtifactId + { ...ARTIFACT_EXCEPTIONS_MACOS, created: expect.anything() }, + { id: ARTIFACT_ID_EXCEPTIONS_MACOS } ); + expect(context.cache.get(getArtifactId(ARTIFACT_EXCEPTIONS_MACOS))).toBeUndefined(); }); + }); + + describe('commit', () => { + test('Creates new saved object if no saved object version', async () => { + const context = buildManifestManagerContextMock({}); + const manifestManager = new ManifestManager(context); + const manifest = Manifest.getDefault(); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS, TEST_POLICY_ID_1); + manifest.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_2); + manifest.addEntry(ARTIFACT_TRUSTED_APPS_MACOS, TEST_POLICY_ID_1); + manifest.addEntry(ARTIFACT_TRUSTED_APPS_MACOS, TEST_POLICY_ID_2); + + context.savedObjectsClient.create = jest + .fn() + .mockImplementation((type: string, object: InternalManifestSchema) => object); - test('ManifestManager handles promise rejections when building artifacts', async () => { - // This test won't fail on an unhandled promise rejection, but it will cause - // an UnhandledPromiseRejectionWarning to be printed. - const manifestManager = getManifestManagerMock({ - mockType: ManifestManagerMockType.ListClientPromiseRejection, + await expect(manifestManager.commit(manifest)).resolves.toBeUndefined(); + + expect(context.savedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(context.savedObjectsClient.create).toHaveBeenNthCalledWith( + 1, + ManifestConstants.SAVED_OBJECT_TYPE, + { + artifacts: [ + { artifactId: ARTIFACT_ID_EXCEPTIONS_MACOS, policyId: undefined }, + { artifactId: ARTIFACT_ID_EXCEPTIONS_MACOS, policyId: TEST_POLICY_ID_1 }, + { artifactId: ARTIFACT_ID_TRUSTED_APPS_MACOS, policyId: TEST_POLICY_ID_1 }, + { artifactId: ARTIFACT_ID_EXCEPTIONS_WINDOWS, policyId: TEST_POLICY_ID_2 }, + { artifactId: ARTIFACT_ID_TRUSTED_APPS_MACOS, policyId: TEST_POLICY_ID_2 }, + ], + schemaVersion: 'v1', + semanticVersion: '1.0.0', + created: expect.anything(), + }, + { id: 'endpoint-manifest-v1' } + ); + }); + + test('Updates existing saved object if has saved object version', async () => { + const context = buildManifestManagerContextMock({}); + const manifestManager = new ManifestManager(context); + const manifest = new Manifest({ soVersion: '1.0.0' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS, TEST_POLICY_ID_1); + manifest.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_2); + manifest.addEntry(ARTIFACT_TRUSTED_APPS_MACOS, TEST_POLICY_ID_1); + manifest.addEntry(ARTIFACT_TRUSTED_APPS_MACOS, TEST_POLICY_ID_2); + + context.savedObjectsClient.update = jest + .fn() + .mockImplementation((type: string, id: string, object: InternalManifestSchema) => object); + + await expect(manifestManager.commit(manifest)).resolves.toBeUndefined(); + + expect(context.savedObjectsClient.update).toHaveBeenCalledTimes(1); + expect(context.savedObjectsClient.update).toHaveBeenNthCalledWith( + 1, + ManifestConstants.SAVED_OBJECT_TYPE, + 'endpoint-manifest-v1', + { + artifacts: [ + { artifactId: ARTIFACT_ID_EXCEPTIONS_MACOS, policyId: undefined }, + { artifactId: ARTIFACT_ID_EXCEPTIONS_MACOS, policyId: TEST_POLICY_ID_1 }, + { artifactId: ARTIFACT_ID_TRUSTED_APPS_MACOS, policyId: TEST_POLICY_ID_1 }, + { artifactId: ARTIFACT_ID_EXCEPTIONS_WINDOWS, policyId: TEST_POLICY_ID_2 }, + { artifactId: ARTIFACT_ID_TRUSTED_APPS_MACOS, policyId: TEST_POLICY_ID_2 }, + ], + schemaVersion: 'v1', + semanticVersion: '1.0.0', + }, + { version: '1.0.0' } + ); + }); + + test('Throws error when saved objects client fails', async () => { + const context = buildManifestManagerContextMock({}); + const manifestManager = new ManifestManager(context); + const manifest = new Manifest({ soVersion: '1.0.0' }); + const error = new Error(); + + context.savedObjectsClient.update = jest.fn().mockRejectedValue(error); + + await expect(manifestManager.commit(manifest)).rejects.toBe(error); + + expect(context.savedObjectsClient.update).toHaveBeenCalledTimes(1); + expect(context.savedObjectsClient.update).toHaveBeenNthCalledWith( + 1, + ManifestConstants.SAVED_OBJECT_TYPE, + 'endpoint-manifest-v1', + { + artifacts: [], + schemaVersion: 'v1', + semanticVersion: '1.0.0', + }, + { version: '1.0.0' } + ); + }); + }); + + describe('tryDispatch', () => { + const mockPolicyListResponse = (items: PackagePolicy[]) => + jest.fn().mockResolvedValue({ + items, + page: 1, + per_page: 100, + total: items.length, }); - await expect(manifestManager.buildNewManifest()).rejects.toThrow(); + + const toNewPackagePolicy = (packagePolicy: PackagePolicy) => { + const { id, revision, updated_at: updatedAt, updated_by: updatedBy, ...rest } = packagePolicy; + + return rest; + }; + + test('Should not dispatch if no policies', async () => { + const context = buildManifestManagerContextMock({}); + const manifestManager = new ManifestManager(context); + const manifest = new Manifest({ soVersion: '1.0.0' }); + + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + + context.packagePolicyService.list = mockPolicyListResponse([]); + + await expect(manifestManager.tryDispatch(manifest)).resolves.toStrictEqual([]); + + expect(context.packagePolicyService.update).toHaveBeenCalledTimes(0); + }); + + test('Should return errors if invalid config for package policy', async () => { + const context = buildManifestManagerContextMock({}); + const manifestManager = new ManifestManager(context); + + const manifest = new Manifest({ soVersion: '1.0.0' }); + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + + context.packagePolicyService.list = mockPolicyListResponse([ + createPackagePolicyWithConfigMock({ id: TEST_POLICY_ID_1 }), + ]); + + await expect(manifestManager.tryDispatch(manifest)).resolves.toStrictEqual([ + new Error(`Package Policy ${TEST_POLICY_ID_1} has no config.`), + ]); + + expect(context.packagePolicyService.update).toHaveBeenCalledTimes(0); + }); + + test('Should not dispatch if semantic version has not changed', async () => { + const context = buildManifestManagerContextMock({}); + const manifestManager = new ManifestManager(context); + + const manifest = new Manifest({ soVersion: '1.0.0' }); + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + + context.packagePolicyService.list = mockPolicyListResponse([ + createPackagePolicyWithConfigMock({ + id: TEST_POLICY_ID_1, + config: { + artifact_manifest: { + value: { + artifacts: toArtifactRecords({ + [ARTIFACT_NAME_EXCEPTIONS_WINDOWS]: ARTIFACT_EXCEPTIONS_MACOS, + }), + manifest_version: '1.0.0', + schema_version: 'v1', + }, + }, + }, + }), + ]); + + await expect(manifestManager.tryDispatch(manifest)).resolves.toStrictEqual([]); + + expect(context.packagePolicyService.update).toHaveBeenCalledTimes(0); + }); + + test('Should dispatch to only policies where list of artifacts changed', async () => { + const context = buildManifestManagerContextMock({}); + const manifestManager = new ManifestManager(context); + + const manifest = new Manifest({ soVersion: '1.0.0', semanticVersion: '1.0.1' }); + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + manifest.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_1); + manifest.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_2); + manifest.addEntry(ARTIFACT_TRUSTED_APPS_MACOS, TEST_POLICY_ID_2); + + context.packagePolicyService.list = mockPolicyListResponse([ + createPackagePolicyWithConfigMock({ + id: TEST_POLICY_ID_1, + config: { + artifact_manifest: { + value: { + artifacts: toArtifactRecords({ + [ARTIFACT_NAME_EXCEPTIONS_MACOS]: ARTIFACT_EXCEPTIONS_MACOS, + }), + manifest_version: '1.0.0', + schema_version: 'v1', + }, + }, + }, + }), + createPackagePolicyWithConfigMock({ + id: TEST_POLICY_ID_2, + config: { + artifact_manifest: { + value: { + artifacts: toArtifactRecords({ + [ARTIFACT_NAME_EXCEPTIONS_WINDOWS]: ARTIFACT_EXCEPTIONS_WINDOWS, + [ARTIFACT_NAME_TRUSTED_APPS_MACOS]: ARTIFACT_TRUSTED_APPS_MACOS, + }), + manifest_version: '1.0.0', + schema_version: 'v1', + }, + }, + }, + }), + ]); + context.packagePolicyService.update = jest.fn().mockResolvedValue({}); + + await expect(manifestManager.tryDispatch(manifest)).resolves.toStrictEqual([]); + + expect(context.packagePolicyService.update).toHaveBeenCalledTimes(1); + expect(context.packagePolicyService.update).toHaveBeenNthCalledWith( + 1, + expect.anything(), + undefined, + TEST_POLICY_ID_1, + toNewPackagePolicy( + createPackagePolicyWithConfigMock({ + id: TEST_POLICY_ID_1, + config: { + artifact_manifest: { + value: { + artifacts: toArtifactRecords({ + [ARTIFACT_NAME_EXCEPTIONS_WINDOWS]: ARTIFACT_EXCEPTIONS_WINDOWS, + }), + manifest_version: '1.0.1', + schema_version: 'v1', + }, + }, + }, + }) + ) + ); + }); + + test('Should dispatch to only policies where artifact content changed', async () => { + const context = buildManifestManagerContextMock({}); + const manifestManager = new ManifestManager(context); + + const manifest = new Manifest({ soVersion: '1.0.0', semanticVersion: '1.0.1' }); + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS, TEST_POLICY_ID_1); + manifest.addEntry(ARTIFACT_EXCEPTIONS_WINDOWS, TEST_POLICY_ID_2); + manifest.addEntry(ARTIFACT_TRUSTED_APPS_MACOS, TEST_POLICY_ID_2); + + context.packagePolicyService.list = mockPolicyListResponse([ + createPackagePolicyWithConfigMock({ + id: TEST_POLICY_ID_1, + config: { + artifact_manifest: { + value: { + artifacts: toArtifactRecords({ + [ARTIFACT_NAME_EXCEPTIONS_MACOS]: await getEmptyInternalArtifactMock( + 'macos', + 'v1', + { + compress: true, + } + ), + }), + manifest_version: '1.0.0', + schema_version: 'v1', + }, + }, + }, + }), + createPackagePolicyWithConfigMock({ + id: TEST_POLICY_ID_2, + config: { + artifact_manifest: { + value: { + artifacts: toArtifactRecords({ + [ARTIFACT_NAME_EXCEPTIONS_WINDOWS]: ARTIFACT_EXCEPTIONS_WINDOWS, + [ARTIFACT_NAME_TRUSTED_APPS_MACOS]: ARTIFACT_TRUSTED_APPS_MACOS, + }), + manifest_version: '1.0.0', + schema_version: 'v1', + }, + }, + }, + }), + ]); + context.packagePolicyService.update = jest.fn().mockResolvedValue({}); + + await expect(manifestManager.tryDispatch(manifest)).resolves.toStrictEqual([]); + + expect(context.packagePolicyService.update).toHaveBeenCalledTimes(1); + expect(context.packagePolicyService.update).toHaveBeenNthCalledWith( + 1, + expect.anything(), + undefined, + TEST_POLICY_ID_1, + toNewPackagePolicy( + createPackagePolicyWithConfigMock({ + id: TEST_POLICY_ID_1, + config: { + artifact_manifest: { + value: { + artifacts: toArtifactRecords({ + [ARTIFACT_NAME_EXCEPTIONS_MACOS]: ARTIFACT_EXCEPTIONS_MACOS, + }), + manifest_version: '1.0.1', + schema_version: 'v1', + }, + }, + }, + }) + ) + ); + }); + + test('Should return partial errors', async () => { + const context = buildManifestManagerContextMock({}); + const manifestManager = new ManifestManager(context); + const error = new Error(); + + const manifest = new Manifest({ soVersion: '1.0.0', semanticVersion: '1.0.1' }); + manifest.addEntry(ARTIFACT_EXCEPTIONS_MACOS); + + context.packagePolicyService.list = mockPolicyListResponse([ + createPackagePolicyWithConfigMock({ + id: TEST_POLICY_ID_1, + config: { + artifact_manifest: { + value: { + artifacts: {}, + manifest_version: '1.0.0', + schema_version: 'v1', + }, + }, + }, + }), + createPackagePolicyWithConfigMock({ + id: TEST_POLICY_ID_2, + config: { + artifact_manifest: { + value: { + artifacts: {}, + manifest_version: '1.0.0', + schema_version: 'v1', + }, + }, + }, + }), + ]); + context.packagePolicyService.update = jest.fn().mockImplementation(async (...args) => { + if (args[2] === TEST_POLICY_ID_2) { + throw error; + } else { + return {}; + } + }); + + await expect(manifestManager.tryDispatch(manifest)).resolves.toStrictEqual([error]); + + expect(context.packagePolicyService.update).toHaveBeenCalledTimes(2); }); }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts index 8e4d2d9349bb..6b9cbb55415a 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts @@ -6,20 +6,25 @@ */ import semver from 'semver'; -import { Logger, SavedObjectsClientContract } from 'src/core/server'; import LRU from 'lru-cache'; +import { isEqual } from 'lodash'; +import { Logger, SavedObjectsClientContract } from 'src/core/server'; import { PackagePolicyServiceInterface } from '../../../../../../fleet/server'; import { ExceptionListClient } from '../../../../../../lists/server'; import { ManifestSchemaVersion } from '../../../../../common/endpoint/schema/common'; -import { manifestDispatchSchema } from '../../../../../common/endpoint/schema/manifest'; +import { + manifestDispatchSchema, + ManifestSchema, +} from '../../../../../common/endpoint/schema/manifest'; import { ArtifactConstants, buildArtifact, getArtifactId, getFullEndpointExceptionList, + isCompressed, Manifest, - ManifestDiff, + maybeCompressArtifact, } from '../../../lib/artifacts'; import { InternalArtifactCompleteSchema, @@ -29,6 +34,7 @@ import { ArtifactClient } from '../artifact_client'; import { ManifestClient } from '../manifest_client'; import { ENDPOINT_LIST_ID } from '../../../../../../lists/common'; import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../../../../../lists/common/constants'; +import { PackagePolicy } from '../../../../../../fleet/common/types/models'; export interface ManifestManagerContext { savedObjectsClient: SavedObjectsClientContract; @@ -39,14 +45,13 @@ export interface ManifestManagerContext { cache: LRU; } -export interface ManifestSnapshotOpts { - initialize?: boolean; -} +const getArtifactIds = (manifest: ManifestSchema) => + [...Object.keys(manifest.artifacts)].map( + (key) => `${key}-${manifest.artifacts[key].decoded_sha256}` + ); -export interface ManifestSnapshot { - manifest: Manifest; - diffs: ManifestDiff[]; -} +const manifestsEqual = (manifest1: ManifestSchema, manifest2: ManifestSchema) => + isEqual(new Set(getArtifactIds(manifest1)), new Set(getArtifactIds(manifest2))); export class ManifestManager { protected artifactClient: ArtifactClient; @@ -209,8 +214,7 @@ export class ManifestManager { */ public async getLastComputedManifest(): Promise { try { - const manifestClient = this.getManifestClient(); - const manifestSo = await manifestClient.getManifest(); + const manifestSo = await this.getManifestClient().getManifest(); if (manifestSo.version === undefined) { throw new Error('No version returned for manifest.'); @@ -222,14 +226,17 @@ export class ManifestManager { soVersion: manifestSo.version, }); - for (const id of manifestSo.attributes.ids) { - const artifactSo = await this.artifactClient.getArtifact(id); - manifest.addEntry(artifactSo.attributes); + for (const entry of manifestSo.attributes.artifacts) { + manifest.addEntry( + (await this.artifactClient.getArtifact(entry.artifactId)).attributes, + entry.policyId + ); } + return manifest; - } catch (err) { - if (err.output.statusCode !== 404) { - throw err; + } catch (error) { + if (!error.output || error.output.statusCode !== 404) { + throw error; } return null; } @@ -241,17 +248,36 @@ export class ManifestManager { * @param baselineManifest A baseline manifest to use for initializing pre-existing artifacts. * @returns {Promise} A new Manifest object reprenting the current exception list. */ - public async buildNewManifest(baselineManifest?: Manifest): Promise { + public async buildNewManifest( + baselineManifest: Manifest = Manifest.getDefault(this.schemaVersion) + ): Promise { // Build new exception list artifacts const artifacts = ( await Promise.all([this.buildExceptionListArtifacts(), this.buildTrustedAppsArtifacts()]) ).flat(); // Build new manifest - const manifest = Manifest.fromArtifacts( - artifacts, - baselineManifest ?? Manifest.getDefault(this.schemaVersion) - ); + const manifest = new Manifest({ + schemaVersion: this.schemaVersion, + semanticVersion: baselineManifest.getSemanticVersion(), + soVersion: baselineManifest.getSavedObjectVersion(), + }); + + for (const artifact of artifacts) { + let artifactToAdd = baselineManifest.getArtifact(getArtifactId(artifact)) || artifact; + + if (!isCompressed(artifactToAdd)) { + artifactToAdd = await maybeCompressArtifact(artifactToAdd); + + if (!isCompressed(artifactToAdd)) { + throw new Error(`Unable to compress artifact: ${getArtifactId(artifactToAdd)}`); + } else if (!internalArtifactCompleteSchema.is(artifactToAdd)) { + throw new Error(`Incomplete artifact detected: ${getArtifactId(artifactToAdd)}`); + } + } + + manifest.addEntry(artifactToAdd); + } return manifest; } @@ -264,35 +290,24 @@ export class ManifestManager { * @returns {Promise} Any errors encountered. */ public async tryDispatch(manifest: Manifest): Promise { - const serializedManifest = manifest.toEndpointFormat(); - if (!manifestDispatchSchema.is(serializedManifest)) { - return [new Error('Invalid manifest')]; - } - - let paging = true; - let page = 1; const errors: Error[] = []; - while (paging) { - const { items, total } = await this.packagePolicyService.list(this.savedObjectsClient, { - page, - perPage: 20, - kuery: 'ingest-package-policies.package.name:endpoint', - }); + await this.forEachPolicy(async (packagePolicy) => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { id, revision, updated_at, updated_by, ...newPackagePolicy } = packagePolicy; + if (newPackagePolicy.inputs.length > 0 && newPackagePolicy.inputs[0].config !== undefined) { + const oldManifest = newPackagePolicy.inputs[0].config.artifact_manifest ?? { + value: {}, + }; - for (const packagePolicy of items) { - // eslint-disable-next-line @typescript-eslint/naming-convention - const { id, revision, updated_at, updated_by, ...newPackagePolicy } = packagePolicy; - if (newPackagePolicy.inputs.length > 0 && newPackagePolicy.inputs[0].config !== undefined) { - const oldManifest = newPackagePolicy.inputs[0].config.artifact_manifest ?? { - value: {}, - }; - - const newManifestVersion = manifest.getSemanticVersion(); - if (semver.gt(newManifestVersion, oldManifest.value.manifest_version)) { - newPackagePolicy.inputs[0].config.artifact_manifest = { - value: serializedManifest, - }; + const newManifestVersion = manifest.getSemanticVersion(); + if (semver.gt(newManifestVersion, oldManifest.value.manifest_version)) { + const serializedManifest = manifest.toPackagePolicyManifest(packagePolicy.id); + + if (!manifestDispatchSchema.is(serializedManifest)) { + errors.push(new Error(`Invalid manifest for policy ${packagePolicy.id}`)); + } else if (!manifestsEqual(serializedManifest, oldManifest.value)) { + newPackagePolicy.inputs[0].config.artifact_manifest = { value: serializedManifest }; try { await this.packagePolicyService.update( @@ -309,15 +324,17 @@ export class ManifestManager { errors.push(err); } } else { - this.logger.debug(`No change in package policy: ${id}`); + this.logger.debug( + `No change in manifest content for package policy: ${id}. Staying on old version` + ); } } else { - errors.push(new Error(`Package Policy ${id} has no config.`)); + this.logger.debug(`No change in manifest version for package policy: ${id}`); } + } else { + errors.push(new Error(`Package Policy ${id} has no config.`)); } - paging = (page - 1) * 20 + items.length < total; - page++; - } + }); return errors; } @@ -328,27 +345,41 @@ export class ManifestManager { * @param manifest The Manifest to commit. * @returns {Promise} An error, if encountered, or null. */ - public async commit(manifest: Manifest): Promise { - try { - const manifestClient = this.getManifestClient(); + public async commit(manifest: Manifest) { + const manifestClient = this.getManifestClient(); + + // Commit the new manifest + const manifestSo = manifest.toSavedObject(); + const version = manifest.getSavedObjectVersion(); + + if (version == null) { + await manifestClient.createManifest(manifestSo); + } else { + await manifestClient.updateManifest(manifestSo, { + version, + }); + } - // Commit the new manifest - const manifestSo = manifest.toSavedObject(); - const version = manifest.getSavedObjectVersion(); + this.logger.info(`Committed manifest ${manifest.getSemanticVersion()}`); + } - if (version == null) { - await manifestClient.createManifest(manifestSo); - } else { - await manifestClient.updateManifest(manifestSo, { - version, - }); + private async forEachPolicy(callback: (policy: PackagePolicy) => Promise) { + let paging = true; + let page = 1; + + while (paging) { + const { items, total } = await this.packagePolicyService.list(this.savedObjectsClient, { + page, + perPage: 20, + kuery: 'ingest-package-policies.package.name:endpoint', + }); + + for (const packagePolicy of items) { + await callback(packagePolicy); } - this.logger.info(`Committed manifest ${manifest.getSemanticVersion()}`); - } catch (err) { - return err; + paging = (page - 1) * 20 + items.length < total; + page++; } - - return null; } } diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts index 8af0df306302..56e2f9c7c730 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts @@ -47,6 +47,8 @@ describe('TelemetryEventsSender', () => { malware_classification: { key1: 'X', }, + quarantine_result: true, + quarantine_message: 'this file is bad', something_else: 'nope', }, }, @@ -79,6 +81,8 @@ describe('TelemetryEventsSender', () => { malware_classification: { key1: 'X', }, + quarantine_result: true, + quarantine_message: 'this file is bad', }, }, host: { diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts b/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts index 4e32410cdb6a..a18604fb92a4 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts @@ -320,6 +320,8 @@ const allowlistEventFields: AllowlistFields = { Ext: { code_signature: true, malware_classification: true, + quarantine_result: true, + quarantine_message: true, }, }, host: { diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 8ed33d660df3..c94d5cf494b4 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -62,7 +62,7 @@ import { registerPolicyRoutes } from './endpoint/routes/policy'; import { ArtifactClient, ManifestManager } from './endpoint/services'; import { EndpointAppContextService } from './endpoint/endpoint_app_context_services'; import { EndpointAppContext } from './endpoint/types'; -import { registerDownloadExceptionListRoute } from './endpoint/routes/artifacts'; +import { registerDownloadArtifactRoute } from './endpoint/routes/artifacts'; import { initUsageCollectors } from './usage'; import type { SecuritySolutionRequestHandlerContext } from './types'; import { registerTrustedAppsRoutes } from './endpoint/routes/trusted_apps'; @@ -138,7 +138,7 @@ export class Plugin implements IPlugin; + private artifactsCache: LRU; constructor(context: PluginInitializerContext) { this.context = context; @@ -146,7 +146,7 @@ export class Plugin implements IPlugin({ max: 3, maxAge: 1000 * 60 * 5 }); + this.artifactsCache = new LRU({ max: 3, maxAge: 1000 * 60 * 5 }); this.telemetryEventsSender = new TelemetryEventsSender(this.logger); this.logger.debug('plugin initialized'); @@ -200,7 +200,7 @@ export class Plugin implements IPlugin { performance.mark('markTaskAsRunning_start'); - const apmTrans = apm.startTransaction( - `taskManager markTaskAsRunning ${this.instance.taskType}`, - 'taskManager' - ); + const apmTrans = apm.startTransaction(`taskManager markTaskAsRunning`, 'taskManager'); + + apmTrans?.addLabels({ + taskType: this.taskType, + }); const now = new Date(); try { diff --git a/x-pack/tasks/build.ts b/x-pack/tasks/build.ts index 2cad1de90952..4b6bc2928474 100644 --- a/x-pack/tasks/build.ts +++ b/x-pack/tasks/build.ts @@ -77,6 +77,7 @@ async function copySourceAndBabelify() { '**/public/**/*.{js,ts,tsx,json}', '**/{__tests__,__mocks__,__snapshots__}/**', 'plugins/canvas/shareable_runtime/test/**', + 'plugins/telemetry_collection_xpack/schema/**', // Skip telemetry schemas ], allowEmpty: true, } diff --git a/x-pack/test/apm_api_integration/common/fixtures/es_archiver/apm_8.0.0/mappings.json b/x-pack/test/apm_api_integration/common/fixtures/es_archiver/apm_8.0.0/mappings.json index 13bfec74269b..9f84f4885c4c 100644 --- a/x-pack/test/apm_api_integration/common/fixtures/es_archiver/apm_8.0.0/mappings.json +++ b/x-pack/test/apm_api_integration/common/fixtures/es_archiver/apm_8.0.0/mappings.json @@ -31,7 +31,7 @@ "core-usage-stats": "3d1b76c39bfb2cc8296b024d73854724", "dashboard": "40554caf09725935e2c02e02563a2d07", "endpoint:user-artifact": "4a11183eee21e6fbad864f7a30b39ad0", - "endpoint:user-artifact-manifest": "4b9c0e7cfaf86d82a7ee9ed68065e50d", + "endpoint:user-artifact-manifest": "a0d7b04ad405eed54d76e279c3727862", "enterprise_search_telemetry": "3d1b76c39bfb2cc8296b024d73854724", "epm-packages": "2b83397e3eaaaa8ef15e38813f3721c3", "exception-list": "67f055ab8c10abd7b2ebfd969b836788", @@ -818,16 +818,25 @@ "index": false, "type": "date" }, - "ids": { - "index": false, - "type": "keyword" - }, "schemaVersion": { "type": "keyword" }, "semanticVersion": { "index": false, "type": "keyword" + }, + "artifacts": { + "type": "nested", + "properties": { + "policyId": { + "type": "keyword", + "index": false + }, + "artifactId": { + "type": "keyword", + "index": false + } + } } } }, @@ -22352,4 +22361,4 @@ } } } -} \ No newline at end of file +} diff --git a/x-pack/test/encrypted_saved_objects_api_integration/fixtures/es_archiver/key_rotation/mappings.json b/x-pack/test/encrypted_saved_objects_api_integration/fixtures/es_archiver/key_rotation/mappings.json index e24ab1c7b63f..26364c89dba3 100644 --- a/x-pack/test/encrypted_saved_objects_api_integration/fixtures/es_archiver/key_rotation/mappings.json +++ b/x-pack/test/encrypted_saved_objects_api_integration/fixtures/es_archiver/key_rotation/mappings.json @@ -74,7 +74,7 @@ "url": "c7f66a0df8b1b52f17c28c4adb111105", "enterprise_search_telemetry": "3d1b76c39bfb2cc8296b024d73854724", "hidden-saved-object-with-secret": "bdf31541e7d2f348b5e21a7769c022ba", - "endpoint:user-artifact-manifest": "4b9c0e7cfaf86d82a7ee9ed68065e50d", + "endpoint:user-artifact-manifest": "a0d7b04ad405eed54d76e279c3727862", "migrationVersion": "4a1746014a75ade3a714e1db5763276f", "index-pattern": "45915a1ad866812242df474eb0479052", "fleet-agents": "cb661e8ede2b640c42c8e5ef99db0683", @@ -700,16 +700,25 @@ "type": "date", "index": false }, - "ids": { - "type": "keyword", - "index": false - }, "schemaVersion": { "type": "keyword" }, "semanticVersion": { "type": "keyword", "index": false + }, + "artifacts": { + "type": "nested", + "properties": { + "policyId": { + "type": "keyword", + "index": false + }, + "artifactId": { + "type": "keyword", + "index": false + } + } } } }, diff --git a/x-pack/test/fleet_api_integration/apis/index.js b/x-pack/test/fleet_api_integration/apis/index.js index 8c66db9c418e..44431795a34b 100644 --- a/x-pack/test/fleet_api_integration/apis/index.js +++ b/x-pack/test/fleet_api_integration/apis/index.js @@ -37,6 +37,7 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./package_policy/create')); loadTestFile(require.resolve('./package_policy/update')); loadTestFile(require.resolve('./package_policy/get')); + loadTestFile(require.resolve('./package_policy/delete')); // Agent policies loadTestFile(require.resolve('./agent_policy/index')); diff --git a/x-pack/test/fleet_api_integration/apis/package_policy/create.ts b/x-pack/test/fleet_api_integration/apis/package_policy/create.ts index 8e339bc78b08..c9c871e280f1 100644 --- a/x-pack/test/fleet_api_integration/apis/package_policy/create.ts +++ b/x-pack/test/fleet_api_integration/apis/package_policy/create.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; import { warnAndSkipTest } from '../../helpers'; @@ -39,6 +39,52 @@ export default function ({ getService }: FtrProviderContext) { .send({ agentPolicyId }); }); + it('should fail for managed agent policies', async function () { + if (server.enabled) { + // get a managed policy + const { + body: { item: managedPolicy }, + } = await supertest + .post(`/api/fleet/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Managed policy from ${Date.now()}`, + namespace: 'default', + is_managed: true, + }); + + // try to add an integration to the managed policy + const { body } = await supertest + .post(`/api/fleet/package_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'filetest-1', + description: '', + namespace: 'default', + policy_id: managedPolicy.id, + enabled: true, + output_id: '', + inputs: [], + package: { + name: 'filetest', + title: 'For File Tests', + version: '0.1.0', + }, + }) + .expect(400); + + expect(body.statusCode).to.be(400); + expect(body.message).to.contain('Cannot add integrations to managed policy'); + + // delete policy we just made + await supertest.post(`/api/fleet/agent_policies/delete`).set('kbn-xsrf', 'xxxx').send({ + agentPolicyId: managedPolicy.id, + }); + } else { + warnAndSkipTest(this, log); + } + }); + it('should work with valid values', async function () { if (server.enabled) { await supertest diff --git a/x-pack/test/fleet_api_integration/apis/package_policy/delete.ts b/x-pack/test/fleet_api_integration/apis/package_policy/delete.ts new file mode 100644 index 000000000000..e64ba8580d14 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/package_policy/delete.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; +import { skipIfNoDockerRegistry } from '../../helpers'; + +export default function (providerContext: FtrProviderContext) { + const { getService } = providerContext; + const supertest = getService('supertest'); + + // use function () {} and not () => {} here + // because `this` has to point to the Mocha context + // see https://mochajs.org/#arrow-functions + + describe('Package Policy - delete', async function () { + skipIfNoDockerRegistry(providerContext); + let agentPolicy: any; + let packagePolicy: any; + + before(async function () { + let agentPolicyResponse = await supertest + .post(`/api/fleet/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'Test policy', + namespace: 'default', + is_managed: false, + }); + + // if one already exists, re-use that + if (agentPolicyResponse.body.statusCode === 409) { + const errorRegex = /^agent policy \'(?[\w,\-]+)\' already exists/i; + const result = errorRegex.exec(agentPolicyResponse.body.message); + if (result?.groups?.id) { + agentPolicyResponse = await supertest + .put(`/api/fleet/agent_policies/${result.groups.id}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'Test policy', + namespace: 'default', + is_managed: false, + }); + } + } + agentPolicy = agentPolicyResponse.body.item; + + const { body: packagePolicyResponse } = await supertest + .post(`/api/fleet/package_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'filetest-1', + description: '', + namespace: 'default', + policy_id: agentPolicy.id, + enabled: true, + output_id: '', + inputs: [], + package: { + name: 'filetest', + title: 'For File Tests', + version: '0.1.0', + }, + }); + packagePolicy = packagePolicyResponse.item; + }); + + after(async function () { + await supertest + .post(`/api/fleet/agent_policies/delete`) + .set('kbn-xsrf', 'xxxx') + .send({ agentPolicyId: agentPolicy.id }); + + await supertest + .post(`/api/fleet/package_policies/delete`) + .set('kbn-xsrf', 'xxxx') + .send({ packagePolicyIds: [packagePolicy.id] }); + }); + + it('should fail on managed agent policies', async function () { + // update existing policy to managed + await supertest + .put(`/api/fleet/agent_policies/${agentPolicy.id}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: agentPolicy.name, + namespace: agentPolicy.namespace, + is_managed: true, + }) + .expect(200); + + // try to delete + const { body: results } = await supertest + .post(`/api/fleet/package_policies/delete`) + .set('kbn-xsrf', 'xxxx') + .send({ packagePolicyIds: [packagePolicy.id] }) + .expect(200); + + // delete always succeeds (returns 200) with Array<{success: boolean}> + expect(Array.isArray(results)); + expect(results.length).to.be(1); + expect(results[0].success).to.be(false); + expect(results[0].body.message).to.contain('Cannot remove integrations of managed policy'); + + // revert existing policy to unmanaged + await supertest + .put(`/api/fleet/agent_policies/${agentPolicy.id}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: agentPolicy.name, + namespace: agentPolicy.namespace, + is_managed: false, + }) + .expect(200); + }); + + it('should work for unmanaged policies', async function () { + await supertest + .post(`/api/fleet/package_policies/delete`) + .set('kbn-xsrf', 'xxxx') + .send({ packagePolicyIds: [packagePolicy.id] }); + }); + }); +} diff --git a/x-pack/test/fleet_api_integration/apis/package_policy/update.ts b/x-pack/test/fleet_api_integration/apis/package_policy/update.ts index e0dc1a5d96b4..9a70c6ad004d 100644 --- a/x-pack/test/fleet_api_integration/apis/package_policy/update.ts +++ b/x-pack/test/fleet_api_integration/apis/package_policy/update.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; import { skipIfNoDockerRegistry } from '../../helpers'; @@ -21,6 +21,7 @@ export default function (providerContext: FtrProviderContext) { describe('Package Policy - update', async function () { skipIfNoDockerRegistry(providerContext); let agentPolicyId: string; + let managedAgentPolicyId: string; let packagePolicyId: string; let packagePolicyId2: string; @@ -35,8 +36,30 @@ export default function (providerContext: FtrProviderContext) { name: 'Test policy', namespace: 'default', }); + agentPolicyId = agentPolicyResponse.item.id; + const { body: managedAgentPolicyResponse } = await supertest + .post(`/api/fleet/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'Test managed policy', + namespace: 'default', + is_managed: true, + }); + + // if one already exists, re-use that + const managedExists = managedAgentPolicyResponse.statusCode === 409; + if (managedExists) { + const errorRegex = /^agent policy \'(?[\w,\-]+)\' already exists/i; + const result = errorRegex.exec(managedAgentPolicyResponse.message); + if (result?.groups?.id) { + managedAgentPolicyId = result.groups.id; + } + } else { + managedAgentPolicyId = managedAgentPolicyResponse.item.id; + } + const { body: packagePolicyResponse } = await supertest .post(`/api/fleet/package_policies`) .set('kbn-xsrf', 'xxxx') @@ -83,6 +106,29 @@ export default function (providerContext: FtrProviderContext) { .send({ agentPolicyId }); }); + it('should fail on managed agent policies', async function () { + const { body } = await supertest + .put(`/api/fleet/package_policies/${packagePolicyId}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'filetest-1', + description: '', + namespace: 'updated_namespace', + policy_id: managedAgentPolicyId, + enabled: true, + output_id: '', + inputs: [], + package: { + name: 'filetest', + title: 'For File Tests', + version: '0.1.0', + }, + }) + .expect(400); + + expect(body.message).to.contain('Cannot update integrations of managed policy'); + }); + it('should work with valid values', async function () { await supertest .put(`/api/fleet/package_policies/${packagePolicyId}`) diff --git a/x-pack/test/functional/apps/dashboard/sync_colors.ts b/x-pack/test/functional/apps/dashboard/sync_colors.ts index 5dd390dd7976..7e54f966870c 100644 --- a/x-pack/test/functional/apps/dashboard/sync_colors.ts +++ b/x-pack/test/functional/apps/dashboard/sync_colors.ts @@ -65,7 +65,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { field: 'geo.src', }); - await PageObjects.lens.save('vis1', true, true); + await PageObjects.lens.save('vis1', false, true); await PageObjects.header.waitUntilLoadingHasFinished(); await dashboardAddPanel.clickCreateNewLink(); await dashboardAddPanel.clickVisType('lens'); @@ -85,7 +85,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await filterBar.addFilter('geo.src', 'is not', 'CN'); - await PageObjects.lens.save('vis2', true, true); + await PageObjects.lens.save('vis2', false, true); await PageObjects.header.waitUntilLoadingHasFinished(); const colorMapping1 = getColorMapping(await PageObjects.dashboard.getPanelChartDebugState(0)); const colorMapping2 = getColorMapping(await PageObjects.dashboard.getPanelChartDebugState(1)); diff --git a/x-pack/test/functional/apps/lens/add_to_dashboard.ts b/x-pack/test/functional/apps/lens/add_to_dashboard.ts new file mode 100644 index 000000000000..ad393e620746 --- /dev/null +++ b/x-pack/test/functional/apps/lens/add_to_dashboard.ts @@ -0,0 +1,243 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects([ + 'dashboard', + 'visualize', + 'lens', + 'timeToVisualize', + 'common', + 'header', + ]); + const find = getService('find'); + const listingTable = getService('listingTable'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const testSubjects = getService('testSubjects'); + const security = getService('security'); + + describe('lens add-to-dashboards tests', () => { + it('should allow new lens vizs be added to a new dashboard', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'avg', + field: 'bytes', + }); + + await PageObjects.lens.switchToVisualization('lnsMetric'); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.lens.assertMetric('Average of bytes', '5,727.322'); + + await PageObjects.lens.save('New Lens from Modal', false, false, 'new'); + + await PageObjects.dashboard.waitForRenderComplete(); + + await PageObjects.lens.assertMetric('Average of bytes', '5,727.322'); + + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(1); + + await PageObjects.timeToVisualize.resetNewDashboard(); + }); + + it('should allow existing lens vizs be added to a new dashboard', async () => { + await PageObjects.visualize.gotoVisualizationLandingPage(); + await listingTable.searchForItemWithName('Artistpreviouslyknownaslens'); + await PageObjects.lens.clickVisualizeListItemTitle('Artistpreviouslyknownaslens'); + await PageObjects.lens.goToTimeRange(); + await PageObjects.lens.assertMetric('Maximum of bytes', '19,986'); + + await PageObjects.lens.save('Artistpreviouslyknownaslens Copy', true, false, 'new'); + + await PageObjects.dashboard.waitForRenderComplete(); + + await PageObjects.lens.assertMetric('Maximum of bytes', '19,986'); + + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(1); + + await PageObjects.timeToVisualize.resetNewDashboard(); + }); + + it('should allow new lens vizs be added to an existing dashboard', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + await dashboardAddPanel.clickOpenAddPanel(); + await dashboardAddPanel.filterEmbeddableNames('lnsXYvis'); + await find.clickByButtonText('lnsXYvis'); + await dashboardAddPanel.closeAddPanel(); + await PageObjects.lens.goToTimeRange(); + + await PageObjects.dashboard.saveDashboard('My Very Cool Dashboard'); + await PageObjects.dashboard.gotoDashboardLandingPage(); + await listingTable.searchAndExpectItemsCount('dashboard', 'My Very Cool Dashboard', 1); + + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'avg', + field: 'bytes', + }); + + await PageObjects.lens.switchToVisualization('lnsMetric'); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.lens.assertMetric('Average of bytes', '5,727.322'); + + await PageObjects.lens.save( + 'New Lens from Modal', + false, + false, + 'existing', + 'My Very Cool Dashboard' + ); + + await PageObjects.dashboard.waitForRenderComplete(); + + await PageObjects.lens.assertMetric('Average of bytes', '5,727.322'); + + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(2); + }); + + it('should allow existing lens vizs be added to an existing dashboard', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + await dashboardAddPanel.clickOpenAddPanel(); + await dashboardAddPanel.filterEmbeddableNames('lnsXYvis'); + await find.clickByButtonText('lnsXYvis'); + await dashboardAddPanel.closeAddPanel(); + await PageObjects.lens.goToTimeRange(); + + await PageObjects.dashboard.saveDashboard('My Wonderful Dashboard'); + await PageObjects.dashboard.gotoDashboardLandingPage(); + await listingTable.searchAndExpectItemsCount('dashboard', 'My Wonderful Dashboard', 1); + + await PageObjects.visualize.gotoVisualizationLandingPage(); + await listingTable.searchForItemWithName('Artistpreviouslyknownaslens'); + await PageObjects.lens.clickVisualizeListItemTitle('Artistpreviouslyknownaslens'); + await PageObjects.lens.goToTimeRange(); + await PageObjects.lens.assertMetric('Maximum of bytes', '19,986'); + + await PageObjects.lens.save( + 'Artistpreviouslyknownaslens Copy', + true, + false, + 'existing', + 'My Wonderful Dashboard' + ); + + await PageObjects.dashboard.waitForRenderComplete(); + + await PageObjects.lens.assertMetric('Maximum of bytes', '19,986'); + + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(2); + }); + + describe('Capabilities', function capabilitiesTests() { + describe('dashboard no-access privileges', () => { + before(async () => { + await PageObjects.common.navigateToApp('visualize'); + await security.testUser.setRoles(['test_logstash_reader', 'global_visualize_all'], true); + }); + + after(async () => { + await security.testUser.restoreDefaults(); + }); + + it('should not display dashboard flow prompt', async () => { + await PageObjects.common.navigateToApp('visualize'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.visualize.gotoLandingPage(); + + const hasPrompt = await testSubjects.exists('visualize-dashboard-flow-prompt'); + expect(hasPrompt).to.eql(false); + }); + + it('should not display add-to-dashboard options', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'avg', + field: 'bytes', + }); + + await PageObjects.lens.switchToVisualization('lnsMetric'); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.lens.assertMetric('Average of bytes', '5,727.322'); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await testSubjects.click('lnsApp_saveButton'); + + const hasOptions = await testSubjects.exists('add-to-dashboard-options'); + expect(hasOptions).to.eql(false); + }); + }); + + describe('dashboard read-only privileges', () => { + before(async () => { + await security.testUser.setRoles( + ['test_logstash_reader', 'global_visualize_all', 'global_dashboard_read'], + true + ); + }); + + after(async () => { + await security.testUser.restoreDefaults(); + }); + + it('should not display dashboard flow prompt', async () => { + await PageObjects.common.navigateToApp('visualize'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.visualize.gotoLandingPage(); + + const hasPrompt = await testSubjects.exists('visualize-dashboard-flow-prompt'); + expect(hasPrompt).to.eql(false); + }); + + it('should not display add-to-dashboard options', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'avg', + field: 'bytes', + }); + + await PageObjects.lens.switchToVisualization('lnsMetric'); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.lens.assertMetric('Average of bytes', '5,727.322'); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await testSubjects.click('lnsApp_saveButton'); + + const hasOptions = await testSubjects.exists('add-to-dashboard-options'); + expect(hasOptions).to.eql(false); + }); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/lens/index.ts b/x-pack/test/functional/apps/lens/index.ts index 10b1f4d30145..31b7b665fb2f 100644 --- a/x-pack/test/functional/apps/lens/index.ts +++ b/x-pack/test/functional/apps/lens/index.ts @@ -29,6 +29,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { this.tags(['ciGroup4', 'skipFirefox']); loadTestFile(require.resolve('./smokescreen')); + loadTestFile(require.resolve('./add_to_dashboard')); loadTestFile(require.resolve('./table')); loadTestFile(require.resolve('./dashboard')); loadTestFile(require.resolve('./persistent_context')); diff --git a/x-pack/test/functional/apps/maps/embeddable/add_to_dashboard.js b/x-pack/test/functional/apps/maps/embeddable/add_to_dashboard.js new file mode 100644 index 000000000000..9bbf6b1afab6 --- /dev/null +++ b/x-pack/test/functional/apps/maps/embeddable/add_to_dashboard.js @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; + +export default function ({ getPageObjects, getService }) { + const PageObjects = getPageObjects([ + 'common', + 'dashboard', + 'header', + 'maps', + 'timeToVisualize', + 'visualize', + ]); + + const listingTable = getService('listingTable'); + const testSubjects = getService('testSubjects'); + const security = getService('security'); + + describe('maps add-to-dashboard save flow', () => { + before(async () => { + await security.testUser.setRoles( + [ + 'test_logstash_reader', + 'global_maps_all', + 'geoshape_data_reader', + 'global_dashboard_all', + 'meta_for_geoshape_data_reader', + ], + false + ); + }); + + after(async () => { + await security.testUser.restoreDefaults(); + }); + + it('should allow new map be added to a new dashboard', async () => { + await PageObjects.maps.openNewMap(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.maps.waitForLayersToLoad(); + + await testSubjects.click('mapSaveButton'); + await PageObjects.timeToVisualize.saveFromModal('map 1', { addToDashboard: 'new' }); + + await PageObjects.dashboard.waitForRenderComplete(); + + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(1); + + await PageObjects.timeToVisualize.resetNewDashboard(); + }); + + it('should allow existing maps be added to a new dashboard', async () => { + await PageObjects.maps.loadSavedMap('document example'); + + await testSubjects.click('mapSaveButton'); + await PageObjects.timeToVisualize.saveFromModal('document example copy', { + addToDashboard: 'new', + saveAsNew: true, + }); + + await PageObjects.dashboard.waitForRenderComplete(); + + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(1); + + await PageObjects.timeToVisualize.resetNewDashboard(); + }); + + it('should allow new map be added to an existing dashboard', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + + await PageObjects.dashboard.saveDashboard('My Very Cool Dashboard'); + await PageObjects.dashboard.gotoDashboardLandingPage(); + await listingTable.searchAndExpectItemsCount('dashboard', 'My Very Cool Dashboard', 1); + + await PageObjects.maps.openNewMap(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.maps.waitForLayersToLoad(); + + await testSubjects.click('mapSaveButton'); + await PageObjects.timeToVisualize.saveFromModal('My New Map 2', { + addToDashboard: 'existing', + dashboardId: 'My Very Cool Dashboard', + }); + + await PageObjects.dashboard.waitForRenderComplete(); + + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(1); + }); + + it('should allow existing maps be added to an existing dashboard', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + + await PageObjects.dashboard.saveDashboard('My Wonderful Dashboard'); + await PageObjects.dashboard.gotoDashboardLandingPage(); + await listingTable.searchAndExpectItemsCount('dashboard', 'My Wonderful Dashboard', 1); + + await PageObjects.maps.loadSavedMap('document example'); + + await testSubjects.click('mapSaveButton'); + await PageObjects.timeToVisualize.saveFromModal('document example copy 2', { + addToDashboard: 'existing', + dashboardId: 'My Wonderful Dashboard', + saveAsNew: true, + }); + + await PageObjects.dashboard.waitForRenderComplete(); + + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(1); + }); + }); +} diff --git a/x-pack/test/functional/apps/maps/embeddable/index.js b/x-pack/test/functional/apps/maps/embeddable/index.js index 9fd4c9db703d..552f830e2a37 100644 --- a/x-pack/test/functional/apps/maps/embeddable/index.js +++ b/x-pack/test/functional/apps/maps/embeddable/index.js @@ -7,6 +7,7 @@ export default function ({ loadTestFile }) { describe('embeddable', function () { + loadTestFile(require.resolve('./add_to_dashboard')); loadTestFile(require.resolve('./save_and_return')); loadTestFile(require.resolve('./dashboard')); loadTestFile(require.resolve('./embeddable_library')); diff --git a/x-pack/test/functional/es_archives/actions/mappings.json b/x-pack/test/functional/es_archives/actions/mappings.json index ee128369ddd2..7101af08400a 100644 --- a/x-pack/test/functional/es_archives/actions/mappings.json +++ b/x-pack/test/functional/es_archives/actions/mappings.json @@ -28,7 +28,7 @@ "config": "c63748b75f39d0c54de12d12c1ccbc20", "dashboard": "d00f614b29a80360e1190193fd333bab", "endpoint:user-artifact": "4a11183eee21e6fbad864f7a30b39ad0", - "endpoint:user-artifact-manifest": "4b9c0e7cfaf86d82a7ee9ed68065e50d", + "endpoint:user-artifact-manifest": "a0d7b04ad405eed54d76e279c3727862", "epm-packages": "8f6e0b09ea0374c4ffe98c3755373cff", "exception-list": "497afa2f881a675d72d58e20057f3d8b", "exception-list-agnostic": "497afa2f881a675d72d58e20057f3d8b", @@ -666,16 +666,25 @@ "index": false, "type": "date" }, - "ids": { - "index": false, - "type": "keyword" - }, "schemaVersion": { "type": "keyword" }, "semanticVersion": { "index": false, "type": "keyword" + }, + "artifacts": { + "type": "nested", + "properties": { + "policyId": { + "type": "keyword", + "index": false + }, + "artifactId": { + "type": "keyword", + "index": false + } + } } } }, @@ -2553,4 +2562,4 @@ } } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/es_archives/alerts_legacy/mappings.json b/x-pack/test/functional/es_archives/alerts_legacy/mappings.json index 68a60bec854c..8c33155636a8 100644 --- a/x-pack/test/functional/es_archives/alerts_legacy/mappings.json +++ b/x-pack/test/functional/es_archives/alerts_legacy/mappings.json @@ -27,7 +27,7 @@ "config": "c63748b75f39d0c54de12d12c1ccbc20", "dashboard": "74eb4b909f81222fa1ddeaba2881a37e", "endpoint:user-artifact": "4a11183eee21e6fbad864f7a30b39ad0", - "endpoint:user-artifact-manifest": "4b9c0e7cfaf86d82a7ee9ed68065e50d", + "endpoint:user-artifact-manifest": "a0d7b04ad405eed54d76e279c3727862", "epm-packages": "8f6e0b09ea0374c4ffe98c3755373cff", "exception-list": "497afa2f881a675d72d58e20057f3d8b", "exception-list-agnostic": "497afa2f881a675d72d58e20057f3d8b", @@ -680,16 +680,25 @@ "index": false, "type": "date" }, - "ids": { - "index": false, - "type": "keyword" - }, "schemaVersion": { "type": "keyword" }, "semanticVersion": { "index": false, "type": "keyword" + }, + "artifacts": { + "type": "nested", + "properties": { + "policyId": { + "type": "keyword", + "index": false + }, + "artifactId": { + "type": "keyword", + "index": false + } + } } } }, @@ -2677,4 +2686,4 @@ } } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/es_archives/canvas/filter/mappings.json b/x-pack/test/functional/es_archives/canvas/filter/mappings.json index 1f7e4092d857..66f40d746b79 100644 --- a/x-pack/test/functional/es_archives/canvas/filter/mappings.json +++ b/x-pack/test/functional/es_archives/canvas/filter/mappings.json @@ -28,7 +28,7 @@ "config": "c63748b75f39d0c54de12d12c1ccbc20", "dashboard": "40554caf09725935e2c02e02563a2d07", "endpoint:user-artifact": "4a11183eee21e6fbad864f7a30b39ad0", - "endpoint:user-artifact-manifest": "4b9c0e7cfaf86d82a7ee9ed68065e50d", + "endpoint:user-artifact-manifest": "a0d7b04ad405eed54d76e279c3727862", "enterprise_search_telemetry": "3d1b76c39bfb2cc8296b024d73854724", "epm-packages": "2b83397e3eaaaa8ef15e38813f3721c3", "exception-list": "67f055ab8c10abd7b2ebfd969b836788", @@ -752,16 +752,25 @@ "index": false, "type": "date" }, - "ids": { - "index": false, - "type": "keyword" - }, "schemaVersion": { "type": "keyword" }, "semanticVersion": { "index": false, "type": "keyword" + }, + "artifacts": { + "type": "nested", + "properties": { + "policyId": { + "type": "keyword", + "index": false + }, + "artifactId": { + "type": "keyword", + "index": false + } + } } } }, @@ -2419,4 +2428,4 @@ } } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/es_archives/canvas/reports/mappings.json b/x-pack/test/functional/es_archives/canvas/reports/mappings.json index 1f7e4092d857..66f40d746b79 100644 --- a/x-pack/test/functional/es_archives/canvas/reports/mappings.json +++ b/x-pack/test/functional/es_archives/canvas/reports/mappings.json @@ -28,7 +28,7 @@ "config": "c63748b75f39d0c54de12d12c1ccbc20", "dashboard": "40554caf09725935e2c02e02563a2d07", "endpoint:user-artifact": "4a11183eee21e6fbad864f7a30b39ad0", - "endpoint:user-artifact-manifest": "4b9c0e7cfaf86d82a7ee9ed68065e50d", + "endpoint:user-artifact-manifest": "a0d7b04ad405eed54d76e279c3727862", "enterprise_search_telemetry": "3d1b76c39bfb2cc8296b024d73854724", "epm-packages": "2b83397e3eaaaa8ef15e38813f3721c3", "exception-list": "67f055ab8c10abd7b2ebfd969b836788", @@ -752,16 +752,25 @@ "index": false, "type": "date" }, - "ids": { - "index": false, - "type": "keyword" - }, "schemaVersion": { "type": "keyword" }, "semanticVersion": { "index": false, "type": "keyword" + }, + "artifacts": { + "type": "nested", + "properties": { + "policyId": { + "type": "keyword", + "index": false + }, + "artifactId": { + "type": "keyword", + "index": false + } + } } } }, @@ -2419,4 +2428,4 @@ } } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/es_archives/cases/mappings.json b/x-pack/test/functional/es_archives/cases/mappings.json index ee128369ddd2..7101af08400a 100644 --- a/x-pack/test/functional/es_archives/cases/mappings.json +++ b/x-pack/test/functional/es_archives/cases/mappings.json @@ -28,7 +28,7 @@ "config": "c63748b75f39d0c54de12d12c1ccbc20", "dashboard": "d00f614b29a80360e1190193fd333bab", "endpoint:user-artifact": "4a11183eee21e6fbad864f7a30b39ad0", - "endpoint:user-artifact-manifest": "4b9c0e7cfaf86d82a7ee9ed68065e50d", + "endpoint:user-artifact-manifest": "a0d7b04ad405eed54d76e279c3727862", "epm-packages": "8f6e0b09ea0374c4ffe98c3755373cff", "exception-list": "497afa2f881a675d72d58e20057f3d8b", "exception-list-agnostic": "497afa2f881a675d72d58e20057f3d8b", @@ -666,16 +666,25 @@ "index": false, "type": "date" }, - "ids": { - "index": false, - "type": "keyword" - }, "schemaVersion": { "type": "keyword" }, "semanticVersion": { "index": false, "type": "keyword" + }, + "artifacts": { + "type": "nested", + "properties": { + "policyId": { + "type": "keyword", + "index": false + }, + "artifactId": { + "type": "keyword", + "index": false + } + } } } }, @@ -2553,4 +2562,4 @@ } } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/es_archives/data/search_sessions/mappings.json b/x-pack/test/functional/es_archives/data/search_sessions/mappings.json index 4492bcae7047..a3a56871269d 100644 --- a/x-pack/test/functional/es_archives/data/search_sessions/mappings.json +++ b/x-pack/test/functional/es_archives/data/search_sessions/mappings.json @@ -31,7 +31,7 @@ "core-usage-stats": "3d1b76c39bfb2cc8296b024d73854724", "dashboard": "40554caf09725935e2c02e02563a2d07", "endpoint:user-artifact": "4a11183eee21e6fbad864f7a30b39ad0", - "endpoint:user-artifact-manifest": "4b9c0e7cfaf86d82a7ee9ed68065e50d", + "endpoint:user-artifact-manifest": "a0d7b04ad405eed54d76e279c3727862", "enterprise_search_telemetry": "3d1b76c39bfb2cc8296b024d73854724", "epm-packages": "0cbbb16506734d341a96aaed65ec6413", "epm-packages-assets": "44621b2f6052ef966da47b7c3a00f33b", @@ -832,16 +832,25 @@ "index": false, "type": "date" }, - "ids": { - "index": false, - "type": "keyword" - }, "schemaVersion": { "type": "keyword" }, "semanticVersion": { "index": false, "type": "keyword" + }, + "artifacts": { + "type": "nested", + "properties": { + "policyId": { + "type": "keyword", + "index": false + }, + "artifactId": { + "type": "keyword", + "index": false + } + } } } }, diff --git a/x-pack/test/functional/es_archives/endpoint/artifacts/api_feature/data.json b/x-pack/test/functional/es_archives/endpoint/artifacts/api_feature/data.json index 2a62886cf3c9..e983512bec8a 100644 --- a/x-pack/test/functional/es_archives/endpoint/artifacts/api_feature/data.json +++ b/x-pack/test/functional/es_archives/endpoint/artifacts/api_feature/data.json @@ -60,9 +60,13 @@ "created": 1593183699663, "schemaVersion": "v1", "semanticVersion": "1.0.1", - "ids": [ - "endpoint-exceptionlist-macos-v1-d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658", - "endpoint-exceptionlist-windows-v1-8d2bcc37e82fad5d06e2c9e4bd96793ea8905ace1d528a57d0d0579ecc8c647e" + "artifacts": [ + { + "artifactId": "endpoint-exceptionlist-macos-v1-d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658" + }, + { + "artifactId": "endpoint-exceptionlist-windows-v1-8d2bcc37e82fad5d06e2c9e4bd96793ea8905ace1d528a57d0d0579ecc8c647e" + } ] }, "type": "endpoint:user-artifact-manifest", @@ -211,4 +215,4 @@ } } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/es_archives/endpoint/telemetry/agent_only/mappings.json b/x-pack/test/functional/es_archives/endpoint/telemetry/agent_only/mappings.json index 507611b8b4b5..9d7da7b5bbd8 100644 --- a/x-pack/test/functional/es_archives/endpoint/telemetry/agent_only/mappings.json +++ b/x-pack/test/functional/es_archives/endpoint/telemetry/agent_only/mappings.json @@ -27,7 +27,7 @@ "config": "c63748b75f39d0c54de12d12c1ccbc20", "dashboard": "d00f614b29a80360e1190193fd333bab", "endpoint:user-artifact": "4a11183eee21e6fbad864f7a30b39ad0", - "endpoint:user-artifact-manifest": "4b9c0e7cfaf86d82a7ee9ed68065e50d", + "endpoint:user-artifact-manifest": "a0d7b04ad405eed54d76e279c3727862", "epm-packages": "8f6e0b09ea0374c4ffe98c3755373cff", "exception-list": "497afa2f881a675d72d58e20057f3d8b", "exception-list-agnostic": "497afa2f881a675d72d58e20057f3d8b", @@ -661,16 +661,25 @@ "index": false, "type": "date" }, - "ids": { - "index": false, - "type": "keyword" - }, "schemaVersion": { "type": "keyword" }, "semanticVersion": { "index": false, "type": "keyword" + }, + "artifacts": { + "type": "nested", + "properties": { + "policyId": { + "type": "keyword", + "index": false + }, + "artifactId": { + "type": "keyword", + "index": false + } + } } } }, @@ -2584,4 +2593,4 @@ } } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/es_archives/endpoint/telemetry/cloned_endpoint_different_states/mappings.json b/x-pack/test/functional/es_archives/endpoint/telemetry/cloned_endpoint_different_states/mappings.json index 27aea27bebcd..7aadc43a3ad9 100644 --- a/x-pack/test/functional/es_archives/endpoint/telemetry/cloned_endpoint_different_states/mappings.json +++ b/x-pack/test/functional/es_archives/endpoint/telemetry/cloned_endpoint_different_states/mappings.json @@ -28,7 +28,7 @@ "config": "c63748b75f39d0c54de12d12c1ccbc20", "dashboard": "d00f614b29a80360e1190193fd333bab", "endpoint:user-artifact": "4a11183eee21e6fbad864f7a30b39ad0", - "endpoint:user-artifact-manifest": "4b9c0e7cfaf86d82a7ee9ed68065e50d", + "endpoint:user-artifact-manifest": "a0d7b04ad405eed54d76e279c3727862", "epm-packages": "8f6e0b09ea0374c4ffe98c3755373cff", "exception-list": "497afa2f881a675d72d58e20057f3d8b", "exception-list-agnostic": "497afa2f881a675d72d58e20057f3d8b", @@ -666,16 +666,25 @@ "index": false, "type": "date" }, - "ids": { - "index": false, - "type": "keyword" - }, "schemaVersion": { "type": "keyword" }, "semanticVersion": { "index": false, "type": "keyword" + }, + "artifacts": { + "type": "nested", + "properties": { + "policyId": { + "type": "keyword", + "index": false + }, + "artifactId": { + "type": "keyword", + "index": false + } + } } } }, @@ -2589,4 +2598,4 @@ } } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/es_archives/endpoint/telemetry/cloned_endpoint_installed/mappings.json b/x-pack/test/functional/es_archives/endpoint/telemetry/cloned_endpoint_installed/mappings.json index 27aea27bebcd..7aadc43a3ad9 100644 --- a/x-pack/test/functional/es_archives/endpoint/telemetry/cloned_endpoint_installed/mappings.json +++ b/x-pack/test/functional/es_archives/endpoint/telemetry/cloned_endpoint_installed/mappings.json @@ -28,7 +28,7 @@ "config": "c63748b75f39d0c54de12d12c1ccbc20", "dashboard": "d00f614b29a80360e1190193fd333bab", "endpoint:user-artifact": "4a11183eee21e6fbad864f7a30b39ad0", - "endpoint:user-artifact-manifest": "4b9c0e7cfaf86d82a7ee9ed68065e50d", + "endpoint:user-artifact-manifest": "a0d7b04ad405eed54d76e279c3727862", "epm-packages": "8f6e0b09ea0374c4ffe98c3755373cff", "exception-list": "497afa2f881a675d72d58e20057f3d8b", "exception-list-agnostic": "497afa2f881a675d72d58e20057f3d8b", @@ -666,16 +666,25 @@ "index": false, "type": "date" }, - "ids": { - "index": false, - "type": "keyword" - }, "schemaVersion": { "type": "keyword" }, "semanticVersion": { "index": false, "type": "keyword" + }, + "artifacts": { + "type": "nested", + "properties": { + "policyId": { + "type": "keyword", + "index": false + }, + "artifactId": { + "type": "keyword", + "index": false + } + } } } }, @@ -2589,4 +2598,4 @@ } } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/es_archives/endpoint/telemetry/cloned_endpoint_uninstalled/mappings.json b/x-pack/test/functional/es_archives/endpoint/telemetry/cloned_endpoint_uninstalled/mappings.json index 27aea27bebcd..7aadc43a3ad9 100644 --- a/x-pack/test/functional/es_archives/endpoint/telemetry/cloned_endpoint_uninstalled/mappings.json +++ b/x-pack/test/functional/es_archives/endpoint/telemetry/cloned_endpoint_uninstalled/mappings.json @@ -28,7 +28,7 @@ "config": "c63748b75f39d0c54de12d12c1ccbc20", "dashboard": "d00f614b29a80360e1190193fd333bab", "endpoint:user-artifact": "4a11183eee21e6fbad864f7a30b39ad0", - "endpoint:user-artifact-manifest": "4b9c0e7cfaf86d82a7ee9ed68065e50d", + "endpoint:user-artifact-manifest": "a0d7b04ad405eed54d76e279c3727862", "epm-packages": "8f6e0b09ea0374c4ffe98c3755373cff", "exception-list": "497afa2f881a675d72d58e20057f3d8b", "exception-list-agnostic": "497afa2f881a675d72d58e20057f3d8b", @@ -666,16 +666,25 @@ "index": false, "type": "date" }, - "ids": { - "index": false, - "type": "keyword" - }, "schemaVersion": { "type": "keyword" }, "semanticVersion": { "index": false, "type": "keyword" + }, + "artifacts": { + "type": "nested", + "properties": { + "policyId": { + "type": "keyword", + "index": false + }, + "artifactId": { + "type": "keyword", + "index": false + } + } } } }, @@ -2589,4 +2598,4 @@ } } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/es_archives/endpoint/telemetry/endpoint_malware_disabled/mappings.json b/x-pack/test/functional/es_archives/endpoint/telemetry/endpoint_malware_disabled/mappings.json index 507611b8b4b5..9d7da7b5bbd8 100644 --- a/x-pack/test/functional/es_archives/endpoint/telemetry/endpoint_malware_disabled/mappings.json +++ b/x-pack/test/functional/es_archives/endpoint/telemetry/endpoint_malware_disabled/mappings.json @@ -27,7 +27,7 @@ "config": "c63748b75f39d0c54de12d12c1ccbc20", "dashboard": "d00f614b29a80360e1190193fd333bab", "endpoint:user-artifact": "4a11183eee21e6fbad864f7a30b39ad0", - "endpoint:user-artifact-manifest": "4b9c0e7cfaf86d82a7ee9ed68065e50d", + "endpoint:user-artifact-manifest": "a0d7b04ad405eed54d76e279c3727862", "epm-packages": "8f6e0b09ea0374c4ffe98c3755373cff", "exception-list": "497afa2f881a675d72d58e20057f3d8b", "exception-list-agnostic": "497afa2f881a675d72d58e20057f3d8b", @@ -661,16 +661,25 @@ "index": false, "type": "date" }, - "ids": { - "index": false, - "type": "keyword" - }, "schemaVersion": { "type": "keyword" }, "semanticVersion": { "index": false, "type": "keyword" + }, + "artifacts": { + "type": "nested", + "properties": { + "policyId": { + "type": "keyword", + "index": false + }, + "artifactId": { + "type": "keyword", + "index": false + } + } } } }, @@ -2584,4 +2593,4 @@ } } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/es_archives/endpoint/telemetry/endpoint_malware_enabled/mappings.json b/x-pack/test/functional/es_archives/endpoint/telemetry/endpoint_malware_enabled/mappings.json index 507611b8b4b5..9d7da7b5bbd8 100644 --- a/x-pack/test/functional/es_archives/endpoint/telemetry/endpoint_malware_enabled/mappings.json +++ b/x-pack/test/functional/es_archives/endpoint/telemetry/endpoint_malware_enabled/mappings.json @@ -27,7 +27,7 @@ "config": "c63748b75f39d0c54de12d12c1ccbc20", "dashboard": "d00f614b29a80360e1190193fd333bab", "endpoint:user-artifact": "4a11183eee21e6fbad864f7a30b39ad0", - "endpoint:user-artifact-manifest": "4b9c0e7cfaf86d82a7ee9ed68065e50d", + "endpoint:user-artifact-manifest": "a0d7b04ad405eed54d76e279c3727862", "epm-packages": "8f6e0b09ea0374c4ffe98c3755373cff", "exception-list": "497afa2f881a675d72d58e20057f3d8b", "exception-list-agnostic": "497afa2f881a675d72d58e20057f3d8b", @@ -661,16 +661,25 @@ "index": false, "type": "date" }, - "ids": { - "index": false, - "type": "keyword" - }, "schemaVersion": { "type": "keyword" }, "semanticVersion": { "index": false, "type": "keyword" + }, + "artifacts": { + "type": "nested", + "properties": { + "policyId": { + "type": "keyword", + "index": false + }, + "artifactId": { + "type": "keyword", + "index": false + } + } } } }, @@ -2584,4 +2593,4 @@ } } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/es_archives/endpoint/telemetry/endpoint_uninstalled/mappings.json b/x-pack/test/functional/es_archives/endpoint/telemetry/endpoint_uninstalled/mappings.json index 507611b8b4b5..9d7da7b5bbd8 100644 --- a/x-pack/test/functional/es_archives/endpoint/telemetry/endpoint_uninstalled/mappings.json +++ b/x-pack/test/functional/es_archives/endpoint/telemetry/endpoint_uninstalled/mappings.json @@ -27,7 +27,7 @@ "config": "c63748b75f39d0c54de12d12c1ccbc20", "dashboard": "d00f614b29a80360e1190193fd333bab", "endpoint:user-artifact": "4a11183eee21e6fbad864f7a30b39ad0", - "endpoint:user-artifact-manifest": "4b9c0e7cfaf86d82a7ee9ed68065e50d", + "endpoint:user-artifact-manifest": "a0d7b04ad405eed54d76e279c3727862", "epm-packages": "8f6e0b09ea0374c4ffe98c3755373cff", "exception-list": "497afa2f881a675d72d58e20057f3d8b", "exception-list-agnostic": "497afa2f881a675d72d58e20057f3d8b", @@ -661,16 +661,25 @@ "index": false, "type": "date" }, - "ids": { - "index": false, - "type": "keyword" - }, "schemaVersion": { "type": "keyword" }, "semanticVersion": { "index": false, "type": "keyword" + }, + "artifacts": { + "type": "nested", + "properties": { + "policyId": { + "type": "keyword", + "index": false + }, + "artifactId": { + "type": "keyword", + "index": false + } + } } } }, @@ -2584,4 +2593,4 @@ } } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/es_archives/event_log_multiple_indicies/mappings.json b/x-pack/test/functional/es_archives/event_log_multiple_indicies/mappings.json index b418ccc1343a..e9a709138256 100644 --- a/x-pack/test/functional/es_archives/event_log_multiple_indicies/mappings.json +++ b/x-pack/test/functional/es_archives/event_log_multiple_indicies/mappings.json @@ -28,7 +28,7 @@ "config": "c63748b75f39d0c54de12d12c1ccbc20", "dashboard": "40554caf09725935e2c02e02563a2d07", "endpoint:user-artifact": "4a11183eee21e6fbad864f7a30b39ad0", - "endpoint:user-artifact-manifest": "4b9c0e7cfaf86d82a7ee9ed68065e50d", + "endpoint:user-artifact-manifest": "a0d7b04ad405eed54d76e279c3727862", "enterprise_search_telemetry": "3d1b76c39bfb2cc8296b024d73854724", "epm-packages": "2b83397e3eaaaa8ef15e38813f3721c3", "event_log_test": "bef808d4a9c27f204ffbda3359233931", @@ -573,4 +573,4 @@ } } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/es_archives/lists/mappings.json b/x-pack/test/functional/es_archives/lists/mappings.json index 73cb3ead0356..2c6e948cedf6 100644 --- a/x-pack/test/functional/es_archives/lists/mappings.json +++ b/x-pack/test/functional/es_archives/lists/mappings.json @@ -63,7 +63,7 @@ "ui-metric": "0d409297dc5ebe1e3a1da691c6ee32e3", "ingest-agent-policies": "9326f99c977fd2ef5ab24b6336a0675c", "url": "c7f66a0df8b1b52f17c28c4adb111105", - "endpoint:user-artifact-manifest": "67c28185da541c1404e7852d30498cd6", + "endpoint:user-artifact-manifest": "a0d7b04ad405eed54d76e279c3727862", "migrationVersion": "4a1746014a75ade3a714e1db5763276f", "index-pattern": "66eccb05066c5a89924f48a9e9736499", "fleet-agents": "034346488514b7058a79140b19ddf631", @@ -659,9 +659,25 @@ "type": "date", "index": false }, - "ids": { - "type": "keyword", - "index": false + "schemaVersion": { + "type": "keyword" + }, + "semanticVersion": { + "index": false, + "type": "keyword" + }, + "artifacts": { + "type": "nested", + "properties": { + "policyId": { + "type": "keyword", + "index": false + }, + "artifactId": { + "type": "keyword", + "index": false + } + } } } }, diff --git a/x-pack/test/functional/es_archives/reporting/canvas_disallowed_url/mappings.json b/x-pack/test/functional/es_archives/reporting/canvas_disallowed_url/mappings.json index da74f0a1f74b..aa097f9bb214 100644 --- a/x-pack/test/functional/es_archives/reporting/canvas_disallowed_url/mappings.json +++ b/x-pack/test/functional/es_archives/reporting/canvas_disallowed_url/mappings.json @@ -26,7 +26,7 @@ "config": "c63748b75f39d0c54de12d12c1ccbc20", "dashboard": "d00f614b29a80360e1190193fd333bab", "endpoint:user-artifact": "4a11183eee21e6fbad864f7a30b39ad0", - "endpoint:user-artifact-manifest": "67c28185da541c1404e7852d30498cd6", + "endpoint:user-artifact-manifest": "a0d7b04ad405eed54d76e279c3727862", "epm-packages": "8f6e0b09ea0374c4ffe98c3755373cff", "exception-list": "4818e7dfc3e538562c80ec34eb6f841b", "exception-list-agnostic": "4818e7dfc3e538562c80ec34eb6f841b", @@ -656,9 +656,25 @@ "index": false, "type": "date" }, - "ids": { + "schemaVersion": { + "type": "keyword" + }, + "semanticVersion": { "index": false, "type": "keyword" + }, + "artifacts": { + "type": "nested", + "properties": { + "policyId": { + "type": "keyword", + "index": false + }, + "artifactId": { + "type": "keyword", + "index": false + } + } } } }, diff --git a/x-pack/test/functional/es_archives/reporting/ecommerce_kibana/mappings.json b/x-pack/test/functional/es_archives/reporting/ecommerce_kibana/mappings.json index 6b2c61e5e782..8ee08f968d07 100644 --- a/x-pack/test/functional/es_archives/reporting/ecommerce_kibana/mappings.json +++ b/x-pack/test/functional/es_archives/reporting/ecommerce_kibana/mappings.json @@ -32,7 +32,7 @@ "core-usage-stats": "3d1b76c39bfb2cc8296b024d73854724", "dashboard": "40554caf09725935e2c02e02563a2d07", "endpoint:user-artifact": "4a11183eee21e6fbad864f7a30b39ad0", - "endpoint:user-artifact-manifest": "4b9c0e7cfaf86d82a7ee9ed68065e50d", + "endpoint:user-artifact-manifest": "a0d7b04ad405eed54d76e279c3727862", "enterprise_search_telemetry": "3d1b76c39bfb2cc8296b024d73854724", "epm-packages": "0cbbb16506734d341a96aaed65ec6413", "epm-packages-assets": "44621b2f6052ef966da47b7c3a00f33b", @@ -876,16 +876,25 @@ "index": false, "type": "date" }, - "ids": { - "index": false, - "type": "keyword" - }, "schemaVersion": { "type": "keyword" }, "semanticVersion": { "index": false, "type": "keyword" + }, + "artifacts": { + "type": "nested", + "properties": { + "policyId": { + "type": "keyword", + "index": false + }, + "artifactId": { + "type": "keyword", + "index": false + } + } } } }, @@ -2718,4 +2727,4 @@ } } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/es_archives/reporting/ecommerce_kibana_spaces/mappings.json b/x-pack/test/functional/es_archives/reporting/ecommerce_kibana_spaces/mappings.json index 1de04a64398c..9d6c0ecf9898 100644 --- a/x-pack/test/functional/es_archives/reporting/ecommerce_kibana_spaces/mappings.json +++ b/x-pack/test/functional/es_archives/reporting/ecommerce_kibana_spaces/mappings.json @@ -27,7 +27,7 @@ "config": "c63748b75f39d0c54de12d12c1ccbc20", "dashboard": "74eb4b909f81222fa1ddeaba2881a37e", "endpoint:user-artifact": "4a11183eee21e6fbad864f7a30b39ad0", - "endpoint:user-artifact-manifest": "4b9c0e7cfaf86d82a7ee9ed68065e50d", + "endpoint:user-artifact-manifest": "a0d7b04ad405eed54d76e279c3727862", "epm-packages": "386dc9996a3b74607de64c2ab2171582", "exception-list": "497afa2f881a675d72d58e20057f3d8b", "exception-list-agnostic": "497afa2f881a675d72d58e20057f3d8b", @@ -704,16 +704,25 @@ "index": false, "type": "date" }, - "ids": { - "index": false, - "type": "keyword" - }, "schemaVersion": { "type": "keyword" }, "semanticVersion": { "index": false, "type": "keyword" + }, + "artifacts": { + "type": "nested", + "properties": { + "policyId": { + "type": "keyword", + "index": false + }, + "artifactId": { + "type": "keyword", + "index": false + } + } } } }, @@ -2632,4 +2641,4 @@ } } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/es_archives/task_manager_removed_types/mappings.json b/x-pack/test/functional/es_archives/task_manager_removed_types/mappings.json index 757a1780e279..6ec81326d1ca 100644 --- a/x-pack/test/functional/es_archives/task_manager_removed_types/mappings.json +++ b/x-pack/test/functional/es_archives/task_manager_removed_types/mappings.json @@ -30,7 +30,7 @@ "config": "c63748b75f39d0c54de12d12c1ccbc20", "dashboard": "40554caf09725935e2c02e02563a2d07", "endpoint:user-artifact": "4a11183eee21e6fbad864f7a30b39ad0", - "endpoint:user-artifact-manifest": "4b9c0e7cfaf86d82a7ee9ed68065e50d", + "endpoint:user-artifact-manifest": "a0d7b04ad405eed54d76e279c3727862", "enterprise_search_telemetry": "3d1b76c39bfb2cc8296b024d73854724", "epm-packages": "2b83397e3eaaaa8ef15e38813f3721c3", "event_log_test": "bef808d4a9c27f204ffbda3359233931", @@ -222,4 +222,4 @@ } } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/es_archives/visualize/default/mappings.json b/x-pack/test/functional/es_archives/visualize/default/mappings.json index a6feafd3922a..be3b28703291 100644 --- a/x-pack/test/functional/es_archives/visualize/default/mappings.json +++ b/x-pack/test/functional/es_archives/visualize/default/mappings.json @@ -31,7 +31,7 @@ "core-usage-stats": "3d1b76c39bfb2cc8296b024d73854724", "dashboard": "40554caf09725935e2c02e02563a2d07", "endpoint:user-artifact": "4a11183eee21e6fbad864f7a30b39ad0", - "endpoint:user-artifact-manifest": "4b9c0e7cfaf86d82a7ee9ed68065e50d", + "endpoint:user-artifact-manifest": "a0d7b04ad405eed54d76e279c3727862", "enterprise_search_telemetry": "3d1b76c39bfb2cc8296b024d73854724", "epm-packages": "0cbbb16506734d341a96aaed65ec6413", "epm-packages-assets": "44621b2f6052ef966da47b7c3a00f33b", @@ -814,16 +814,25 @@ "index": false, "type": "date" }, - "ids": { - "index": false, - "type": "keyword" - }, "schemaVersion": { "type": "keyword" }, "semanticVersion": { "index": false, "type": "keyword" + }, + "artifacts": { + "type": "nested", + "properties": { + "policyId": { + "type": "keyword", + "index": false + }, + "artifactId": { + "type": "keyword", + "index": false + } + } } } }, @@ -2667,4 +2676,4 @@ } } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index add6979c2dde..dcb730f77725 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -17,7 +17,15 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont const find = getService('find'); const comboBox = getService('comboBox'); const browser = getService('browser'); - const PageObjects = getPageObjects(['header', 'timePicker', 'common', 'visualize', 'dashboard']); + + const PageObjects = getPageObjects([ + 'header', + 'timePicker', + 'common', + 'visualize', + 'dashboard', + 'timeToVisualize', + ]); return logWrapper('lensPage', log, { /** @@ -341,16 +349,16 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont title: string, saveAsNew?: boolean, redirectToOrigin?: boolean, - addToDashboard?: boolean, + addToDashboard?: 'new' | 'existing' | null, dashboardId?: string ) { await PageObjects.header.waitUntilLoadingHasFinished(); await testSubjects.click('lnsApp_saveButton'); - await PageObjects.visualize.setSaveModalValues(title, { + await PageObjects.timeToVisualize.setSaveModalValues(title, { saveAsNew, redirectToOrigin, - addToDashboard, + addToDashboard: addToDashboard ? addToDashboard : null, dashboardId, }); diff --git a/x-pack/test/security_solution_endpoint/page_objects/fleet_integrations_page.ts b/x-pack/test/security_solution_endpoint/page_objects/fleet_integrations_page.ts index 15e1c37befd9..791fed942496 100644 --- a/x-pack/test/security_solution_endpoint/page_objects/fleet_integrations_page.ts +++ b/x-pack/test/security_solution_endpoint/page_objects/fleet_integrations_page.ts @@ -18,7 +18,7 @@ export function FleetIntegrations({ getService, getPageObjects }: FtrProviderCon return { async navigateToIntegrationDetails(pkgkey: string) { await pageObjects.common.navigateToApp(PLUGIN_ID, { - hash: pagePathGetters.integration_details({ pkgkey }), + hash: pagePathGetters.integration_details_overview({ pkgkey }), }); }, diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index 4cbec2da2180..6209503e7561 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -68,6 +68,7 @@ { "path": "../plugins/licensing/tsconfig.json" }, { "path": "../plugins/ml/tsconfig.json" }, { "path": "../plugins/observability/tsconfig.json" }, + { "path": "../plugins/osquery/tsconfig.json" }, { "path": "../plugins/painless_lab/tsconfig.json" }, { "path": "../plugins/runtime_fields/tsconfig.json" }, { "path": "../plugins/saved_objects_tagging/tsconfig.json" }, diff --git a/x-pack/tsconfig.json b/x-pack/tsconfig.json index 00286ac47da6..5589c62010db 100644 --- a/x-pack/tsconfig.json +++ b/x-pack/tsconfig.json @@ -30,6 +30,7 @@ "plugins/maps_legacy_licensing/**/*", "plugins/ml/**/*", "plugins/observability/**/*", + "plugins/osquery/**/*", "plugins/reporting/**/*", "plugins/searchprofiler/**/*", "plugins/security_solution/cypress/**/*", @@ -133,6 +134,7 @@ { "path": "./plugins/maps/tsconfig.json" }, { "path": "./plugins/ml/tsconfig.json" }, { "path": "./plugins/observability/tsconfig.json" }, + { "path": "./plugins/osquery/tsconfig.json" }, { "path": "./plugins/painless_lab/tsconfig.json" }, { "path": "./plugins/saved_objects_tagging/tsconfig.json" }, { "path": "./plugins/searchprofiler/tsconfig.json" }, diff --git a/yarn.lock b/yarn.lock index c9f3186ffcba..319025b3aab7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6488,7 +6488,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^16.8.23", "@types/react@^16.9.36": +"@types/react@*", "@types/react@^16.9.36": version "16.9.36" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.36.tgz#ade589ff51e2a903e34ee4669e05dbfa0c1ce849" integrity sha512-mGgUb/Rk/vGx4NCvquRuSH0GHBQKb1OqpGS9cT9lFxlTLHZgkksgI60TuIxubmn7JuCb+sENHhQciqa0npm0AQ== @@ -21069,10 +21069,10 @@ moment-timezone@^0.5.27: resolved "https://registry.yarnpkg.com/moment/-/moment-2.28.0.tgz#cdfe73ce01327cee6537b0fafac2e0f21a237d75" integrity sha512-Z5KOjYmnHyd/ukynmFd/WwyXHd7L4J9vTI/nn5Ap9AVUgaAE15VvQ9MOGmJJygEUklupqIrFnor/tjTwRU+tQw== -monaco-editor@^0.17.0: - version "0.17.1" - resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.17.1.tgz#8fbe96ca54bfa75262706e044f8f780e904aa45c" - integrity sha512-JAc0mtW7NeO+0SwPRcdkfDbWLgkqL9WfP1NbpP9wNASsW6oWqgZqNIWt4teymGjZIXTElx3dnQmUYHmVrJ7HxA== +monaco-editor@*, monaco-editor@^0.22.3: + version "0.22.3" + resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.22.3.tgz#69b42451d3116c6c08d9b8e052007ff891fd85d7" + integrity sha512-RM559z2CJbczZ3k2b+ouacMINkAYWwRit4/vs0g2X/lkYefDiu0k2GmgWjAuiIpQi+AqASPOKvXNmYc8KUSvVQ== monitor-event-loop-delay@^1.0.0: version "1.0.0" @@ -24247,12 +24247,12 @@ react-moment-proptypes@^1.7.0: dependencies: moment ">=1.6.0" -react-monaco-editor@^0.27.0: - version "0.27.0" - resolved "https://registry.yarnpkg.com/react-monaco-editor/-/react-monaco-editor-0.27.0.tgz#2dbf47b8fd4d8e4763934051f07291d9b128bb89" - integrity sha512-Im40xO4DuFlQ6kVcSBHC+p70fD/5aErUy1uyLT9RZ4nlehn6BOPpwmcw/2IN/LfMvy8X4WmLuuvrNftBZLH+vA== +react-monaco-editor@^0.41.2: + version "0.41.2" + resolved "https://registry.yarnpkg.com/react-monaco-editor/-/react-monaco-editor-0.41.2.tgz#7ec9cadc101d73003a908fca61c50011f237d2b5" + integrity sha512-0nNqkkSLtUQDHtcCASv3ccYukD+P2uvFzcFZGh6iWg9RZF3Rj9/+jqsTNo2cl4avkX8JVGC/qnZr/g7hxXTBTQ== dependencies: - "@types/react" "^16.8.23" + monaco-editor "*" prop-types "^15.7.2" react-motion@^0.4.8: