diff --git a/.ci/teamcity/default/jest.sh b/.ci/teamcity/default/jest.sh index 93ca7f76f3a21..b900d1b6d6b4e 100755 --- a/.ci/teamcity/default/jest.sh +++ b/.ci/teamcity/default/jest.sh @@ -6,7 +6,5 @@ source "$(dirname "${0}")/../util.sh" export JOB=kibana-default-jest -cd "$XPACK_DIR" - checks-reporter-with-killswitch "Jest Unit Tests" \ - node scripts/jest --bail --debug + node scripts/jest x-pack --ci --verbose --maxWorkers=5 diff --git a/.ci/teamcity/oss/jest.sh b/.ci/teamcity/oss/jest.sh index 3ba9ab0c31c57..0dee07d00d2be 100755 --- a/.ci/teamcity/oss/jest.sh +++ b/.ci/teamcity/oss/jest.sh @@ -7,4 +7,4 @@ source "$(dirname "${0}")/../util.sh" export JOB=kibana-oss-jest checks-reporter-with-killswitch "OSS Jest Unit Tests" \ - node scripts/jest --ci --verbose + node scripts/jest --config jest.config.oss.js --ci --verbose --maxWorkers=5 diff --git a/.ci/teamcity/oss/jest_integration.sh b/.ci/teamcity/oss/jest_integration.sh index 1a23c46c8a2c2..4c51d2ff29888 100755 --- a/.ci/teamcity/oss/jest_integration.sh +++ b/.ci/teamcity/oss/jest_integration.sh @@ -7,4 +7,4 @@ source "$(dirname "${0}")/../util.sh" export JOB=kibana-oss-jest-integration checks-reporter-with-killswitch "OSS Jest Integration Tests" \ - node scripts/jest_integration --verbose + node scripts/jest_integration --ci --verbose diff --git a/docs/developer/contributing/development-functional-tests.asciidoc b/docs/developer/contributing/development-functional-tests.asciidoc index 580a5a000f391..f149e9de7aaba 100644 --- a/docs/developer/contributing/development-functional-tests.asciidoc +++ b/docs/developer/contributing/development-functional-tests.asciidoc @@ -6,7 +6,7 @@ We use functional tests to make sure the {kib} UI works as expected. It replaces [discrete] === Running functional tests -The `FunctionalTestRunner` is very bare bones and gets most of its functionality from its config file, located at {blob}test/functional/config.js[test/functional/config.js]. If you’re writing a plugin outside the {kib} repo, you will have your own config file. +The `FunctionalTestRunner` is very bare bones and gets most of its functionality from its config file, located at {blob}test/functional/config.js[test/functional/config.js] or {blob}x-pack/test/functional/config.js[x-pack/test/functional/config.js]. If you’re writing a plugin outside the {kib} repo, you will have your own config file. See <> for more info. There are three ways to run the tests depending on your goals: diff --git a/docs/developer/contributing/development-tests.asciidoc b/docs/developer/contributing/development-tests.asciidoc index 4cf667195153d..647dc8b3f3b26 100644 --- a/docs/developer/contributing/development-tests.asciidoc +++ b/docs/developer/contributing/development-tests.asciidoc @@ -1,8 +1,6 @@ [[development-tests]] == Testing -To ensure that your changes will not break other functionality, please run the test suite and build (<>) before submitting your Pull Request. - [discrete] === Running specific {kib} tests @@ -13,63 +11,57 @@ invoke them: |=== |Test runner |Test location |Runner command (working directory is {kib} root) -|Jest |`src/**/*.test.js` `src/**/*.test.ts` -|`yarn test:jest -t regexp [test path]` +|Jest |`**/*.test.{js,mjs,ts,tsx}` +|`yarn test:jest [test path]` -|Jest (integration) |`**/integration_tests/**/*.test.js` -|`yarn test:jest_integration -t regexp [test path]` +|Jest (integration) |`**/integration_tests/**/*.test.{js,mjs,ts,tsx}` +|`yarn test:jest_integration [test path]` |Mocha -|`src/**/__tests__/**/*.js` `!src/**/public/__tests__/*.js` `packages/kbn-dev-utils/src/**/__tests__/**/*.js` `tasks/**/__tests__/**/*.js` +|`**/__tests__/**/*.js` |`node scripts/mocha --grep=regexp [test path]` |Functional -|`test/*integration/**/config.js` `test/*functional/**/config.js` `test/accessibility/config.js` -|`yarn test:ftr:server --config test/[directory]/config.js``yarn test:ftr:runner --config test/[directory]/config.js --grep=regexp` +|`test/**/config.js` `x-pack/test/**/config.js` +|`node scripts/functional_tests_server --config [directory]/config.js``node scripts/functional_test_runner_ --config [directory]/config.js --grep=regexp` |=== -For X-Pack tests located in `x-pack/` see -link:{kib-repo}tree/{branch}/x-pack/README.md#testing[X-Pack Testing] - Test runner arguments: - Where applicable, the optional arguments -`-t=regexp` or `--grep=regexp` will only run tests or test suites +`--grep=regexp` will only run tests or test suites whose descriptions matches the regular expression. - `[test path]` is the relative path to the test file. -Examples: - Run the entire elasticsearch_service test suite: -`yarn test:jest src/core/server/elasticsearch/elasticsearch_service.test.ts` -- Run the jest test case whose description matches -`stops both admin and data clients`: -`yarn test:jest -t 'stops both admin and data clients' src/core/server/elasticsearch/elasticsearch_service.test.ts` -- Run the api integration test case whose description matches the given -string: ``` yarn test:ftr:server –config test/api_integration/config.js -yarn test:ftr:runner –config test/api_integration/config +=== Unit Testing -[discrete] -=== Cross-browser compatibility +Kibana primarily uses Jest for unit testing. Each plugin or package defines a `jest.config.js` that extends link:{kib-repo}tree/{branch}/packages/kbn-test/jest-preset.js[a preset] provided by the link:{kib-repo}tree/{branch}/packages/kbn-test[`@kbn/test`] package. Unless you intend to run all unit tests within the project, it's most efficient to provide the Jest configuration file for the plugin or package you're testing. -**Testing IE on OS X** +[source,bash] +---- +yarn jest --config src/plugins/dashboard/jest.config.js +---- -**Note:** IE11 is not supported from 7.9 onwards. +A script is available to provide a better user experience when testing while navigating throughout the repository. To run the tests within your current working directory, use `yarn test:jest`. Like the Jest CLI, you can also supply a path to determine which tests to run. + +[source,bash] +---- +kibana/src/plugins/dashboard/server$ yarn test:jest #or +kibana/src/plugins/dashboard$ yarn test:jest server #or +kibana$ yarn test:jest src/plugins/dashboard/server +---- + +Any additional options supplied to `test:jest` will be passed onto the Jest CLI with the resulting Jest command always being outputted. + +[source,bash] +---- +kibana/src/plugins/dashboard/server$ yarn test:jest --coverage + +# is equivelant to + +yarn jest --coverage --verbose --config /home/tyler/elastic/kibana/src/plugins/dashboard/jest.config.js server +---- + +NOTE: There are still a handful of legacy tests that use the Mocha test runner. For those tests, use `node scripts/mocha --grep=regexp [test path]`. Tests using Mocha are located within `__tests__` directories. -* http://www.vmware.com/products/fusion/fusion-evaluation.html[Download -VMWare Fusion]. -* https://developer.microsoft.com/en-us/microsoft-edge/tools/vms/#downloads[Download -IE virtual machines] for VMWare. -* Open VMWare and go to Window > Virtual Machine Library. Unzip the -virtual machine and drag the .vmx file into your Virtual Machine -Library. -* Right-click on the virtual machine you just added to your library and -select "`Snapshots…`", and then click the "`Take`" button in the modal -that opens. You can roll back to this snapshot when the VM expires in 90 -days. -* In System Preferences > Sharing, change your computer name to be -something simple, e.g. "`computer`". -* Run {kib} with `yarn start --host=computer.local` (substituting -your computer name). -* Now you can run your VM, open the browser, and navigate to -`http://computer.local:5601` to test {kib}. -* Alternatively you can use browserstack [discrete] === Running browser automation tests @@ -93,4 +85,30 @@ include::development-functional-tests.asciidoc[leveloffset=+1] include::development-unit-tests.asciidoc[leveloffset=+1] -include::development-accessibility-tests.asciidoc[leveloffset=+1] \ No newline at end of file +include::development-accessibility-tests.asciidoc[leveloffset=+1] + +[discrete] +=== Cross-browser compatibility + +**Testing IE on OS X** + +**Note:** IE11 is not supported from 7.9 onwards. + +* http://www.vmware.com/products/fusion/fusion-evaluation.html[Download +VMWare Fusion]. +* https://developer.microsoft.com/en-us/microsoft-edge/tools/vms/#downloads[Download +IE virtual machines] for VMWare. +* Open VMWare and go to Window > Virtual Machine Library. Unzip the +virtual machine and drag the .vmx file into your Virtual Machine +Library. +* Right-click on the virtual machine you just added to your library and +select "`Snapshots…`", and then click the "`Take`" button in the modal +that opens. You can roll back to this snapshot when the VM expires in 90 +days. +* In System Preferences > Sharing, change your computer name to be +something simple, e.g. "`computer`". +* Run {kib} with `yarn start --host=computer.local` (substituting +your computer name). +* Now you can run your VM, open the browser, and navigate to +`http://computer.local:5601` to test {kib}. +* Alternatively you can use browserstack \ No newline at end of file diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md index fde40cca38fa2..522c01124de82 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md @@ -101,12 +101,7 @@ readonly links: { readonly dateMath: string; }; readonly management: Record; - readonly ml: { - readonly guide: string; - readonly anomalyDetection: string; - readonly anomalyDetectionJobs: string; - readonly dataFrameAnalytics: string; - }; + readonly ml: Record; readonly visualize: Record; }; ``` diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md index 46437f7ccdc21..2bb885cba434f 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md @@ -17,5 +17,5 @@ export interface DocLinksStart | --- | --- | --- | | [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | string | | | [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | string | | -| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly date_histogram: string;
readonly date_range: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessSyntax: string;
readonly luceneExpressions: string;
};
readonly indexPatterns: {
readonly loadingData: string;
readonly introduction: string;
};
readonly addData: string;
readonly kibana: string;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
};
readonly date: {
readonly dateMath: string;
};
readonly management: Record<string, string>;
readonly ml: {
readonly guide: string;
readonly anomalyDetection: string;
readonly anomalyDetectionJobs: string;
readonly dataFrameAnalytics: string;
};
readonly visualize: Record<string, string>;
} | | +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly date_histogram: string;
readonly date_range: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessSyntax: string;
readonly luceneExpressions: string;
};
readonly indexPatterns: {
readonly loadingData: string;
readonly introduction: string;
};
readonly addData: string;
readonly kibana: string;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
};
readonly date: {
readonly dateMath: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly visualize: Record<string, string>;
} | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.fieldformat.allowsnumericalaggregations.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.fieldformat.allowsnumericalaggregations.md new file mode 100644 index 0000000000000..454a816a60171 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.fieldformat.allowsnumericalaggregations.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [FieldFormat](./kibana-plugin-plugins-data-public.fieldformat.md) > [allowsNumericalAggregations](./kibana-plugin-plugins-data-public.fieldformat.allowsnumericalaggregations.md) + +## FieldFormat.allowsNumericalAggregations property + +Signature: + +```typescript +allowsNumericalAggregations?: boolean; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.fieldformat.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.fieldformat.md index b53e301c46c1c..c956ffffd85ec 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.fieldformat.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.fieldformat.md @@ -21,6 +21,7 @@ export declare abstract class FieldFormat | Property | Modifiers | Type | Description | | --- | --- | --- | --- | | [\_params](./kibana-plugin-plugins-data-public.fieldformat._params.md) | | any | | +| [allowsNumericalAggregations](./kibana-plugin-plugins-data-public.fieldformat.allowsnumericalaggregations.md) | | boolean | | | [convertObject](./kibana-plugin-plugins-data-public.fieldformat.convertobject.md) | | FieldFormatConvert | undefined | {FieldFormatConvert} have to remove the private because of https://github.com/Microsoft/TypeScript/issues/17293 | | [fieldType](./kibana-plugin-plugins-data-public.fieldformat.fieldtype.md) | static | string | string[] | {string} - Field Format Type | | [getConfig](./kibana-plugin-plugins-data-public.fieldformat.getconfig.md) | | FieldFormatsGetConfigFn | undefined | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ifieldformat.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ifieldformat.md index 0937706d6b0e9..4fe738ddef5dc 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ifieldformat.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ifieldformat.md @@ -7,5 +7,5 @@ Signature: ```typescript -export declare type IFieldFormat = PublicMethodsOf; +export declare type IFieldFormat = FieldFormat; ``` diff --git a/package.json b/package.json index 4fb88706be16f..9ee9df67b8aea 100644 --- a/package.json +++ b/package.json @@ -43,14 +43,12 @@ "preinstall": "node ./preinstall_check", "kbn": "node scripts/kbn", "es": "node scripts/es", - "test": "grunt test", "test:jest": "node scripts/jest", "test:jest_integration": "node scripts/jest_integration", "test:mocha": "node scripts/mocha", "test:ftr": "node scripts/functional_tests", "test:ftr:server": "node scripts/functional_tests_server", "test:ftr:runner": "node scripts/functional_test_runner", - "test:coverage": "grunt test:coverage", "checkLicenses": "node scripts/check_licenses --dev", "build": "node scripts/build --all-platforms", "start": "node scripts/kibana --dev", @@ -108,7 +106,7 @@ "@elastic/datemath": "link:packages/elastic-datemath", "@elastic/elasticsearch": "7.10.0", "@elastic/ems-client": "7.11.0", - "@elastic/eui": "30.5.1", + "@elastic/eui": "30.6.0", "@elastic/filesaver": "1.1.2", "@elastic/good": "^9.0.1-kibana3", "@elastic/node-crypto": "1.2.1", diff --git a/packages/kbn-monaco/src/painless/diagnostics_adapter.ts b/packages/kbn-monaco/src/painless/diagnostics_adapter.ts index 95c4ec19cea1f..3535e1e3373e0 100644 --- a/packages/kbn-monaco/src/painless/diagnostics_adapter.ts +++ b/packages/kbn-monaco/src/painless/diagnostics_adapter.ts @@ -32,13 +32,28 @@ export class DiagnosticsAdapter { constructor(private worker: WorkerAccessor) { const onModelAdd = (model: monaco.editor.IModel): void => { let handle: any; - model.onDidChangeContent(() => { - // Every time a new change is made, wait 500ms before validating - clearTimeout(handle); - handle = setTimeout(() => this.validate(model.uri), 500); - }); - this.validate(model.uri); + if (model.getModeId() === ID) { + model.onDidChangeContent(() => { + // Do not validate if the language ID has changed + if (model.getModeId() !== ID) { + return; + } + + // Every time a new change is made, wait 500ms before validating + clearTimeout(handle); + handle = setTimeout(() => this.validate(model.uri), 500); + }); + + model.onDidChangeLanguage(({ newLanguage }) => { + // Reset the model markers if the language ID has changed and is no longer "painless" + if (newLanguage !== ID) { + return monaco.editor.setModelMarkers(model, ID, []); + } + }); + + this.validate(model.uri); + } }; monaco.editor.onDidCreateModel(onModelAdd); monaco.editor.getModels().forEach(onModelAdd); @@ -46,11 +61,12 @@ export class DiagnosticsAdapter { private async validate(resource: monaco.Uri): Promise { const worker = await this.worker(resource); - const errorMarkers = await worker.getSyntaxErrors(); - - const model = monaco.editor.getModel(resource); + const errorMarkers = await worker.getSyntaxErrors(resource.toString()); - // Set the error markers and underline them with "Error" severity - monaco.editor.setModelMarkers(model!, ID, errorMarkers.map(toDiagnostics)); + if (errorMarkers) { + const model = monaco.editor.getModel(resource); + // Set the error markers and underline them with "Error" severity + monaco.editor.setModelMarkers(model!, ID, errorMarkers.map(toDiagnostics)); + } } } diff --git a/packages/kbn-monaco/src/painless/worker/painless_worker.ts b/packages/kbn-monaco/src/painless/worker/painless_worker.ts index ce4ba024a4caa..35138dfdd4417 100644 --- a/packages/kbn-monaco/src/painless/worker/painless_worker.ts +++ b/packages/kbn-monaco/src/painless/worker/painless_worker.ts @@ -28,14 +28,18 @@ export class PainlessWorker { this._ctx = ctx; } - private getTextDocument(): string { - const model = this._ctx.getMirrorModels()[0]; - return model.getValue(); + private getTextDocument(modelUri: string): string | undefined { + const model = this._ctx.getMirrorModels().find((m) => m.uri.toString() === modelUri); + + return model?.getValue(); } - public async getSyntaxErrors() { - const code = this.getTextDocument(); - return parseAndGetSyntaxErrors(code); + public async getSyntaxErrors(modelUri: string) { + const code = this.getTextDocument(modelUri); + + if (code) { + return parseAndGetSyntaxErrors(code); + } } public provideAutocompleteSuggestions( diff --git a/packages/kbn-test/src/index.ts b/packages/kbn-test/src/index.ts index 54b064f5cd49e..a88820eb281cc 100644 --- a/packages/kbn-test/src/index.ts +++ b/packages/kbn-test/src/index.ts @@ -62,3 +62,5 @@ export * from './functional_test_runner'; export { getUrl } from './jest/utils/get_url'; export { runCheckJestConfigsCli } from './jest/run_check_jest_configs_cli'; + +export { runJest } from './jest/run'; diff --git a/packages/kbn-test/src/jest/run.test.ts b/packages/kbn-test/src/jest/run.test.ts new file mode 100644 index 0000000000000..5be033baade6a --- /dev/null +++ b/packages/kbn-test/src/jest/run.test.ts @@ -0,0 +1,38 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { commonBasePath } from './run'; + +describe('commonBasePath', () => { + it('returns a common path', () => { + expect(commonBasePath(['foo/bar/baz', 'foo/bar/quux', 'foo/bar'])).toBe('foo/bar'); + }); + + it('handles an empty array', () => { + expect(commonBasePath([])).toBe(''); + }); + + it('handles no common path', () => { + expect(commonBasePath(['foo', 'bar'])).toBe(''); + }); + + it('matches full paths', () => { + expect(commonBasePath(['foo/bar', 'foo/bar_baz'])).toBe('foo'); + }); +}); diff --git a/packages/kbn-test/src/jest/run.ts b/packages/kbn-test/src/jest/run.ts new file mode 100644 index 0000000000000..3283b6c8901fa --- /dev/null +++ b/packages/kbn-test/src/jest/run.ts @@ -0,0 +1,110 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Run Jest tests +// +// Provides Jest with `--config` to the first jest.config.js file found in the current +// directory, or while going up in the directory chain. If the current working directory +// is nested under the config path, a pattern will be provided to Jest to only run the +// tests within that directory. +// +// Any additional options passed will be forwarded to Jest. +// +// See all cli options in https://facebook.github.io/jest/docs/cli.html + +import { resolve, relative, sep as osSep } from 'path'; +import { existsSync } from 'fs'; +import { run } from 'jest'; +import { buildArgv } from 'jest-cli/build/cli'; +import { ToolingLog } from '@kbn/dev-utils'; + +// yarn test:jest src/core/server/saved_objects +// yarn test:jest src/core/public/core_system.test.ts +// :kibana/src/core/server/saved_objects yarn test:jest + +export function runJest(configName = 'jest.config.js') { + const argv = buildArgv(process.argv); + + const log = new ToolingLog({ + level: argv.verbose ? 'verbose' : 'info', + writeTo: process.stdout, + }); + + if (!argv.config) { + const cwd = process.env.INIT_CWD || process.cwd(); + const testFiles = argv._.splice(2).map((p) => resolve(cwd, p)); + const commonTestFiles = commonBasePath(testFiles); + const testFilesProvided = testFiles.length > 0; + + log.verbose('cwd:', cwd); + log.verbose('testFiles:', testFiles.join(', ')); + log.verbose('commonTestFiles:', commonTestFiles); + + let configPath; + + // sets the working directory to the cwd or the common + // base directory of the provided test files + let wd = testFilesProvided ? commonTestFiles : cwd; + + configPath = resolve(wd, configName); + + while (!existsSync(configPath)) { + wd = resolve(wd, '..'); + configPath = resolve(wd, configName); + } + + log.verbose(`no config provided, found ${configPath}`); + process.argv.push('--config', configPath); + + if (!testFilesProvided) { + log.verbose(`no test files provided, setting to current directory`); + process.argv.push(relative(wd, cwd)); + } + + log.info('yarn jest', process.argv.slice(2).join(' ')); + } + + if (process.env.NODE_ENV == null) { + process.env.NODE_ENV = 'test'; + } + + run(); +} + +/** + * Finds the common basePath by sorting the array + * and comparing the first and last element + */ +export function commonBasePath(paths: string[] = [], sep = osSep) { + if (paths.length === 0) return ''; + + paths = paths.concat().sort(); + + const first = paths[0].split(sep); + const last = paths[paths.length - 1].split(sep); + + const length = first.length; + let i = 0; + + while (i < length && first[i] === last[i]) { + i++; + } + + return first.slice(0, i).join(sep); +} diff --git a/scripts/functional_tests.js b/scripts/functional_tests.js index 196a13fbb2133..632a760d605b0 100644 --- a/scripts/functional_tests.js +++ b/scripts/functional_tests.js @@ -24,6 +24,7 @@ const alwaysImportedTests = [ require.resolve('../test/ui_capabilities/newsfeed_err/config.ts'), require.resolve('../test/new_visualize_flow/config.ts'), require.resolve('../test/security_functional/config.ts'), + require.resolve('../test/functional/config.legacy.ts'), ]; // eslint-disable-next-line no-restricted-syntax const onlyNotInCoverageTests = [ diff --git a/scripts/jest.js b/scripts/jest.js index 90f8da10f4c90..cb31d7785898d 100755 --- a/scripts/jest.js +++ b/scripts/jest.js @@ -17,27 +17,4 @@ * under the License. */ -// # Run Jest tests -// -// All args will be forwarded directly to Jest, e.g. to watch tests run: -// -// node scripts/jest --watch -// -// or to build code coverage: -// -// node scripts/jest --coverage -// -// See all cli options in https://facebook.github.io/jest/docs/cli.html - -if (process.argv.indexOf('--config') === -1) { - // append correct jest.config if none is provided - var configPath = require('path').resolve(__dirname, '../jest.config.oss.js'); - process.argv.push('--config', configPath); - console.log('Running Jest with --config', configPath); -} - -if (process.env.NODE_ENV == null) { - process.env.NODE_ENV = 'test'; -} - -require('jest').run(); +require('@kbn/test').runJest(); diff --git a/scripts/jest_integration.js b/scripts/jest_integration.js index f07d28939ef0c..1df79781fe26d 100755 --- a/scripts/jest_integration.js +++ b/scripts/jest_integration.js @@ -17,29 +17,6 @@ * under the License. */ -// # Run Jest integration tests -// -// All args will be forwarded directly to Jest, e.g. to watch tests run: -// -// node scripts/jest_integration --watch -// -// or to build code coverage: -// -// node scripts/jest_integration --coverage -// -// See all cli options in https://facebook.github.io/jest/docs/cli.html - process.argv.push('--runInBand'); -if (process.argv.indexOf('--config') === -1) { - // append correct jest.config if none is provided - var configPath = require('path').resolve(__dirname, '../jest.config.integration.js'); - process.argv.push('--config', configPath); - console.log('Running Jest with --config', configPath); -} - -if (process.env.NODE_ENV == null) { - process.env.NODE_ENV = 'test'; -} - -require('jest').run(); +require('@kbn/test').runJest('jest.config.integration.js'); diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index 201f2e5f8f14b..c836686ec602b 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -1974,6 +1974,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` > + + + `); + }); + it('supplies status badge correct status', () => { step.synthetics = { payload: { status: 'THE_STATUS' }, }; - expect(shallowWithIntl().find('StatusBadge')) - .toMatchInlineSnapshot(` + expect( + mountWithRouter().find( + 'StatusBadge' + ) + ).toMatchInlineSnapshot(` + > + + + + + + + + `); }); @@ -86,8 +172,11 @@ describe('ExecutedStep', () => { }, }; - expect(shallowWithIntl().find('CodeBlockAccordion')) - .toMatchInlineSnapshot(` + expect( + mountWithRouter().find( + 'CodeBlockAccordion' + ) + ).toMatchInlineSnapshot(` Array [ { language="javascript" overflowHeight={360} > - const someVar = "the var" + +
+
+ +
+
+ +
+
+ + +
+
+                              
+                                const someVar = "the var"
+                              
+                            
+
+
+
+
+
+
+
+
+
, { language="html" overflowHeight={360} > - There was an error executing the step. + +
+
+ +
+
+ +
+
+ + +
+
+                              
+                                There was an error executing the step.
+                              
+                            
+
+
+
+
+
+
+
+
+
, { language="html" overflowHeight={360} > - some.stack.trace.string + +
+
+ +
+
+ +
+
+ + +
+
+                              
+                                some.stack.trace.string
+                              
+                            
+
+
+
+
+
+
+
+
+
, ] `); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_journey.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_journey.tsx index 0c47e4c73e976..a9748524d1bb3 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_journey.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_journey.tsx @@ -78,7 +78,7 @@ export const ExecutedJourney: FC = ({ journey }) => { {journey.steps.filter(isStepEnd).map((step, index) => ( - + ))} diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_step.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_step.tsx index 5966851973af2..01a599f8e8a60 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_step.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_step.tsx @@ -12,80 +12,104 @@ import { CodeBlockAccordion } from './code_block_accordion'; import { StepScreenshotDisplay } from './step_screenshot_display'; import { StatusBadge } from './status_badge'; import { Ping } from '../../../../common/runtime_types'; +import { StepDetailLink } from '../../common/step_detail_link'; const CODE_BLOCK_OVERFLOW_HEIGHT = 360; interface ExecutedStepProps { step: Ping; index: number; + checkGroup: string; } -export const ExecutedStep: FC = ({ step, index }) => ( - <> -
-
- - - - - +export const ExecutedStep: FC = ({ step, index, checkGroup }) => { + return ( + <> +
+
+ {step.synthetics?.step?.index && checkGroup ? ( + + + + + + + + ) : ( + + + + + + )} +
+ +
+ +
+ +
+ + + + + + + {step.synthetics?.payload?.source} + + + {step.synthetics?.error?.message} + + + {step.synthetics?.error?.stack} + + + +
- -
- -
- -
- - - - - - - {step.synthetics?.payload?.source} - - - {step.synthetics?.error?.message} - - - {step.synthetics?.error?.stack} - - - -
-
- -); + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail.tsx new file mode 100644 index 0000000000000..fd68edef3226b --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail.tsx @@ -0,0 +1,142 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiTitle, + EuiButtonEmpty, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; +import moment from 'moment'; +import { WaterfallChartContainer } from './waterfall/waterfall_chart_container'; + +export const PREVIOUS_CHECK_BUTTON_TEXT = i18n.translate( + 'xpack.uptime.synthetics.stepDetail.previousCheckButtonText', + { + defaultMessage: 'Previous check', + } +); + +export const NEXT_CHECK_BUTTON_TEXT = i18n.translate( + 'xpack.uptime.synthetics.stepDetail.nextCheckButtonText', + { + defaultMessage: 'Next check', + } +); + +interface Props { + checkGroup: string; + stepName?: string; + stepIndex: number; + totalSteps: number; + hasPreviousStep: boolean; + hasNextStep: boolean; + handlePreviousStep: () => void; + handleNextStep: () => void; + handleNextRun: () => void; + handlePreviousRun: () => void; + previousCheckGroup?: string; + nextCheckGroup?: string; + checkTimestamp?: string; + dateFormat: string; +} + +export const StepDetail: React.FC = ({ + dateFormat, + stepName, + checkGroup, + stepIndex, + totalSteps, + hasPreviousStep, + hasNextStep, + handlePreviousStep, + handleNextStep, + handlePreviousRun, + handleNextRun, + previousCheckGroup, + nextCheckGroup, + checkTimestamp, +}) => { + return ( + <> + + + + + +

{stepName}

+
+
+ + + + + + + + + + + + + + + +
+
+ + + + + {PREVIOUS_CHECK_BUTTON_TEXT} + + + + {moment(checkTimestamp).format(dateFormat)} + + + + {NEXT_CHECK_BUTTON_TEXT} + + + + +
+ + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx new file mode 100644 index 0000000000000..58cf8d6e492da --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText, EuiLoadingSpinner } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useEffect, useCallback, useMemo } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { useHistory } from 'react-router-dom'; +import moment from 'moment'; +import { useBreadcrumbs } from '../../../../hooks/use_breadcrumbs'; +import { getJourneySteps } from '../../../../state/actions/journey'; +import { journeySelector } from '../../../../state/selectors'; +import { useUiSetting$ } from '../../../../../../../../src/plugins/kibana_react/public'; +import { StepDetail } from './step_detail'; + +export const NO_STEP_DATA = i18n.translate('xpack.uptime.synthetics.stepDetail.noData', { + defaultMessage: 'No data could be found for this step', +}); + +interface Props { + checkGroup: string; + stepIndex: number; +} + +export const StepDetailContainer: React.FC = ({ checkGroup, stepIndex }) => { + const dispatch = useDispatch(); + const history = useHistory(); + + const [dateFormat] = useUiSetting$('dateFormat'); + + useEffect(() => { + if (checkGroup) { + dispatch(getJourneySteps({ checkGroup })); + } + }, [dispatch, checkGroup]); + + const journeys = useSelector(journeySelector); + const journey = journeys[checkGroup ?? '']; + + const { activeStep, hasPreviousStep, hasNextStep } = useMemo(() => { + return { + hasPreviousStep: stepIndex > 1 ? true : false, + activeStep: journey?.steps?.find((step) => step.synthetics?.step?.index === stepIndex), + hasNextStep: journey && journey.steps && stepIndex < journey.steps.length ? true : false, + }; + }, [stepIndex, journey]); + + useBreadcrumbs([ + ...(activeStep?.monitor?.name ? [{ text: activeStep?.monitor?.name }] : []), + ...(journey?.details?.timestamp + ? [{ text: moment(journey?.details?.timestamp).format(dateFormat) }] + : []), + ]); + + const handleNextStep = useCallback(() => { + history.push(`/journey/${checkGroup}/step/${stepIndex + 1}`); + }, [history, checkGroup, stepIndex]); + + const handlePreviousStep = useCallback(() => { + history.push(`/journey/${checkGroup}/step/${stepIndex - 1}`); + }, [history, checkGroup, stepIndex]); + + const handleNextRun = useCallback(() => { + history.push(`/journey/${journey?.details?.next?.checkGroup}/step/1`); + }, [history, journey?.details?.next?.checkGroup]); + + const handlePreviousRun = useCallback(() => { + history.push(`/journey/${journey?.details?.previous?.checkGroup}/step/1`); + }, [history, journey?.details?.previous?.checkGroup]); + + return ( + <> + + {(!journey || journey.loading) && ( + + + + + + )} + {journey && !activeStep && !journey.loading && ( + + + +

{NO_STEP_DATA}

+
+
+
+ )} + {journey && activeStep && !journey.loading && ( + + )} +
+ + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.test.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.test.ts new file mode 100644 index 0000000000000..fff14376667b2 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.test.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { colourPalette } from './data_formatting'; + +describe('Palettes', () => { + it('A colour palette comprising timing and mime type colours is correctly generated', () => { + expect(colourPalette).toEqual({ + blocked: '#b9a888', + connect: '#da8b45', + dns: '#54b399', + font: '#aa6556', + html: '#f3b3a6', + media: '#d6bf57', + other: '#e7664c', + receive: '#54b399', + script: '#9170b8', + send: '#d36086', + ssl: '#edc5a2', + stylesheet: '#ca8eae', + wait: '#b0c9e0', + }); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.ts new file mode 100644 index 0000000000000..7c6e176315b5b --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.ts @@ -0,0 +1,209 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { euiPaletteColorBlind } from '@elastic/eui'; + +import { + NetworkItems, + NetworkItem, + FriendlyTimingLabels, + FriendlyMimetypeLabels, + MimeType, + MimeTypesMap, + Timings, + TIMING_ORDER, + SidebarItems, + LegendItems, +} from './types'; +import { WaterfallData } from '../../waterfall'; +import { NetworkEvent } from '../../../../../../common/runtime_types'; + +export const extractItems = (data: NetworkEvent[]): NetworkItems => { + // NOTE: This happens client side as the "payload" property is mapped + // in such a way it can't be queried (or sorted on) via ES. + return data.sort((a: NetworkItem, b: NetworkItem) => { + return a.requestSentTime - b.requestSentTime; + }); +}; + +const formatValueForDisplay = (value: number, points: number = 3) => { + return Number(value).toFixed(points); +}; + +const getColourForMimeType = (mimeType?: string) => { + const key = mimeType && MimeTypesMap[mimeType] ? MimeTypesMap[mimeType] : MimeType.Other; + return colourPalette[key]; +}; + +export const getSeriesAndDomain = (items: NetworkItems) => { + const getValueForOffset = (item: NetworkItem) => { + return item.requestSentTime; + }; + + // The earliest point in time a request is sent or started. This will become our notion of "0". + const zeroOffset = items.reduce((acc, item) => { + const offsetValue = getValueForOffset(item); + return offsetValue < acc ? offsetValue : acc; + }, Infinity); + + const getValue = (timings: NetworkEvent['timings'], timing: Timings) => { + if (!timings) return; + + // SSL is a part of the connect timing + if (timing === Timings.Connect && timings.ssl > 0) { + return timings.connect - timings.ssl; + } else { + return timings[timing]; + } + }; + + const series = items.reduce((acc, item, index) => { + if (!item.timings) return acc; + + const offsetValue = getValueForOffset(item); + + let currentOffset = offsetValue - zeroOffset; + + TIMING_ORDER.forEach((timing) => { + const value = getValue(item.timings, timing); + const colour = + timing === Timings.Receive ? getColourForMimeType(item.mimeType) : colourPalette[timing]; + if (value && value >= 0) { + const y = currentOffset + value; + + acc.push({ + x: index, + y0: currentOffset, + y, + config: { + colour, + tooltipProps: { + value: `${FriendlyTimingLabels[timing]}: ${formatValueForDisplay( + y - currentOffset + )}ms`, + colour, + }, + }, + }); + currentOffset = y; + } + }); + return acc; + }, []); + + const yValues = series.map((serie) => serie.y); + const domain = { min: 0, max: Math.max(...yValues) }; + return { series, domain }; +}; + +export const getSidebarItems = (items: NetworkItems): SidebarItems => { + return items.map((item) => { + const { url, status, method } = item; + return { url, status, method }; + }); +}; + +export const getLegendItems = (): LegendItems => { + let timingItems: LegendItems = []; + Object.values(Timings).forEach((timing) => { + // The "receive" timing is mapped to a mime type colour, so we don't need to show this in the legend + if (timing === Timings.Receive) { + return; + } + timingItems = [ + ...timingItems, + { name: FriendlyTimingLabels[timing], colour: TIMING_PALETTE[timing] }, + ]; + }); + + let mimeTypeItems: LegendItems = []; + Object.values(MimeType).forEach((mimeType) => { + mimeTypeItems = [ + ...mimeTypeItems, + { name: FriendlyMimetypeLabels[mimeType], colour: MIME_TYPE_PALETTE[mimeType] }, + ]; + }); + return [...timingItems, ...mimeTypeItems]; +}; + +// Timing colour palette +type TimingColourPalette = { + [K in Timings]: string; +}; + +const SAFE_PALETTE = euiPaletteColorBlind({ rotations: 2 }); + +const buildTimingPalette = (): TimingColourPalette => { + const palette = Object.values(Timings).reduce>((acc, value) => { + switch (value) { + case Timings.Blocked: + acc[value] = SAFE_PALETTE[6]; + break; + case Timings.Dns: + acc[value] = SAFE_PALETTE[0]; + break; + case Timings.Connect: + acc[value] = SAFE_PALETTE[7]; + break; + case Timings.Ssl: + acc[value] = SAFE_PALETTE[17]; + break; + case Timings.Send: + acc[value] = SAFE_PALETTE[2]; + break; + case Timings.Wait: + acc[value] = SAFE_PALETTE[11]; + break; + case Timings.Receive: + acc[value] = SAFE_PALETTE[0]; + break; + } + return acc; + }, {}); + + return palette as TimingColourPalette; +}; + +const TIMING_PALETTE = buildTimingPalette(); + +// MimeType colour palette +type MimeTypeColourPalette = { + [K in MimeType]: string; +}; + +const buildMimeTypePalette = (): MimeTypeColourPalette => { + const palette = Object.values(MimeType).reduce>((acc, value) => { + switch (value) { + case MimeType.Html: + acc[value] = SAFE_PALETTE[19]; + break; + case MimeType.Script: + acc[value] = SAFE_PALETTE[3]; + break; + case MimeType.Stylesheet: + acc[value] = SAFE_PALETTE[4]; + break; + case MimeType.Media: + acc[value] = SAFE_PALETTE[5]; + break; + case MimeType.Font: + acc[value] = SAFE_PALETTE[8]; + break; + case MimeType.Other: + acc[value] = SAFE_PALETTE[9]; + break; + } + return acc; + }, {}); + + return palette as MimeTypeColourPalette; +}; + +const MIME_TYPE_PALETTE = buildMimeTypePalette(); + +type ColourPalette = TimingColourPalette & MimeTypeColourPalette; + +export const colourPalette: ColourPalette = { ...TIMING_PALETTE, ...MIME_TYPE_PALETTE }; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/consumers/synthetics/types.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/types.ts similarity index 86% rename from x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/consumers/synthetics/types.ts rename to x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/types.ts index 1dd58b4f86db3..738929741ddaf 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/consumers/synthetics/types.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/types.ts @@ -5,6 +5,7 @@ */ import { i18n } from '@kbn/i18n'; +import { NetworkEvent } from '../../../../../../common/runtime_types'; export enum Timings { Blocked = 'blocked', @@ -33,7 +34,7 @@ export const FriendlyTimingLabels = { } ), [Timings.Ssl]: i18n.translate('xpack.uptime.synthetics.waterfallChart.labels.timings.ssl', { - defaultMessage: 'SSL', + defaultMessage: 'TLS', }), [Timings.Send]: i18n.translate('xpack.uptime.synthetics.waterfallChart.labels.timings.send', { defaultMessage: 'Sending request', @@ -144,21 +145,7 @@ export const MimeTypesMap: Record = { 'application/font-sfnt': MimeType.Font, }; -export interface NetworkItem { - timestamp: string; - method: string; - url: string; - status: number; - mimeType?: string; - // NOTE: This is the time the request was actually issued. timing.request_time might be later if the request was queued. - requestSentTime: number; - responseReceivedTime: number; - // NOTE: Denotes the earlier figure out of request sent time and request start time (part of timings). This can vary based on queue times, and - // also whether an entry actually has timings available. - // Ref: https://github.com/ChromeDevTools/devtools-frontend/blob/ed2a064ac194bfae4e25c4748a9fa3513b3e9f7d/front_end/network/RequestTimingView.js#L154 - earliestRequestTime: number; - timings: CalculatedTimings | null; -} +export type NetworkItem = NetworkEvent; export type NetworkItems = NetworkItem[]; // NOTE: A number will always be present if the property exists, but that number might be -1, which represents no value. diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_container.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_container.tsx new file mode 100644 index 0000000000000..7657ca7f9c64a --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_container.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiLoadingChart } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useEffect } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { getNetworkEvents } from '../../../../../state/actions/network_events'; +import { networkEventsSelector } from '../../../../../state/selectors'; +import { WaterfallChartWrapper } from './waterfall_chart_wrapper'; +import { extractItems } from './data_formatting'; + +export const NO_DATA_TEXT = i18n.translate('xpack.uptime.synthetics.stepDetail.waterfallNoData', { + defaultMessage: 'No waterfall data could be found for this step', +}); + +interface Props { + checkGroup: string; + stepIndex: number; +} + +export const WaterfallChartContainer: React.FC = ({ checkGroup, stepIndex }) => { + const dispatch = useDispatch(); + + useEffect(() => { + if (checkGroup && stepIndex) { + dispatch( + getNetworkEvents({ + checkGroup, + stepIndex, + }) + ); + } + }, [dispatch, stepIndex, checkGroup]); + + const _networkEvents = useSelector(networkEventsSelector); + const networkEvents = _networkEvents[checkGroup ?? '']?.[stepIndex]; + + return ( + <> + {!networkEvents || + (networkEvents.loading && ( + + + + + + ))} + {networkEvents && !networkEvents.loading && networkEvents.events.length === 0 && ( + + + +

{NO_DATA_TEXT}

+
+
+
+ )} + {networkEvents && !networkEvents.loading && networkEvents.events.length > 0 && ( + + )} + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/consumers/synthetics/waterfall_chart_wrapper.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.tsx similarity index 91% rename from x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/consumers/synthetics/waterfall_chart_wrapper.tsx rename to x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.tsx index 434b44a94b79f..b10c3844f3002 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/consumers/synthetics/waterfall_chart_wrapper.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.tsx @@ -13,7 +13,7 @@ import { WaterfallChart, MiddleTruncatedText, RenderItem, -} from '../../../waterfall'; +} from '../../waterfall'; const renderSidebarItem: RenderItem = (item, index) => { const { status } = item; @@ -27,7 +27,7 @@ const renderSidebarItem: RenderItem = (item, index) => { return ( <> - {!isErrorStatusCode(status) ? ( + {!status || !isErrorStatusCode(status) ? ( ) : ( @@ -47,9 +47,12 @@ const renderLegendItem: RenderItem = (item) => { return {item.name}; }; -export const WaterfallChartWrapper = () => { - // TODO: Will be sourced via an API - const [networkData] = useState([]); +interface Props { + data: NetworkItems; +} + +export const WaterfallChartWrapper: React.FC = ({ data }) => { + const [networkData] = useState(data); const { series, domain } = useMemo(() => { return getSeriesAndDomain(networkData); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/constants.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/constants.ts index ac650c5ef0ddd..95ec298e2e349 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/constants.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/constants.ts @@ -10,3 +10,6 @@ export const BAR_HEIGHT = 32; export const MAIN_GROW_SIZE = 8; // Flex grow value export const SIDEBAR_GROW_SIZE = 2; +// Axis height +// NOTE: This isn't a perfect solution - changes in font size etc within charts could change the ideal height here. +export const FIXED_AXIS_HEIGHT = 32; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/sidebar.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/sidebar.tsx index 9ff544fc1946b..c551561d5ad4f 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/sidebar.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/sidebar.tsx @@ -27,7 +27,11 @@ export const Sidebar: React.FC = ({ items, height, render }) => { - + {items.map((item, index) => { return ( diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/styles.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/styles.ts index 25f5e5f8f5cc9..320e415585ca3 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/styles.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/styles.ts @@ -6,9 +6,7 @@ import { EuiPanel, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { euiStyled } from '../../../../../../../observability/public'; - -// NOTE: This isn't a perfect solution - changes in font size etc within charts could change the ideal height here. -const FIXED_AXIS_HEIGHT = 33; +import { FIXED_AXIS_HEIGHT } from './constants'; interface WaterfallChartOuterContainerProps { height?: number; @@ -24,6 +22,7 @@ export const WaterfallChartFixedTopContainer = euiStyled.div` position: sticky; top: 0; z-index: ${(props) => props.theme.eui.euiZLevel4}; + border-bottom: ${(props) => `1px solid ${props.theme.eui.euiColorLightShade}`}; `; export const WaterfallChartFixedTopContainerSidebarCover = euiStyled(EuiPanel)` diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_chart.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_chart.tsx index de4be0ea34b2c..d92e26335a6bd 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_chart.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_chart.tsx @@ -33,7 +33,7 @@ import { WaterfallChartTooltip, } from './styles'; import { WaterfallData } from '../types'; -import { BAR_HEIGHT, MAIN_GROW_SIZE, SIDEBAR_GROW_SIZE } from './constants'; +import { BAR_HEIGHT, MAIN_GROW_SIZE, SIDEBAR_GROW_SIZE, FIXED_AXIS_HEIGHT } from './constants'; import { Sidebar } from './sidebar'; import { Legend } from './legend'; @@ -77,7 +77,8 @@ const getUniqueBars = (data: WaterfallData) => { }, new Set()); }; -const getChartHeight = (data: WaterfallData): number => getUniqueBars(data).size * BAR_HEIGHT; +const getChartHeight = (data: WaterfallData): number => + getUniqueBars(data).size * BAR_HEIGHT + FIXED_AXIS_HEIGHT; export const WaterfallChart = ({ tickFormat, @@ -85,7 +86,7 @@ export const WaterfallChart = ({ barStyleAccessor, renderSidebarItem, renderLegendItem, - maxHeight = 600, + maxHeight = 800, }: WaterfallChartProps) => { const { data, sidebarItems, legendItems } = useWaterfallContext(); @@ -108,10 +109,10 @@ export const WaterfallChart = ({ <> - + {shouldRenderSidebar && ( - + )} @@ -130,10 +131,13 @@ export const WaterfallChart = ({ tickFormat={tickFormat} domain={domain} showGridLines={true} + style={{ + axisLine: { + visible: false, + }, + }} /> - ''} /> - - + {shouldRenderSidebar && ( )} @@ -169,10 +173,13 @@ export const WaterfallChart = ({ tickFormat={tickFormat} domain={domain} showGridLines={true} + style={{ + axisLine: { + visible: false, + }, + }} /> - ''} /> - seconds * 1000; - -// describe('getTimings', () => { -// it('Calculates timings for network events correctly', () => { -// // NOTE: Uses these timings as the file protocol events don't have timing information -// const eventOneTimings = getTimings( -// TEST_DATA[0].synthetics.payload.response.timing!, -// toMillis(TEST_DATA[0].synthetics.payload.start), -// toMillis(TEST_DATA[0].synthetics.payload.end) -// ); -// expect(eventOneTimings).toEqual({ -// blocked: 162.4549999999106, -// connect: -1, -// dns: -1, -// receive: 0.5629999989271255, -// send: 0.5149999999999864, -// ssl: undefined, -// wait: 28.494, -// }); - -// const eventFourTimings = getTimings( -// TEST_DATA[3].synthetics.payload.response.timing!, -// toMillis(TEST_DATA[3].synthetics.payload.start), -// toMillis(TEST_DATA[3].synthetics.payload.end) -// ); -// expect(eventFourTimings).toEqual({ -// blocked: 1.8559999997466803, -// connect: 25.52200000000002, -// dns: 4.683999999999999, -// receive: 0.6780000009983667, -// send: 0.6490000000000009, -// ssl: 130.541, -// wait: 27.245000000000005, -// }); -// }); -// }); - -// describe('getSeriesAndDomain', () => { -// let seriesAndDomain: any; -// let NetworkItems: any; - -// beforeAll(() => { -// NetworkItems = extractItems(TEST_DATA); -// seriesAndDomain = getSeriesAndDomain(NetworkItems); -// }); - -// it('Correctly calculates the domain', () => { -// expect(seriesAndDomain.domain).toEqual({ max: 218.34699999913573, min: 0 }); -// }); - -// it('Correctly calculates the series', () => { -// expect(seriesAndDomain.series).toEqual([ -// { -// config: { colour: '#f3b3a6', tooltipProps: { colour: '#f3b3a6', value: '3.635ms' } }, -// x: 0, -// y: 3.6349999997764826, -// y0: 0, -// }, -// { -// config: { -// colour: '#b9a888', -// tooltipProps: { colour: '#b9a888', value: 'Queued / Blocked: 1.856ms' }, -// }, -// x: 1, -// y: 27.889999999731778, -// y0: 26.0339999999851, -// }, -// { -// config: { colour: '#54b399', tooltipProps: { colour: '#54b399', value: 'DNS: 4.684ms' } }, -// x: 1, -// y: 32.573999999731775, -// y0: 27.889999999731778, -// }, -// { -// config: { -// colour: '#da8b45', -// tooltipProps: { colour: '#da8b45', value: 'Connecting: 25.522ms' }, -// }, -// x: 1, -// y: 58.095999999731795, -// y0: 32.573999999731775, -// }, -// { -// config: { colour: '#edc5a2', tooltipProps: { colour: '#edc5a2', value: 'SSL: 130.541ms' } }, -// x: 1, -// y: 188.63699999973178, -// y0: 58.095999999731795, -// }, -// { -// config: { -// colour: '#d36086', -// tooltipProps: { colour: '#d36086', value: 'Sending request: 0.649ms' }, -// }, -// x: 1, -// y: 189.28599999973179, -// y0: 188.63699999973178, -// }, -// { -// config: { -// colour: '#b0c9e0', -// tooltipProps: { colour: '#b0c9e0', value: 'Waiting (TTFB): 27.245ms' }, -// }, -// x: 1, -// y: 216.5309999997318, -// y0: 189.28599999973179, -// }, -// { -// config: { -// colour: '#ca8eae', -// tooltipProps: { colour: '#ca8eae', value: 'Content downloading: 0.678ms' }, -// }, -// x: 1, -// y: 217.20900000073016, -// y0: 216.5309999997318, -// }, -// { -// config: { -// colour: '#b9a888', -// tooltipProps: { colour: '#b9a888', value: 'Queued / Blocked: 162.455ms' }, -// }, -// x: 2, -// y: 188.77500000020862, -// y0: 26.320000000298023, -// }, -// { -// config: { -// colour: '#d36086', -// tooltipProps: { colour: '#d36086', value: 'Sending request: 0.515ms' }, -// }, -// x: 2, -// y: 189.2900000002086, -// y0: 188.77500000020862, -// }, -// { -// config: { -// colour: '#b0c9e0', -// tooltipProps: { colour: '#b0c9e0', value: 'Waiting (TTFB): 28.494ms' }, -// }, -// x: 2, -// y: 217.7840000002086, -// y0: 189.2900000002086, -// }, -// { -// config: { -// colour: '#9170b8', -// tooltipProps: { colour: '#9170b8', value: 'Content downloading: 0.563ms' }, -// }, -// x: 2, -// y: 218.34699999913573, -// y0: 217.7840000002086, -// }, -// { -// config: { colour: '#9170b8', tooltipProps: { colour: '#9170b8', value: '12.139ms' } }, -// x: 3, -// y: 46.15699999965727, -// y0: 34.01799999922514, -// }, -// { -// config: { colour: '#9170b8', tooltipProps: { colour: '#9170b8', value: '8.453ms' } }, -// x: 4, -// y: 43.506999999284744, -// y0: 35.053999999538064, -// }, -// ]); -// }); -// }); - -describe('Palettes', () => { - it('A colour palette comprising timing and mime type colours is correctly generated', () => { - expect(colourPalette).toEqual({ - blocked: '#b9a888', - connect: '#da8b45', - dns: '#54b399', - font: '#aa6556', - html: '#f3b3a6', - media: '#d6bf57', - other: '#e7664c', - receive: '#54b399', - script: '#9170b8', - send: '#d36086', - ssl: '#edc5a2', - stylesheet: '#ca8eae', - wait: '#b0c9e0', - }); - }); -}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/consumers/synthetics/data_formatting.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/consumers/synthetics/data_formatting.ts deleted file mode 100644 index 9c66ea638c942..0000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/consumers/synthetics/data_formatting.ts +++ /dev/null @@ -1,336 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { euiPaletteColorBlind } from '@elastic/eui'; - -import { - PayloadTimings, - CalculatedTimings, - NetworkItems, - FriendlyTimingLabels, - FriendlyMimetypeLabels, - MimeType, - MimeTypesMap, - Timings, - TIMING_ORDER, - SidebarItems, - LegendItems, -} from './types'; -import { WaterfallData } from '../../../waterfall'; - -const microToMillis = (micro: number): number => (micro === -1 ? -1 : micro * 1000); - -// The timing calculations here are based off several sources: -// https://github.com/ChromeDevTools/devtools-frontend/blob/2fe91adefb2921b4deb2b4b125370ef9ccdb8d1b/front_end/sdk/HARLog.js#L307 -// and -// https://chromium.googlesource.com/chromium/blink.git/+/master/Source/devtools/front_end/sdk/HAREntry.js#131 -// and -// https://github.com/cyrus-and/chrome-har-capturer/blob/master/lib/har.js#L195 -// Order of events: request_start = 0, [proxy], [dns], [connect [ssl]], [send], receive_headers_end - -export const getTimings = ( - timings: PayloadTimings, - requestSentTime: number, - responseReceivedTime: number -): CalculatedTimings => { - if (!timings) return { blocked: -1, dns: -1, connect: -1, send: 0, wait: 0, receive: 0, ssl: -1 }; - - const getLeastNonNegative = (values: number[]) => - values.reduce((best, value) => (value >= 0 && value < best ? value : best), Infinity); - const getOptionalTiming = (_timings: PayloadTimings, key: keyof PayloadTimings) => - _timings[key] >= 0 ? _timings[key] : -1; - - // NOTE: Request sent and request start can differ due to queue times - const requestStartTime = microToMillis(timings.request_time); - - // Queued - const queuedTime = requestSentTime < requestStartTime ? requestStartTime - requestSentTime : -1; - - // Blocked - // "blocked" represents both queued time + blocked/stalled time + proxy time (ie: anything before the request was actually started). - let blocked = queuedTime; - - const blockedStart = getLeastNonNegative([ - timings.dns_start, - timings.connect_start, - timings.send_start, - ]); - - if (blockedStart !== Infinity) { - blocked += blockedStart; - } - - // Proxy - // Proxy is part of blocked, but it can be quirky in that blocked can be -1 even though there are proxy timings. This can happen with - // protocols like Quic. - if (timings.proxy_end !== -1) { - const blockedProxy = timings.proxy_end - timings.proxy_start; - - if (blockedProxy && blockedProxy > blocked) { - blocked = blockedProxy; - } - } - - // DNS - const dnsStart = timings.dns_end >= 0 ? blockedStart : 0; - const dnsEnd = getOptionalTiming(timings, 'dns_end'); - const dns = dnsEnd - dnsStart; - - // SSL - const sslStart = getOptionalTiming(timings, 'ssl_start'); - const sslEnd = getOptionalTiming(timings, 'ssl_end'); - let ssl; - - if (sslStart >= 0 && sslEnd >= 0) { - ssl = timings.ssl_end - timings.ssl_start; - } - - // Connect - let connect = -1; - if (timings.connect_start >= 0) { - connect = timings.send_start - timings.connect_start; - } - - // Send - const send = timings.send_end - timings.send_start; - - // Wait - const wait = timings.receive_headers_end - timings.send_end; - - // Receive - const receive = responseReceivedTime - (requestStartTime + timings.receive_headers_end); - - // SSL connection is a part of the overall connection time - if (connect && ssl) { - connect = connect - ssl; - } - - return { blocked, dns, connect, send, wait, receive, ssl }; -}; - -// TODO: Switch to real API data, and type data as the payload response (if server response isn't preformatted) -export const extractItems = (data: any): NetworkItems => { - const items = data - .map((entry: any) => { - const requestSentTime = microToMillis(entry.synthetics.payload.start); - const responseReceivedTime = microToMillis(entry.synthetics.payload.end); - const requestStartTime = - entry.synthetics.payload.response && entry.synthetics.payload.response.timing - ? microToMillis(entry.synthetics.payload.response.timing.request_time) - : null; - - return { - timestamp: entry['@timestamp'], - method: entry.synthetics.payload.method, - url: entry.synthetics.payload.url, - status: entry.synthetics.payload.status, - mimeType: entry.synthetics.payload?.response?.mime_type, - requestSentTime, - responseReceivedTime, - earliestRequestTime: requestStartTime - ? Math.min(requestSentTime, requestStartTime) - : requestSentTime, - timings: - entry.synthetics.payload.response && entry.synthetics.payload.response.timing - ? getTimings( - entry.synthetics.payload.response.timing, - requestSentTime, - responseReceivedTime - ) - : null, - }; - }) - .sort((a: any, b: any) => { - return a.earliestRequestTime - b.earliestRequestTime; - }); - - return items; -}; - -const formatValueForDisplay = (value: number, points: number = 3) => { - return Number(value).toFixed(points); -}; - -const getColourForMimeType = (mimeType?: string) => { - const key = mimeType && MimeTypesMap[mimeType] ? MimeTypesMap[mimeType] : MimeType.Other; - return colourPalette[key]; -}; - -export const getSeriesAndDomain = (items: NetworkItems) => { - // The earliest point in time a request is sent or started. This will become our notion of "0". - const zeroOffset = items.reduce((acc, item) => { - const { earliestRequestTime } = item; - return earliestRequestTime < acc ? earliestRequestTime : acc; - }, Infinity); - - const series = items.reduce((acc, item, index) => { - const { earliestRequestTime } = item; - - // Entries without timings should be handled differently: - // https://github.com/ChromeDevTools/devtools-frontend/blob/ed2a064ac194bfae4e25c4748a9fa3513b3e9f7d/front_end/network/RequestTimingView.js#L140 - // If there are no concrete timings just plot one block via start and end - if (!item.timings || item.timings === null) { - const duration = item.responseReceivedTime - item.earliestRequestTime; - const colour = getColourForMimeType(item.mimeType); - return [ - ...acc, - { - x: index, - y0: item.earliestRequestTime - zeroOffset, - y: item.responseReceivedTime - zeroOffset, - config: { - colour, - tooltipProps: { - value: `${formatValueForDisplay(duration)}ms`, - colour, - }, - }, - }, - ]; - } - - let currentOffset = earliestRequestTime - zeroOffset; - - TIMING_ORDER.forEach((timing) => { - const value = item.timings![timing]; - const colour = - timing === Timings.Receive ? getColourForMimeType(item.mimeType) : colourPalette[timing]; - if (value && value >= 0) { - const y = currentOffset + value; - - acc.push({ - x: index, - y0: currentOffset, - y, - config: { - colour, - tooltipProps: { - value: `${FriendlyTimingLabels[timing]}: ${formatValueForDisplay( - y - currentOffset - )}ms`, - colour, - }, - }, - }); - currentOffset = y; - } - }); - return acc; - }, []); - - const yValues = series.map((serie) => serie.y); - const domain = { min: 0, max: Math.max(...yValues) }; - return { series, domain }; -}; - -export const getSidebarItems = (items: NetworkItems): SidebarItems => { - return items.map((item) => { - const { url, status, method } = item; - return { url, status, method }; - }); -}; - -export const getLegendItems = (): LegendItems => { - let timingItems: LegendItems = []; - Object.values(Timings).forEach((timing) => { - // The "receive" timing is mapped to a mime type colour, so we don't need to show this in the legend - if (timing === Timings.Receive) { - return; - } - timingItems = [ - ...timingItems, - { name: FriendlyTimingLabels[timing], colour: TIMING_PALETTE[timing] }, - ]; - }); - - let mimeTypeItems: LegendItems = []; - Object.values(MimeType).forEach((mimeType) => { - mimeTypeItems = [ - ...mimeTypeItems, - { name: FriendlyMimetypeLabels[mimeType], colour: MIME_TYPE_PALETTE[mimeType] }, - ]; - }); - return [...timingItems, ...mimeTypeItems]; -}; - -// Timing colour palette -type TimingColourPalette = { - [K in Timings]: string; -}; - -const SAFE_PALETTE = euiPaletteColorBlind({ rotations: 2 }); - -const buildTimingPalette = (): TimingColourPalette => { - const palette = Object.values(Timings).reduce>((acc, value) => { - switch (value) { - case Timings.Blocked: - acc[value] = SAFE_PALETTE[6]; - break; - case Timings.Dns: - acc[value] = SAFE_PALETTE[0]; - break; - case Timings.Connect: - acc[value] = SAFE_PALETTE[7]; - break; - case Timings.Ssl: - acc[value] = SAFE_PALETTE[17]; - break; - case Timings.Send: - acc[value] = SAFE_PALETTE[2]; - break; - case Timings.Wait: - acc[value] = SAFE_PALETTE[11]; - break; - case Timings.Receive: - acc[value] = SAFE_PALETTE[0]; - break; - } - return acc; - }, {}); - - return palette as TimingColourPalette; -}; - -const TIMING_PALETTE = buildTimingPalette(); - -// MimeType colour palette -type MimeTypeColourPalette = { - [K in MimeType]: string; -}; - -const buildMimeTypePalette = (): MimeTypeColourPalette => { - const palette = Object.values(MimeType).reduce>((acc, value) => { - switch (value) { - case MimeType.Html: - acc[value] = SAFE_PALETTE[19]; - break; - case MimeType.Script: - acc[value] = SAFE_PALETTE[3]; - break; - case MimeType.Stylesheet: - acc[value] = SAFE_PALETTE[4]; - break; - case MimeType.Media: - acc[value] = SAFE_PALETTE[5]; - break; - case MimeType.Font: - acc[value] = SAFE_PALETTE[8]; - break; - case MimeType.Other: - acc[value] = SAFE_PALETTE[9]; - break; - } - return acc; - }, {}); - - return palette as MimeTypeColourPalette; -}; - -const MIME_TYPE_PALETTE = buildMimeTypePalette(); - -type ColourPalette = TimingColourPalette & MimeTypeColourPalette; - -export const colourPalette: ColourPalette = { ...TIMING_PALETTE, ...MIME_TYPE_PALETTE }; diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/uptime_alerts_flyout_wrapper.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/uptime_alerts_flyout_wrapper.tsx index 7995cf88df9ba..75cbd43cd0b38 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/uptime_alerts_flyout_wrapper.tsx +++ b/x-pack/plugins/uptime/public/components/overview/alerts/uptime_alerts_flyout_wrapper.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { TriggersAndActionsUIPublicPluginStart } from '../../../../../../plugins/triggers_actions_ui/public'; @@ -24,19 +24,20 @@ export const UptimeAlertsFlyoutWrapperComponent = ({ setAlertFlyoutVisibility, }: Props) => { const { triggersActionsUi } = useKibana().services; - + const onCloseAlertFlyout = useCallback(() => setAlertFlyoutVisibility(false), [ + setAlertFlyoutVisibility, + ]); const AddAlertFlyout = useMemo( () => triggersActionsUi.getAddAlertFlyout({ consumer: 'uptime', - addFlyoutVisible: alertFlyoutVisible, - setAddFlyoutVisibility: setAlertFlyoutVisibility, + onClose: onCloseAlertFlyout, alertTypeId, canChangeTrigger: !alertTypeId, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [alertFlyoutVisible, alertTypeId] + [onCloseAlertFlyout, alertTypeId] ); - return <>{AddAlertFlyout}; + return <>{alertFlyoutVisible && AddAlertFlyout}; }; diff --git a/x-pack/plugins/uptime/public/components/overview/empty_state/__tests__/__snapshots__/empty_state.test.tsx.snap b/x-pack/plugins/uptime/public/components/overview/empty_state/__tests__/__snapshots__/empty_state.test.tsx.snap index 89433f8bc57c4..b959d822ac73e 100644 --- a/x-pack/plugins/uptime/public/components/overview/empty_state/__tests__/__snapshots__/empty_state.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/overview/empty_state/__tests__/__snapshots__/empty_state.test.tsx.snap @@ -379,6 +379,7 @@ exports[`EmptyState component does not render empty state with appropriate base element="a" fill={true} href="/app/home#/tutorial/uptimeMonitors" + isDisabled={false} rel="noreferrer" > { + useInitApp(); + const { checkGroupId, stepIndex } = useParams<{ checkGroupId: string; stepIndex: string }>(); + useTrackPageview({ app: 'uptime', path: 'stepDetail' }); + useTrackPageview({ app: 'uptime', path: 'stepDetail', delay: 15000 }); + + return ; +}; diff --git a/x-pack/plugins/uptime/public/routes.tsx b/x-pack/plugins/uptime/public/routes.tsx index 9b54c52cc674c..65526f9bca4fc 100644 --- a/x-pack/plugins/uptime/public/routes.tsx +++ b/x-pack/plugins/uptime/public/routes.tsx @@ -12,8 +12,9 @@ import { MONITOR_ROUTE, OVERVIEW_ROUTE, SETTINGS_ROUTE, + STEP_DETAIL_ROUTE, } from '../common/constants'; -import { MonitorPage, NotFoundPage, SettingsPage } from './pages'; +import { MonitorPage, StepDetailPage, NotFoundPage, SettingsPage } from './pages'; import { CertificatesPage } from './pages/certificates'; import { UptimePage, useUptimeTelemetry } from './hooks'; import { PageHeader } from './components/common/header/page_header'; @@ -50,6 +51,13 @@ const Routes: RouteProps[] = [ dataTestSubj: 'uptimeCertificatesPage', telemetryId: UptimePage.Certificates, }, + { + title: baseTitle, + path: STEP_DETAIL_ROUTE, + component: StepDetailPage, + dataTestSubj: 'uptimeStepDetailPage', + telemetryId: UptimePage.StepDetail, + }, { title: baseTitle, path: OVERVIEW_ROUTE, diff --git a/x-pack/plugins/uptime/public/state/actions/network_events.ts b/x-pack/plugins/uptime/public/state/actions/network_events.ts new file mode 100644 index 0000000000000..e3564689fcd48 --- /dev/null +++ b/x-pack/plugins/uptime/public/state/actions/network_events.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createAction } from 'redux-actions'; +import { SyntheticsNetworkEventsApiResponse } from '../../../common/runtime_types'; + +export interface FetchNetworkEventsParams { + checkGroup: string; + stepIndex: number; +} + +export interface FetchNetworkEventsFailPayload { + checkGroup: string; + stepIndex: number; + error: Error; +} + +export const getNetworkEvents = createAction('GET_NETWORK_EVENTS'); +export const getNetworkEventsSuccess = createAction< + Pick & SyntheticsNetworkEventsApiResponse +>('GET_NETWORK_EVENTS_SUCCESS'); +export const getNetworkEventsFail = createAction( + 'GET_NETWORK_EVENTS_FAIL' +); diff --git a/x-pack/plugins/uptime/public/state/api/network_events.ts b/x-pack/plugins/uptime/public/state/api/network_events.ts new file mode 100644 index 0000000000000..a4eceb4812d28 --- /dev/null +++ b/x-pack/plugins/uptime/public/state/api/network_events.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { apiService } from './utils'; +import { FetchNetworkEventsParams } from '../actions/network_events'; +import { + SyntheticsNetworkEventsApiResponse, + SyntheticsNetworkEventsApiResponseType, +} from '../../../common/runtime_types'; + +export async function fetchNetworkEvents( + params: FetchNetworkEventsParams +): Promise { + return (await apiService.get( + `/api/uptime/network_events`, + { + checkGroup: params.checkGroup, + stepIndex: params.stepIndex, + }, + SyntheticsNetworkEventsApiResponseType + )) as SyntheticsNetworkEventsApiResponse; +} diff --git a/x-pack/plugins/uptime/public/state/effects/index.ts b/x-pack/plugins/uptime/public/state/effects/index.ts index 4951f2102c8a7..3c75e75871882 100644 --- a/x-pack/plugins/uptime/public/state/effects/index.ts +++ b/x-pack/plugins/uptime/public/state/effects/index.ts @@ -19,6 +19,7 @@ import { fetchIndexStatusEffect } from './index_status'; import { fetchCertificatesEffect } from '../certificates/certificates'; import { fetchAlertsEffect } from '../alerts/alerts'; import { fetchJourneyStepsEffect } from './journey'; +import { fetchNetworkEventsEffect } from './network_events'; export function* rootEffect() { yield fork(fetchMonitorDetailsEffect); @@ -37,4 +38,5 @@ export function* rootEffect() { yield fork(fetchCertificatesEffect); yield fork(fetchAlertsEffect); yield fork(fetchJourneyStepsEffect); + yield fork(fetchNetworkEventsEffect); } diff --git a/x-pack/plugins/uptime/public/state/effects/network_events.ts b/x-pack/plugins/uptime/public/state/effects/network_events.ts new file mode 100644 index 0000000000000..95d24fbe37ae2 --- /dev/null +++ b/x-pack/plugins/uptime/public/state/effects/network_events.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Action } from 'redux-actions'; +import { call, put, takeLatest } from 'redux-saga/effects'; +import { + getNetworkEvents, + getNetworkEventsSuccess, + getNetworkEventsFail, + FetchNetworkEventsParams, +} from '../actions/network_events'; +import { fetchNetworkEvents } from '../api/network_events'; + +export function* fetchNetworkEventsEffect() { + yield takeLatest(getNetworkEvents, function* (action: Action) { + try { + const response = yield call(fetchNetworkEvents, action.payload); + + yield put( + getNetworkEventsSuccess({ + checkGroup: action.payload.checkGroup, + stepIndex: action.payload.stepIndex, + ...response, + }) + ); + } catch (e) { + yield put( + getNetworkEventsFail({ + checkGroup: action.payload.checkGroup, + stepIndex: action.payload.stepIndex, + error: e, + }) + ); + } + }); +} diff --git a/x-pack/plugins/uptime/public/state/reducers/index.ts b/x-pack/plugins/uptime/public/state/reducers/index.ts index c0bab124d5f9d..661b637802707 100644 --- a/x-pack/plugins/uptime/public/state/reducers/index.ts +++ b/x-pack/plugins/uptime/public/state/reducers/index.ts @@ -22,6 +22,7 @@ import { certificatesReducer } from '../certificates/certificates'; import { selectedFiltersReducer } from './selected_filters'; import { alertsReducer } from '../alerts/alerts'; import { journeyReducer } from './journey'; +import { networkEventsReducer } from './network_events'; export const rootReducer = combineReducers({ monitor: monitorReducer, @@ -41,4 +42,5 @@ export const rootReducer = combineReducers({ selectedFilters: selectedFiltersReducer, alerts: alertsReducer, journeys: journeyReducer, + networkEvents: networkEventsReducer, }); diff --git a/x-pack/plugins/uptime/public/state/reducers/journey.ts b/x-pack/plugins/uptime/public/state/reducers/journey.ts index e1c3dc808f1bf..133a5d1edb2c2 100644 --- a/x-pack/plugins/uptime/public/state/reducers/journey.ts +++ b/x-pack/plugins/uptime/public/state/reducers/journey.ts @@ -18,6 +18,7 @@ import { export interface JourneyState { checkGroup: string; steps: Ping[]; + details?: SyntheticsJourneyApiResponse['details']; loading: boolean; error?: Error; } @@ -56,13 +57,14 @@ export const journeyReducer = handleActions( [String(getJourneyStepsSuccess)]: ( state: JourneyKVP, - { payload: { checkGroup, steps } }: Action + { payload: { checkGroup, steps, details } }: Action ) => ({ ...state, [checkGroup]: { loading: false, checkGroup, steps, + details, }, }), diff --git a/x-pack/plugins/uptime/public/state/reducers/network_events.ts b/x-pack/plugins/uptime/public/state/reducers/network_events.ts new file mode 100644 index 0000000000000..44a23b0fa53d7 --- /dev/null +++ b/x-pack/plugins/uptime/public/state/reducers/network_events.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { handleActions, Action } from 'redux-actions'; +import { NetworkEvent, SyntheticsNetworkEventsApiResponse } from '../../../common/runtime_types'; +import { + FetchNetworkEventsParams, + FetchNetworkEventsFailPayload, + getNetworkEvents, + getNetworkEventsFail, + getNetworkEventsSuccess, +} from '../actions/network_events'; + +export interface NetworkEventsState { + [checkGroup: string]: { + [stepIndex: number]: { + events: NetworkEvent[]; + loading: boolean; + error?: Error; + }; + }; +} + +const initialState: NetworkEventsState = {}; + +type Payload = FetchNetworkEventsParams & + SyntheticsNetworkEventsApiResponse & + FetchNetworkEventsFailPayload & + string[]; + +export const networkEventsReducer = handleActions( + { + [String(getNetworkEvents)]: ( + state: NetworkEventsState, + { payload: { checkGroup, stepIndex } }: Action + ) => ({ + ...state, + [checkGroup]: state[checkGroup] + ? { + [stepIndex]: state[checkGroup][stepIndex] + ? { + ...state[checkGroup][stepIndex], + loading: true, + events: [], + } + : { + loading: true, + events: [], + }, + } + : { + [stepIndex]: { + loading: true, + events: [], + }, + }, + }), + + [String(getNetworkEventsSuccess)]: ( + state: NetworkEventsState, + { + payload: { events, checkGroup, stepIndex }, + }: Action + ) => { + return { + ...state, + [checkGroup]: state[checkGroup] + ? { + [stepIndex]: state[checkGroup][stepIndex] + ? { + ...state[checkGroup][stepIndex], + loading: false, + events, + } + : { + loading: false, + events, + }, + } + : { + [stepIndex]: { + loading: false, + events, + }, + }, + }; + }, + + [String(getNetworkEventsFail)]: ( + state: NetworkEventsState, + { payload: { checkGroup, stepIndex, error } }: Action + ) => ({ + ...state, + [checkGroup]: state[checkGroup] + ? { + [stepIndex]: state[checkGroup][stepIndex] + ? { + ...state[checkGroup][stepIndex], + loading: false, + events: [], + error, + } + : { + loading: false, + events: [], + error, + }, + } + : { + [stepIndex]: { + loading: false, + events: [], + error, + }, + }, + }), + }, + initialState +); diff --git a/x-pack/plugins/uptime/public/state/selectors/__tests__/index.test.ts b/x-pack/plugins/uptime/public/state/selectors/__tests__/index.test.ts index f1a68318be863..64410b860b197 100644 --- a/x-pack/plugins/uptime/public/state/selectors/__tests__/index.test.ts +++ b/x-pack/plugins/uptime/public/state/selectors/__tests__/index.test.ts @@ -116,6 +116,7 @@ describe('state selectors', () => { anomalyAlertDeletion: { data: null, loading: false }, }, journeys: {}, + networkEvents: {}, }; it('selects base path from state', () => { diff --git a/x-pack/plugins/uptime/public/state/selectors/index.ts b/x-pack/plugins/uptime/public/state/selectors/index.ts index 6bfe67468aae5..eef53e1100029 100644 --- a/x-pack/plugins/uptime/public/state/selectors/index.ts +++ b/x-pack/plugins/uptime/public/state/selectors/index.ts @@ -96,3 +96,5 @@ export const selectedFiltersSelector = ({ selectedFilters }: AppState) => select export const monitorIdSelector = ({ ui: { monitorId } }: AppState) => monitorId; export const journeySelector = ({ journeys }: AppState) => journeys; + +export const networkEventsSelector = ({ networkEvents }: AppState) => networkEvents; diff --git a/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts b/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts index 022ec48bad1d9..f5e79ad43336b 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts @@ -82,6 +82,7 @@ export const durationAnomalyAlertFactory: UptimeAlertTypeFactory = (_server, _li context: [], state: [...durationAnomalyTranslations.actionVariables, ...commonStateTranslations], }, + minimumLicenseRequired: 'basic', async executor({ options, uptimeEsClient, savedObjectsClient, dynamicSettings }) { const { services: { alertInstanceFactory }, diff --git a/x-pack/plugins/uptime/server/lib/alerts/status_check.ts b/x-pack/plugins/uptime/server/lib/alerts/status_check.ts index 3e45ce302bf87..56ca7a85784c5 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/status_check.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/status_check.ts @@ -255,6 +255,7 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = (_server, libs) = ], state: [...commonMonitorStateI18, ...commonStateTranslations], }, + minimumLicenseRequired: 'basic', async executor({ options: { params: rawParams, diff --git a/x-pack/plugins/uptime/server/lib/alerts/tls.ts b/x-pack/plugins/uptime/server/lib/alerts/tls.ts index 41a5101716122..b6501f7d92059 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/tls.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/tls.ts @@ -100,6 +100,7 @@ export const tlsAlertFactory: UptimeAlertTypeFactory = (_server, libs) => context: [], state: [...tlsTranslations.actionVariables, ...commonStateTranslations], }, + minimumLicenseRequired: 'basic', async executor({ options, dynamicSettings, uptimeEsClient }) { const { services: { alertInstanceFactory }, diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_network_events.test.ts b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_network_events.test.ts new file mode 100644 index 0000000000000..bb88911eedfb0 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_network_events.test.ts @@ -0,0 +1,251 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getUptimeESMockClient } from './helper'; +import { getNetworkEvents } from '../get_network_events'; + +describe('getNetworkEvents', () => { + let mockHits: any; + + beforeEach(() => { + mockHits = [ + { + _index: 'heartbeat-2020.12.14', + _id: 'YMfcYHYBOm8nKLizQt1o', + _score: null, + _source: { + '@timestamp': '2020-12-14T10:46:39.183Z', + synthetics: { + step: { + name: 'Click next link', + index: 2, + }, + journey: { + name: 'inline', + id: 'inline', + }, + type: 'journey/network_info', + package_version: '0.0.1-alpha.8', + payload: { + load_end_time: 3287.298251, + response_received_time: 3287.299074, + method: 'GET', + step: { + index: 2, + name: 'Click next link', + }, + status: 200, + type: 'Image', + request_sent_time: 3287.154973, + url: 'www.test.com', + request: { + initial_priority: 'Low', + referrer_policy: 'no-referrer-when-downgrade', + url: 'www.test.com', + method: 'GET', + headers: { + referer: 'www.test.com', + user_agent: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/88.0.4324.0 Safari/537.36', + }, + mixed_content_type: 'none', + }, + response: { + from_service_worker: false, + security_details: { + protocol: 'TLS 1.2', + key_exchange: 'ECDHE_RSA', + valid_to: 1638230399, + certificate_transparency_compliance: 'unknown', + cipher: 'AES_128_GCM', + issuer: 'DigiCert TLS RSA SHA256 2020 CA1', + subject_name: 'syndication.twitter.com', + valid_from: 1606694400, + signed_certificate_timestamp_list: [], + key_exchange_group: 'P-256', + san_list: [ + 'syndication.twitter.com', + 'syndication.twimg.com', + 'cdn.syndication.twitter.com', + 'cdn.syndication.twimg.com', + 'syndication-o.twitter.com', + 'syndication-o.twimg.com', + ], + certificate_id: 0, + }, + security_state: 'secure', + connection_reused: true, + remote_port: 443, + timing: { + ssl_start: -1, + send_start: 0.214, + ssl_end: -1, + connect_start: -1, + connect_end: -1, + send_end: 0.402, + dns_start: -1, + request_time: 3287.155502, + push_end: 0, + worker_fetch_start: -1, + worker_ready: -1, + worker_start: -1, + proxy_end: -1, + push_start: 0, + worker_respond_with_settled: -1, + proxy_start: -1, + dns_end: -1, + receive_headers_end: 142.215, + }, + connection_id: 852, + remote_i_p_address: '104.244.42.200', + encoded_data_length: 337, + response_time: 1.60794279932414e12, + from_prefetch_cache: false, + mime_type: 'image/gif', + from_disk_cache: false, + url: 'www.test.com', + protocol: 'h2', + headers: { + x_frame_options: 'SAMEORIGIN', + cache_control: 'no-cache, no-store, must-revalidate, pre-check=0, post-check=0', + strict_transport_security: 'max-age=631138519', + x_twitter_response_tags: 'BouncerCompliant', + content_type: 'image/gif;charset=utf-8', + expires: 'Tue, 31 Mar 1981 05:00:00 GMT', + date: 'Mon, 14 Dec 2020 10:46:39 GMT', + x_transaction: '008fff3d00a1e64c', + x_connection_hash: 'cb6fe99b8676f4e4b827cc3e6512c90d', + last_modified: 'Mon, 14 Dec 2020 10:46:39 GMT', + x_content_type_options: 'nosniff', + content_encoding: 'gzip', + x_xss_protection: '0', + server: 'tsa_f', + x_response_time: '108', + pragma: 'no-cache', + content_length: '65', + status: '200 OK', + }, + status_text: '', + status: 200, + }, + timings: { + proxy: -1, + connect: -1, + receive: 0.5340000002433953, + blocked: 0.21400000014182297, + ssl: -1, + send: 0.18799999998009298, + total: 143.27800000000934, + queueing: 0.5289999999149586, + wait: 141.81299999972907, + dns: -1, + }, + is_navigation_request: false, + timestamp: 1607942799183375, + }, + }, + }, + }, + ]; + }); + + it('Uses the correct query', async () => { + const { uptimeEsClient, esClient } = getUptimeESMockClient(); + + esClient.search.mockResolvedValueOnce({ + body: { + hits: { + hits: mockHits, + }, + }, + } as any); + + await getNetworkEvents({ + uptimeEsClient, + checkGroup: 'my-fake-group', + stepIndex: '1', + }); + + expect(esClient.search.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "body": Object { + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "synthetics.type": "journey/network_info", + }, + }, + Object { + "term": Object { + "monitor.check_group": "my-fake-group", + }, + }, + Object { + "term": Object { + "synthetics.step.index": 1, + }, + }, + ], + }, + }, + "size": 1000, + }, + "index": "heartbeat-8*", + }, + ], + ] + `); + }); + + it('Returns the correct result', async () => { + const { esClient, uptimeEsClient } = getUptimeESMockClient(); + + esClient.search.mockResolvedValueOnce({ + body: { + hits: { + hits: mockHits, + }, + }, + } as any); + + const result = await getNetworkEvents({ + uptimeEsClient, + checkGroup: 'my-fake-group', + stepIndex: '1', + }); + + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "loadEndTime": 3287298.251, + "method": "GET", + "mimeType": "image/gif", + "requestSentTime": 3287154.973, + "requestStartTime": 3287155.502, + "status": 200, + "timestamp": "2020-12-14T10:46:39.183Z", + "timings": Object { + "blocked": 0.21400000014182297, + "connect": -1, + "dns": -1, + "proxy": -1, + "queueing": 0.5289999999149586, + "receive": 0.5340000002433953, + "send": 0.18799999998009298, + "ssl": -1, + "total": 143.27800000000934, + "wait": 141.81299999972907, + }, + "url": "www.test.com", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/uptime/server/lib/requests/get_journey_details.ts b/x-pack/plugins/uptime/server/lib/requests/get_journey_details.ts new file mode 100644 index 0000000000000..ef11b00604490 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/requests/get_journey_details.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { UMElasticsearchQueryFn } from '../adapters/framework'; +import { SyntheticsJourneyApiResponse } from '../../../common/runtime_types'; + +interface GetJourneyDetails { + checkGroup: string; +} + +export const getJourneyDetails: UMElasticsearchQueryFn< + GetJourneyDetails, + SyntheticsJourneyApiResponse['details'] +> = async ({ uptimeEsClient, checkGroup }) => { + const baseParams = { + query: { + bool: { + filter: [ + { + term: { + 'monitor.check_group': checkGroup, + }, + }, + { + term: { + 'synthetics.type': 'journey/end', + }, + }, + ], + }, + }, + _source: ['@timestamp', 'monitor.id'], + size: 1, + }; + + const { body: thisJourney } = await uptimeEsClient.search({ body: baseParams }); + + if (thisJourney?.hits?.hits.length > 0) { + const thisJourneySource: any = thisJourney.hits.hits[0]._source; + + const baseSiblingParams = { + query: { + bool: { + filter: [ + { + term: { + 'monitor.id': thisJourneySource.monitor.id, + }, + }, + { + term: { + 'synthetics.type': 'journey/end', + }, + }, + ], + }, + }, + _source: ['@timestamp', 'monitor.check_group'], + size: 1, + }; + + const previousParams = { + ...baseSiblingParams, + query: { + bool: { + filter: [ + ...baseSiblingParams.query.bool.filter, + { + range: { + '@timestamp': { + lt: thisJourneySource['@timestamp'], + }, + }, + }, + ], + }, + }, + sort: [{ '@timestamp': { order: 'desc' } }], + }; + + const nextParams = { + ...baseSiblingParams, + query: { + bool: { + filter: [ + ...baseSiblingParams.query.bool.filter, + { + range: { + '@timestamp': { + gt: thisJourneySource['@timestamp'], + }, + }, + }, + ], + }, + }, + sort: [{ '@timestamp': { order: 'asc' } }], + }; + + const { body: previousJourneyResult } = await uptimeEsClient.search({ body: previousParams }); + const { body: nextJourneyResult } = await uptimeEsClient.search({ body: nextParams }); + const previousJourney: any = + previousJourneyResult?.hits?.hits.length > 0 ? previousJourneyResult?.hits?.hits[0] : null; + const nextJourney: any = + nextJourneyResult?.hits?.hits.length > 0 ? nextJourneyResult?.hits?.hits[0] : null; + return { + timestamp: thisJourneySource['@timestamp'], + previous: previousJourney + ? { + checkGroup: previousJourney._source.monitor.check_group, + timestamp: previousJourney._source['@timestamp'], + } + : undefined, + next: nextJourney + ? { + checkGroup: nextJourney._source.monitor.check_group, + timestamp: nextJourney._source['@timestamp'], + } + : undefined, + } as SyntheticsJourneyApiResponse['details']; + } else { + return null; + } +}; diff --git a/x-pack/plugins/uptime/server/lib/requests/get_network_events.ts b/x-pack/plugins/uptime/server/lib/requests/get_network_events.ts new file mode 100644 index 0000000000000..1353175a8f94d --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/requests/get_network_events.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { UMElasticsearchQueryFn } from '../adapters/framework'; +import { NetworkEvent } from '../../../common/runtime_types'; + +interface GetNetworkEventsParams { + checkGroup: string; + stepIndex: string; +} + +export const getNetworkEvents: UMElasticsearchQueryFn< + GetNetworkEventsParams, + NetworkEvent[] +> = async ({ uptimeEsClient, checkGroup, stepIndex }) => { + const params = { + query: { + bool: { + filter: [ + { term: { 'synthetics.type': 'journey/network_info' } }, + { term: { 'monitor.check_group': checkGroup } }, + { term: { 'synthetics.step.index': Number(stepIndex) } }, + ], + }, + }, + // NOTE: This limit may need tweaking in the future. Users can technically perform multiple + // navigations within one step, and may push up against this limit, however this manner + // of usage isn't advised. + size: 1000, + }; + + const { body: result } = await uptimeEsClient.search({ body: params }); + + const microToMillis = (micro: number): number => (micro === -1 ? -1 : micro * 1000); + + return result.hits.hits.map((event: any) => { + const requestSentTime = microToMillis(event._source.synthetics.payload.request_sent_time); + const loadEndTime = microToMillis(event._source.synthetics.payload.load_end_time); + const requestStartTime = + event._source.synthetics.payload.response && event._source.synthetics.payload.response.timing + ? microToMillis(event._source.synthetics.payload.response.timing.request_time) + : undefined; + + return { + timestamp: event._source['@timestamp'], + method: event._source.synthetics.payload?.method, + url: event._source.synthetics.payload?.url, + status: event._source.synthetics.payload?.status, + mimeType: event._source.synthetics.payload?.response?.mime_type, + requestSentTime, + requestStartTime, + loadEndTime, + timings: event._source.synthetics.payload.timings, + }; + }); +}; diff --git a/x-pack/plugins/uptime/server/lib/requests/index.ts b/x-pack/plugins/uptime/server/lib/requests/index.ts index fd7e5f6041719..34137fe400b00 100644 --- a/x-pack/plugins/uptime/server/lib/requests/index.ts +++ b/x-pack/plugins/uptime/server/lib/requests/index.ts @@ -20,6 +20,8 @@ import { getSnapshotCount } from './get_snapshot_counts'; import { getIndexStatus } from './get_index_status'; import { getJourneySteps } from './get_journey_steps'; import { getJourneyScreenshot } from './get_journey_screenshot'; +import { getJourneyDetails } from './get_journey_details'; +import { getNetworkEvents } from './get_network_events'; import { getJourneyFailedSteps } from './get_journey_failed_steps'; export const requests = { @@ -40,6 +42,8 @@ export const requests = { getJourneySteps, getJourneyFailedSteps, getJourneyScreenshot, + getJourneyDetails, + getNetworkEvents, }; export type UptimeRequests = typeof requests; diff --git a/x-pack/plugins/uptime/server/rest_api/index.ts b/x-pack/plugins/uptime/server/rest_api/index.ts index a2475792edfbe..4db2da541079c 100644 --- a/x-pack/plugins/uptime/server/rest_api/index.ts +++ b/x-pack/plugins/uptime/server/rest_api/index.ts @@ -24,6 +24,7 @@ import { } from './monitors'; import { createGetMonitorDurationRoute } from './monitors/monitors_durations'; import { createGetIndexPatternRoute, createGetIndexStatusRoute } from './index_state'; +import { createNetworkEventsRoute } from './network_events'; import { createJourneyFailedStepsRoute } from './pings/journeys'; export * from './types'; @@ -48,5 +49,6 @@ export const restApiRoutes: UMRestApiRouteFactory[] = [ createGetMonitorDurationRoute, createJourneyRoute, createJourneyScreenshotRoute, + createNetworkEventsRoute, createJourneyFailedStepsRoute, ]; diff --git a/x-pack/plugins/uptime/server/rest_api/network_events/get_network_events.ts b/x-pack/plugins/uptime/server/rest_api/network_events/get_network_events.ts new file mode 100644 index 0000000000000..f24b319baff00 --- /dev/null +++ b/x-pack/plugins/uptime/server/rest_api/network_events/get_network_events.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { UMServerLibs } from '../../lib/lib'; +import { UMRestApiRouteFactory } from '../types'; + +export const createNetworkEventsRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ + method: 'GET', + path: '/api/uptime/network_events', + validate: { + query: schema.object({ + checkGroup: schema.string(), + stepIndex: schema.number(), + }), + }, + handler: async ({ uptimeEsClient, request }): Promise => { + const { checkGroup, stepIndex } = request.query; + + const result = await libs.requests.getNetworkEvents({ + uptimeEsClient, + checkGroup, + stepIndex, + }); + + return { + events: result, + }; + }, +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/constants.ts b/x-pack/plugins/uptime/server/rest_api/network_events/index.ts similarity index 75% rename from x-pack/plugins/actions/server/builtin_action_types/case/constants.ts rename to x-pack/plugins/uptime/server/rest_api/network_events/index.ts index 1f2bc7f5e8e53..3f3c1afe06f99 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/case/constants.ts +++ b/x-pack/plugins/uptime/server/rest_api/network_events/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export const SUPPORTED_SOURCE_FIELDS = ['title', 'comments', 'description']; +export { createNetworkEventsRoute } from './get_network_events'; diff --git a/x-pack/plugins/uptime/server/rest_api/pings/journeys.ts b/x-pack/plugins/uptime/server/rest_api/pings/journeys.ts index 8ebd4b4609c75..b2559ee8d7054 100644 --- a/x-pack/plugins/uptime/server/rest_api/pings/journeys.ts +++ b/x-pack/plugins/uptime/server/rest_api/pings/journeys.ts @@ -24,9 +24,15 @@ export const createJourneyRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => checkGroup, }); + const details = await libs.requests.getJourneyDetails({ + uptimeEsClient, + checkGroup, + }); + return { checkGroup, steps: result, + details, }; }, }); diff --git a/x-pack/scripts/jest.js b/x-pack/scripts/jest.js index 68cfcf082f818..aca7e558301df 100644 --- a/x-pack/scripts/jest.js +++ b/x-pack/scripts/jest.js @@ -4,15 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -if (process.argv.indexOf('--config') === -1) { - // append correct jest.config if none is provided - const configPath = require('path').resolve(__dirname, '../jest.config.js'); - process.argv.push('--config', configPath); - console.log('Running Jest with --config', configPath); -} - -if (process.env.NODE_ENV == null) { - process.env.NODE_ENV = 'test'; -} - -require('jest').run(); +require('@kbn/test').runJest(); diff --git a/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/jira.ts b/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/jira.ts index 025fd558ee1ca..a208d0ab22d62 100644 --- a/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/jira.ts +++ b/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/jira.ts @@ -11,26 +11,6 @@ import { ExternalServiceSimulator, } from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; -// node ../scripts/functional_test_runner.js --grep "Actions.servicenddd" --config=test/alerting_api_integration/security_and_spaces/config.ts - -const mapping = [ - { - source: 'title', - target: 'summary', - actionType: 'nothing', - }, - { - source: 'description', - target: 'description', - actionType: 'nothing', - }, - { - source: 'comments', - target: 'comments', - actionType: 'nothing', - }, -]; - // eslint-disable-next-line import/no-default-export export default function jiraTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -38,21 +18,20 @@ export default function jiraTest({ getService }: FtrProviderContext) { const mockJira = { config: { apiUrl: 'www.jiraisinkibanaactions.com', - incidentConfiguration: { mapping: [...mapping] }, - isCaseOwned: true, }, secrets: { email: 'elastic', apiToken: 'changeme', }, params: { - savedObjectId: '123', - title: 'a title', - description: 'a description', - labels: ['kibana'], - issueType: '10006', - priority: 'High', - externalId: null, + incident: { + summary: 'a title', + description: 'a description', + labels: ['kibana'], + issueType: '10006', + priority: 'High', + externalId: null, + }, comments: [ { commentId: '456', @@ -81,8 +60,6 @@ export default function jiraTest({ getService }: FtrProviderContext) { config: { apiUrl: jiraSimulatorURL, projectKey: 'CK', - incidentConfiguration: { ...mockJira.config.incidentConfiguration }, - isCaseOwned: true, }, secrets: mockJira.secrets, }) diff --git a/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/resilient.ts b/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/resilient.ts index 576ed4bbc5dfe..7576d4ac4c28f 100644 --- a/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/resilient.ts +++ b/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/resilient.ts @@ -11,24 +11,6 @@ import { ExternalServiceSimulator, } from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; -const mapping = [ - { - source: 'title', - target: 'description', - actionType: 'nothing', - }, - { - source: 'description', - target: 'short_description', - actionType: 'nothing', - }, - { - source: 'comments', - target: 'comments', - actionType: 'nothing', - }, -]; - // eslint-disable-next-line import/no-default-export export default function resilientTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -37,19 +19,18 @@ export default function resilientTest({ getService }: FtrProviderContext) { config: { apiUrl: 'www.resilientisinkibanaactions.com', orgId: '201', - incidentConfiguration: { mapping: [...mapping] }, - isCaseOwned: true, }, secrets: { apiKeyId: 'elastic', apiKeySecret: 'changeme', }, params: { - savedObjectId: '123', - title: 'a title', - description: 'a description', - incidentTypes: [1001], - severityCode: 'High', + incident: { + name: 'a title', + description: 'a description', + incidentTypes: [1001], + severityCode: 'High', + }, comments: [ { commentId: '456', @@ -77,8 +58,6 @@ export default function resilientTest({ getService }: FtrProviderContext) { actionTypeId: '.resilient', config: { apiUrl: resilientSimulatorURL, - incidentConfiguration: { ...mockResilient.config.incidentConfiguration }, - isCaseOwned: true, }, secrets: mockResilient.secrets, }) diff --git a/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/servicenow.ts b/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/servicenow.ts index a451edea76d83..a2c2fffed4ea0 100644 --- a/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/servicenow.ts +++ b/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/servicenow.ts @@ -11,26 +11,6 @@ import { ExternalServiceSimulator, } from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; -// node ../scripts/functional_test_runner.js --grep "Actions.servicenddd" --config=test/alerting_api_integration/security_and_spaces/config.ts - -const mapping = [ - { - source: 'title', - target: 'description', - actionType: 'nothing', - }, - { - source: 'description', - target: 'short_description', - actionType: 'nothing', - }, - { - source: 'comments', - target: 'comments', - actionType: 'nothing', - }, -]; - // eslint-disable-next-line import/no-default-export export default function servicenowTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -38,21 +18,19 @@ export default function servicenowTest({ getService }: FtrProviderContext) { const mockServiceNow = { config: { apiUrl: 'www.servicenowisinkibanaactions.com', - incidentConfiguration: { mapping: [...mapping] }, - isCaseOwned: true, }, secrets: { password: 'elastic', username: 'changeme', }, params: { - savedObjectId: '123', - title: 'a title', - description: 'a description', - comment: 'test-alert comment', - severity: '1', - urgency: '2', - impact: '1', + incident: { + short_description: 'a title', + description: 'a description', + severity: '1', + urgency: '2', + impact: '1', + }, comments: [ { commentId: '456', @@ -80,8 +58,6 @@ export default function servicenowTest({ getService }: FtrProviderContext) { actionTypeId: '.servicenow', config: { apiUrl: servicenowSimulatorURL, - incidentConfiguration: { ...mockServiceNow.config.incidentConfiguration }, - isCaseOwned: true, }, secrets: mockServiceNow.secrets, }) diff --git a/x-pack/test/alerting_api_integration/basic/tests/alerts/basic_noop_alert_type.ts b/x-pack/test/alerting_api_integration/basic/tests/alerts/basic_noop_alert_type.ts new file mode 100644 index 0000000000000..f6b0ef2a773f1 --- /dev/null +++ b/x-pack/test/alerting_api_integration/basic/tests/alerts/basic_noop_alert_type.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getTestAlertData } from '../../../common/lib'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function basicAlertTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('basic alert', () => { + it('should return 200 when creating a basic license alert', async () => { + await supertest + .post(`/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/basic/tests/alerts/gold_noop_alert_type.ts b/x-pack/test/alerting_api_integration/basic/tests/alerts/gold_noop_alert_type.ts new file mode 100644 index 0000000000000..3ba9d43cdedf0 --- /dev/null +++ b/x-pack/test/alerting_api_integration/basic/tests/alerts/gold_noop_alert_type.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getTestAlertData } from '../../../common/lib'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function emailTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('create gold noop alert', () => { + it('should return 403 when creating an gold alert', async () => { + await supertest + .post(`/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData({ alertTypeId: 'test.gold.noop' })) + .expect(403, { + statusCode: 403, + error: 'Forbidden', + message: + 'Alert test.gold.noop is disabled because it requires a Gold license. Contact your administrator to upgrade your license.', + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/basic/tests/alerts/index.ts b/x-pack/test/alerting_api_integration/basic/tests/alerts/index.ts new file mode 100644 index 0000000000000..84fceb9a6c0f4 --- /dev/null +++ b/x-pack/test/alerting_api_integration/basic/tests/alerts/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function alertingTests({ loadTestFile }: FtrProviderContext) { + describe('Alerts', () => { + loadTestFile(require.resolve('./gold_noop_alert_type')); + loadTestFile(require.resolve('./basic_noop_alert_type')); + }); +} diff --git a/x-pack/test/alerting_api_integration/basic/tests/index.ts b/x-pack/test/alerting_api_integration/basic/tests/index.ts index 7f3152cc38ca8..80152cca07c60 100644 --- a/x-pack/test/alerting_api_integration/basic/tests/index.ts +++ b/x-pack/test/alerting_api_integration/basic/tests/index.ts @@ -15,5 +15,6 @@ export default function alertingApiIntegrationTests({ this.tags('ciGroup3'); loadTestFile(require.resolve('./actions')); + loadTestFile(require.resolve('./alerts')); }); } diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts index 43e4f642bb943..e7ce0638c6319 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts @@ -37,6 +37,9 @@ export function getAllExternalServiceSimulatorPaths(): string[] { getExternalServiceSimulatorPath(service) ); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/incident`); + allPaths.push( + `/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/sys_dictionary` + ); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.JIRA}/rest/api/2/issue`); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.JIRA}/rest/api/2/createmeta`); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.RESILIENT}/rest/orgs/201/incidents`); diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts index e2f31da1c8064..2c3138a36f071 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts @@ -89,6 +89,44 @@ export function initPlugin(router: IRouter, path: string) { }); } ); + + router.get( + { + path: `${path}/api/now/v2/table/sys_dictionary`, + options: { + authRequired: false, + }, + validate: {}, + }, + async function ( + context: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise> { + return jsonResponse(res, 200, { + result: [ + { + column_label: 'Close notes', + mandatory: 'false', + max_length: '4000', + element: 'close_notes', + }, + { + column_label: 'Description', + mandatory: 'false', + max_length: '4000', + element: 'description', + }, + { + column_label: 'Short description', + mandatory: 'false', + max_length: '160', + element: 'short_description', + }, + ], + }); + } + ); } function jsonResponse(res: KibanaResponseFactory, code: number, object?: Record) { diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/slack_simulation.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/slack_simulation.ts index 8f5b1ea75d188..dcbfff81cd85d 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/slack_simulation.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/slack_simulation.ts @@ -7,7 +7,17 @@ import http from 'http'; export async function initPlugin() { + const messages: string[] = []; + return http.createServer((request, response) => { + // return the messages that were posted to be remembered + if (request.method === 'GET') { + response.statusCode = 200; + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify(messages, null, 4)); + return; + } + if (request.method === 'POST') { let data = ''; request.on('data', (chunk) => { @@ -15,7 +25,7 @@ export async function initPlugin() { }); request.on('end', () => { const body = JSON.parse(data); - const text = body && body.text; + const text: string = body && body.text; if (text == null) { response.statusCode = 400; @@ -23,6 +33,15 @@ export async function initPlugin() { return; } + // store a message that was posted to be remembered + const match = text.match(/^message (.*)$/); + if (match) { + messages.push(match[1]); + response.statusCode = 200; + response.end('ok'); + return; + } + switch (text) { case 'success': { response.statusCode = 200; diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/webhook_simulation.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/webhook_simulation.ts index 44d8ea0c2da20..a34293090d7af 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/webhook_simulation.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/webhook_simulation.ts @@ -10,6 +10,8 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { constant } from 'fp-ts/lib/function'; export async function initPlugin() { + const payloads: string[] = []; + return http.createServer((request, response) => { const credentials = pipe( fromNullable(request.headers.authorization), @@ -24,6 +26,14 @@ export async function initPlugin() { getOrElse(constant({ username: '', password: '' })) ); + // return the payloads that were posted to be remembered + if (request.method === 'GET') { + response.statusCode = 200; + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify(payloads, null, 4)); + return; + } + if (request.method === 'POST' || request.method === 'PUT') { let data = ''; request.on('data', (chunk) => { @@ -46,10 +56,18 @@ export async function initPlugin() { response.end('Error'); return; } + + // store a payload that was posted to be remembered + const match = data.match(/^payload (.*)$/); + if (match) { + payloads.push(match[1]); + response.statusCode = 200; + response.end('ok'); + return; + } + response.statusCode = 400; - response.end( - `unknown request to webhook simulator [${data ? `content: ${data}` : `no content`}]` - ); + response.end(`unexpected body ${data}`); return; }); } else { diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts index 93ee72082d387..11065edd4beeb 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts @@ -6,7 +6,7 @@ import { CoreSetup } from 'src/core/server'; import { schema, TypeOf } from '@kbn/config-schema'; -import { times } from 'lodash'; +import { curry, times } from 'lodash'; import { ES_TEST_INDEX_NAME } from '../../../../lib'; import { FixtureStartDeps, FixtureSetupDeps } from './plugin'; import { @@ -15,6 +15,15 @@ import { AlertInstanceContext, } from '../../../../../../../plugins/alerts/server'; +export const EscapableStrings = { + escapableBold: '*bold*', + escapableBacktic: 'back`tic', + escapableBackticBold: '`*bold*`', + escapableHtml: '<&>', + escapableDoubleQuote: '"double quote"', + escapableLineFeed: 'line\x0afeed', +}; + function getAlwaysFiringAlertType() { const paramsSchema = schema.object({ index: schema.string(), @@ -43,72 +52,73 @@ function getAlwaysFiringAlertType() { }, producer: 'alertsFixture', defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', actionVariables: { state: [{ name: 'instanceStateValue', description: 'the instance state value' }], params: [{ name: 'instanceParamsValue', description: 'the instance params value' }], context: [{ name: 'instanceContextValue', description: 'the instance context value' }], }, - async executor(alertExecutorOptions) { - const { - services, - params, - state, - alertId, - spaceId, - namespace, - name, - tags, - createdBy, - updatedBy, - } = alertExecutorOptions; - let group: string | null = 'default'; - let subgroup: string | null = null; - const alertInfo = { alertId, spaceId, namespace, name, tags, createdBy, updatedBy }; + executor: curry(alwaysFiringExecutor)(), + }; + return result; +} - if (params.groupsToScheduleActionsInSeries) { - const index = state.groupInSeriesIndex || 0; - const [scheduledGroup, scheduledSubgroup] = ( - params.groupsToScheduleActionsInSeries[index] ?? '' - ).split(':'); +async function alwaysFiringExecutor(alertExecutorOptions: any) { + const { + services, + params, + state, + alertId, + spaceId, + namespace, + name, + tags, + createdBy, + updatedBy, + } = alertExecutorOptions; + let group: string | null = 'default'; + let subgroup: string | null = null; + const alertInfo = { alertId, spaceId, namespace, name, tags, createdBy, updatedBy }; - group = scheduledGroup; - subgroup = scheduledSubgroup; - } + if (params.groupsToScheduleActionsInSeries) { + const index = state.groupInSeriesIndex || 0; + const [scheduledGroup, scheduledSubgroup] = ( + params.groupsToScheduleActionsInSeries[index] ?? '' + ).split(':'); - if (group) { - const instance = services - .alertInstanceFactory('1') - .replaceState({ instanceStateValue: true }); + group = scheduledGroup; + subgroup = scheduledSubgroup; + } - if (subgroup) { - instance.scheduleActionsWithSubGroup(group, subgroup, { - instanceContextValue: true, - }); - } else { - instance.scheduleActions(group, { - instanceContextValue: true, - }); - } - } + if (group) { + const instance = services.alertInstanceFactory('1').replaceState({ instanceStateValue: true }); - await services.scopedClusterClient.index({ - index: params.index, - refresh: 'wait_for', - body: { - state, - params, - reference: params.reference, - source: 'alert:test.always-firing', - alertInfo, - }, + if (subgroup) { + instance.scheduleActionsWithSubGroup(group, subgroup, { + instanceContextValue: true, }); - return { - globalStateValue: true, - groupInSeriesIndex: (state.groupInSeriesIndex || 0) + 1, - }; + } else { + instance.scheduleActions(group, { + instanceContextValue: true, + }); + } + } + + await services.scopedClusterClient.index({ + index: params.index, + refresh: 'wait_for', + body: { + state, + params, + reference: params.reference, + source: 'alert:test.always-firing', + alertInfo, }, + }); + return { + globalStateValue: true, + groupInSeriesIndex: (state.groupInSeriesIndex || 0) + 1, }; - return result; } function getCumulativeFiringAlertType() { @@ -127,6 +137,7 @@ function getCumulativeFiringAlertType() { ], producer: 'alertsFixture', defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', async executor(alertExecutorOptions) { const { services, state } = alertExecutorOptions; const group = 'default'; @@ -145,7 +156,7 @@ function getCumulativeFiringAlertType() { }; }, }; - return result; + return result as AlertType; } function getNeverFiringAlertType() { @@ -171,6 +182,7 @@ function getNeverFiringAlertType() { }, producer: 'alertsFixture', defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', async executor({ services, params, state }) { await services.callCluster('index', { index: params.index, @@ -210,6 +222,7 @@ function getFailingAlertType() { ], producer: 'alertsFixture', defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', async executor({ services, params, state }) { await services.callCluster('index', { index: params.index, @@ -248,6 +261,7 @@ function getAuthorizationAlertType(core: CoreSetup) { ], defaultActionGroupId: 'default', producer: 'alertsFixture', + minimumLicenseRequired: 'basic', validate: { params: paramsSchema, }, @@ -333,6 +347,7 @@ function getValidationAlertType() { }, ], producer: 'alertsFixture', + minimumLicenseRequired: 'basic', defaultActionGroupId: 'default', validate: { params: paramsSchema, @@ -360,6 +375,7 @@ function getPatternFiringAlertType() { actionGroups: [{ id: 'default', name: 'Default' }], producer: 'alertsFixture', defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', async executor(alertExecutorOptions) { const { services, state, params } = alertExecutorOptions; const pattern = params.pattern; @@ -394,7 +410,7 @@ function getPatternFiringAlertType() { for (const [instanceId, instancePattern] of Object.entries(pattern)) { const scheduleByPattern = instancePattern[patternIndex]; if (scheduleByPattern === true) { - services.alertInstanceFactory(instanceId).scheduleActions('default'); + services.alertInstanceFactory(instanceId).scheduleActions('default', EscapableStrings); } else if (typeof scheduleByPattern === 'string') { services .alertInstanceFactory(instanceId) @@ -420,6 +436,16 @@ export function defineAlertTypes( actionGroups: [{ id: 'default', name: 'Default' }], producer: 'alertsFixture', defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + async executor() {}, + }; + const goldNoopAlertType: AlertType = { + id: 'test.gold.noop', + name: 'Test: Noop', + actionGroups: [{ id: 'default', name: 'Default' }], + producer: 'alertsFixture', + defaultActionGroupId: 'default', + minimumLicenseRequired: 'gold', async executor() {}, }; const onlyContextVariablesAlertType: AlertType = { @@ -428,6 +454,7 @@ export function defineAlertTypes( actionGroups: [{ id: 'default', name: 'Default' }], producer: 'alertsFixture', defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', actionVariables: { context: [{ name: 'aContextVariable', description: 'this is a context variable' }], }, @@ -442,6 +469,7 @@ export function defineAlertTypes( actionVariables: { state: [{ name: 'aStateVariable', description: 'this is a state variable' }], }, + minimumLicenseRequired: 'basic', async executor() {}, }; const throwAlertType: AlertType = { @@ -455,6 +483,7 @@ export function defineAlertTypes( ], producer: 'alertsFixture', defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', async executor() { throw new Error('this alert is intended to fail'); }, @@ -470,6 +499,7 @@ export function defineAlertTypes( ], producer: 'alertsFixture', defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', async executor() { await new Promise((resolve) => setTimeout(resolve, 5000)); }, @@ -487,4 +517,5 @@ export function defineAlertTypes( alerts.registerType(getPatternFiringAlertType()); alerts.registerType(throwAlertType); alerts.registerType(longRunningAlertType); + alerts.registerType(goldNoopAlertType); } diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts_restricted/server/alert_types.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts_restricted/server/alert_types.ts index 3e3c44f2c2784..3a81d41a2ca9c 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts_restricted/server/alert_types.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts_restricted/server/alert_types.ts @@ -18,6 +18,7 @@ export function defineAlertTypes( actionGroups: [{ id: 'default', name: 'Default' }], producer: 'alertsRestrictedFixture', defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: { id: 'restrictedRecovered', name: 'Restricted Recovery' }, async executor({ services, params, state }: AlertExecutorOptions) {}, }; @@ -27,6 +28,7 @@ export function defineAlertTypes( actionGroups: [{ id: 'default', name: 'Default' }], producer: 'alertsRestrictedFixture', defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', async executor({ services, params, state }: AlertExecutorOptions) {}, }; alerts.registerType(noopRestrictedAlertType); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/jira.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/jira.ts index edac71b8c594f..aba2b8426adf1 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/jira.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/jira.ts @@ -15,24 +15,6 @@ import { ExternalServiceSimulator, } from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; -const mapping = [ - { - source: 'title', - target: 'summary', - actionType: 'overwrite', - }, - { - source: 'description', - target: 'description', - actionType: 'overwrite', - }, - { - source: 'comments', - target: 'comments', - actionType: 'append', - }, -]; - // eslint-disable-next-line import/no-default-export export default function jiraTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -43,7 +25,6 @@ export default function jiraTest({ getService }: FtrProviderContext) { config: { apiUrl: 'www.jiraisinkibanaactions.com', projectKey: 'CK', - incidentConfiguration: { mapping }, }, secrets: { apiToken: 'elastic', @@ -52,23 +33,15 @@ export default function jiraTest({ getService }: FtrProviderContext) { params: { subAction: 'pushToService', subActionParams: { - savedObjectId: '123', - title: 'a title', - description: 'a description', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: null, - updatedBy: null, - externalId: null, + incident: { + summary: 'a title', + description: 'a description', + externalId: null, + }, comments: [ { - commentId: '456', - version: 'WzU3LDFd', comment: 'first comment', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: null, - updatedBy: null, + commentId: '456', }, ], }, @@ -94,8 +67,6 @@ export default function jiraTest({ getService }: FtrProviderContext) { config: { ...mockJira.config, apiUrl: jiraSimulatorURL, - incidentConfiguration: mockJira.config.incidentConfiguration, - isCaseOwned: true, }, secrets: mockJira.secrets, }) @@ -109,8 +80,6 @@ export default function jiraTest({ getService }: FtrProviderContext) { config: { apiUrl: jiraSimulatorURL, projectKey: mockJira.config.projectKey, - incidentConfiguration: mockJira.config.incidentConfiguration, - isCaseOwned: true, }, }); @@ -126,8 +95,6 @@ export default function jiraTest({ getService }: FtrProviderContext) { config: { apiUrl: jiraSimulatorURL, projectKey: mockJira.config.projectKey, - incidentConfiguration: mockJira.config.incidentConfiguration, - isCaseOwned: true, }, }); }); @@ -182,7 +149,6 @@ export default function jiraTest({ getService }: FtrProviderContext) { config: { apiUrl: 'http://jira.mynonexistent.com', projectKey: mockJira.config.projectKey, - incidentConfiguration: mockJira.config.incidentConfiguration, }, secrets: mockJira.secrets, }) @@ -207,7 +173,6 @@ export default function jiraTest({ getService }: FtrProviderContext) { config: { apiUrl: jiraSimulatorURL, projectKey: mockJira.config.projectKey, - incidentConfiguration: mockJira.config.incidentConfiguration, }, }) .expect(400) @@ -220,56 +185,6 @@ export default function jiraTest({ getService }: FtrProviderContext) { }); }); }); - - it('should respond with a 400 Bad Request when creating a jira action with empty mapping', async () => { - await supertest - .post('/api/actions/action') - .set('kbn-xsrf', 'foo') - .send({ - name: 'A jira action', - actionTypeId: '.jira', - config: { - apiUrl: jiraSimulatorURL, - projectKey: mockJira.config.projectKey, - incidentConfiguration: { mapping: [] }, - }, - secrets: mockJira.secrets, - }) - .expect(400) - .then((resp: any) => { - expect(resp.body).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: - 'error validating action type config: [incidentConfiguration.mapping]: expected non-empty but got empty', - }); - }); - }); - - it('should respond with a 400 Bad Request when creating a jira action with wrong actionType', async () => { - await supertest - .post('/api/actions/action') - .set('kbn-xsrf', 'foo') - .send({ - name: 'A jira action', - actionTypeId: '.jira', - config: { - apiUrl: jiraSimulatorURL, - projectKey: mockJira.config.projectKey, - incidentConfiguration: { - mapping: [ - { - source: 'title', - target: 'description', - actionType: 'non-supported', - }, - ], - }, - }, - secrets: mockJira.secrets, - }) - .expect(400); - }); }); describe('Jira - Executor', () => { @@ -287,7 +202,6 @@ export default function jiraTest({ getService }: FtrProviderContext) { config: { apiUrl: jiraSimulatorURL, projectKey: mockJira.config.projectKey, - incidentConfiguration: mockJira.config.incidentConfiguration, }, secrets: mockJira.secrets, }); @@ -375,7 +289,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.title]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [issueTypes]\n- [5.subAction]: expected value to equal [fieldsByIssueType]\n- [6.subAction]: expected value to equal [issues]\n- [7.subAction]: expected value to equal [issue]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.summary]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [issueTypes]\n- [5.subAction]: expected value to equal [fieldsByIssueType]\n- [6.subAction]: expected value to equal [issues]\n- [7.subAction]: expected value to equal [issue]', }); }); }); @@ -388,7 +302,10 @@ export default function jiraTest({ getService }: FtrProviderContext) { params: { ...mockJira.params, subActionParams: { - savedObjectId: 'success', + incident: { + description: 'success', + }, + comments: [], }, }, }) @@ -398,7 +315,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.title]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [issueTypes]\n- [5.subAction]: expected value to equal [fieldsByIssueType]\n- [6.subAction]: expected value to equal [issues]\n- [7.subAction]: expected value to equal [issue]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.summary]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [issueTypes]\n- [5.subAction]: expected value to equal [fieldsByIssueType]\n- [6.subAction]: expected value to equal [issues]\n- [7.subAction]: expected value to equal [issue]', }); }); }); @@ -411,12 +328,12 @@ export default function jiraTest({ getService }: FtrProviderContext) { params: { ...mockJira.params, subActionParams: { - ...mockJira.params.subActionParams, - savedObjectId: 'success', - title: 'success', - createdAt: 'success', - createdBy: { username: 'elastic' }, - comments: [{}], + incident: { + ...mockJira.params.subActionParams.incident, + description: 'success', + summary: 'success', + }, + comments: [{ comment: 'comment' }], }, }, }) @@ -439,11 +356,10 @@ export default function jiraTest({ getService }: FtrProviderContext) { params: { ...mockJira.params, subActionParams: { - ...mockJira.params.subActionParams, - savedObjectId: 'success', - title: 'success', - createdAt: 'success', - createdBy: { username: 'elastic' }, + incident: { + ...mockJira.params.subActionParams.incident, + summary: 'success', + }, comments: [{ commentId: 'success' }], }, }, @@ -469,9 +385,11 @@ export default function jiraTest({ getService }: FtrProviderContext) { params: { ...mockJira.params, subActionParams: { - ...mockJira.params.subActionParams, + incident: { + ...mockJira.params.subActionParams.incident, + issueType: '10006', + }, comments: [], - issueType: '10006', }, }, }) diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/resilient.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/resilient.ts index 617f66ec98f50..392a430134352 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/resilient.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/resilient.ts @@ -15,24 +15,6 @@ import { ExternalServiceSimulator, } from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; -const mapping = [ - { - source: 'title', - target: 'name', - actionType: 'overwrite', - }, - { - source: 'description', - target: 'description', - actionType: 'overwrite', - }, - { - source: 'comments', - target: 'comments', - actionType: 'append', - }, -]; - // eslint-disable-next-line import/no-default-export export default function resilientTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -43,8 +25,6 @@ export default function resilientTest({ getService }: FtrProviderContext) { config: { apiUrl: 'www.resilientisinkibanaactions.com', orgId: '201', - incidentConfiguration: { mapping }, - isCaseOwned: true, }, secrets: { apiKeyId: 'key', @@ -53,25 +33,17 @@ export default function resilientTest({ getService }: FtrProviderContext) { params: { subAction: 'pushToService', subActionParams: { - savedObjectId: '123', - title: 'a title', - description: 'a description', - incidentTypes: [1001], - severityCode: 6, - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: null, - updatedBy: null, - externalId: null, + incident: { + name: 'a title', + description: 'a description', + incidentTypes: [1001], + severityCode: 6, + externalId: null, + }, comments: [ { - commentId: '456', - version: 'WzU3LDFd', comment: 'first comment', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: null, - updatedBy: null, + commentId: '456', }, ], }, @@ -111,8 +83,6 @@ export default function resilientTest({ getService }: FtrProviderContext) { config: { apiUrl: resilientSimulatorURL, orgId: mockResilient.config.orgId, - incidentConfiguration: mockResilient.config.incidentConfiguration, - isCaseOwned: true, }, }); @@ -128,8 +98,6 @@ export default function resilientTest({ getService }: FtrProviderContext) { config: { apiUrl: resilientSimulatorURL, orgId: mockResilient.config.orgId, - incidentConfiguration: mockResilient.config.incidentConfiguration, - isCaseOwned: true, }, }); }); @@ -184,7 +152,6 @@ export default function resilientTest({ getService }: FtrProviderContext) { config: { apiUrl: 'http://resilient.mynonexistent.com', orgId: mockResilient.config.orgId, - incidentConfiguration: mockResilient.config.incidentConfiguration, }, secrets: mockResilient.secrets, }) @@ -209,7 +176,6 @@ export default function resilientTest({ getService }: FtrProviderContext) { config: { apiUrl: resilientSimulatorURL, orgId: mockResilient.config.orgId, - incidentConfiguration: mockResilient.config.incidentConfiguration, }, }) .expect(400) @@ -222,56 +188,6 @@ export default function resilientTest({ getService }: FtrProviderContext) { }); }); }); - - it('should respond with a 400 Bad Request when creating a ibm resilient action with empty mapping', async () => { - await supertest - .post('/api/actions/action') - .set('kbn-xsrf', 'foo') - .send({ - name: 'An IBM Resilient', - actionTypeId: '.resilient', - config: { - apiUrl: resilientSimulatorURL, - orgId: mockResilient.config.orgId, - incidentConfiguration: { mapping: [] }, - }, - secrets: mockResilient.secrets, - }) - .expect(400) - .then((resp: any) => { - expect(resp.body).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: - 'error validating action type config: [incidentConfiguration.mapping]: expected non-empty but got empty', - }); - }); - }); - - it('should respond with a 400 Bad Request when creating a ibm resilient action with wrong actionType', async () => { - await supertest - .post('/api/actions/action') - .set('kbn-xsrf', 'foo') - .send({ - name: 'An IBM Resilient', - actionTypeId: '.resilient', - config: { - apiUrl: resilientSimulatorURL, - orgId: mockResilient.config.orgId, - incidentConfiguration: { - mapping: [ - { - source: 'title', - target: 'description', - actionType: 'non-supported', - }, - ], - }, - }, - secrets: mockResilient.secrets, - }) - .expect(400); - }); }); describe('IBM Resilient - Executor', () => { @@ -288,7 +204,6 @@ export default function resilientTest({ getService }: FtrProviderContext) { config: { apiUrl: resilientSimulatorURL, orgId: mockResilient.config.orgId, - incidentConfiguration: mockResilient.config.incidentConfiguration, }, secrets: mockResilient.secrets, }); @@ -376,7 +291,7 @@ export default function resilientTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.title]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [incidentTypes]\n- [5.subAction]: expected value to equal [severity]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.name]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [incidentTypes]\n- [5.subAction]: expected value to equal [severity]', }); }); }); @@ -389,7 +304,10 @@ export default function resilientTest({ getService }: FtrProviderContext) { params: { ...mockResilient.params, subActionParams: { - savedObjectId: 'success', + incident: { + description: 'success', + }, + comments: [], }, }, }) @@ -399,7 +317,7 @@ export default function resilientTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.title]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [incidentTypes]\n- [5.subAction]: expected value to equal [severity]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.name]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [incidentTypes]\n- [5.subAction]: expected value to equal [severity]', }); }); }); @@ -412,12 +330,11 @@ export default function resilientTest({ getService }: FtrProviderContext) { params: { ...mockResilient.params, subActionParams: { - ...mockResilient.params.subActionParams, - savedObjectId: 'success', - title: 'success', - createdAt: 'success', - createdBy: { username: 'elastic' }, - comments: [{}], + incident: { + ...mockResilient.params.subActionParams.incident, + name: 'success', + }, + comments: [{ comment: 'comment' }], }, }, }) @@ -440,11 +357,10 @@ export default function resilientTest({ getService }: FtrProviderContext) { params: { ...mockResilient.params, subActionParams: { - ...mockResilient.params.subActionParams, - savedObjectId: 'success', - title: 'success', - createdAt: 'success', - createdBy: { username: 'elastic' }, + incident: { + ...mockResilient.params.subActionParams.incident, + name: 'success', + }, comments: [{ commentId: 'success' }], }, }, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts index 47bfd3c496123..e448ad1f9c2ad 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts @@ -15,24 +15,6 @@ import { ExternalServiceSimulator, } from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; -const mapping = [ - { - source: 'title', - target: 'short_description', - actionType: 'overwrite', - }, - { - source: 'description', - target: 'description', - actionType: 'overwrite', - }, - { - source: 'comments', - target: 'comments', - actionType: 'append', - }, -]; - // eslint-disable-next-line import/no-default-export export default function servicenowTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -42,8 +24,6 @@ export default function servicenowTest({ getService }: FtrProviderContext) { const mockServiceNow = { config: { apiUrl: 'www.servicenowisinkibanaactions.com', - incidentConfiguration: { mapping }, - isCaseOwned: true, }, secrets: { password: 'elastic', @@ -52,27 +32,20 @@ export default function servicenowTest({ getService }: FtrProviderContext) { params: { subAction: 'pushToService', subActionParams: { - savedObjectId: '123', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, + incident: { + description: 'a description', + externalId: null, + impact: '1', + severity: '1', + short_description: 'a title', + urgency: '1', + }, comments: [ { - commentId: '456', comment: 'first comment', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: null, - updatedBy: null, + commentId: '456', }, ], - description: 'a description', - externalId: null, - title: 'a title', - severity: '1', - urgency: '1', - impact: '1', - updatedAt: '2020-06-17T04:37:45.147Z', - updatedBy: { fullName: null, username: 'elastic' }, }, }, }; @@ -96,8 +69,6 @@ export default function servicenowTest({ getService }: FtrProviderContext) { actionTypeId: '.servicenow', config: { apiUrl: servicenowSimulatorURL, - incidentConfiguration: mockServiceNow.config.incidentConfiguration, - isCaseOwned: true, }, secrets: mockServiceNow.secrets, }) @@ -110,8 +81,6 @@ export default function servicenowTest({ getService }: FtrProviderContext) { actionTypeId: '.servicenow', config: { apiUrl: servicenowSimulatorURL, - incidentConfiguration: mockServiceNow.config.incidentConfiguration, - isCaseOwned: true, }, }); @@ -126,8 +95,6 @@ export default function servicenowTest({ getService }: FtrProviderContext) { actionTypeId: '.servicenow', config: { apiUrl: servicenowSimulatorURL, - incidentConfiguration: mockServiceNow.config.incidentConfiguration, - isCaseOwned: true, }, }); }); @@ -161,8 +128,6 @@ export default function servicenowTest({ getService }: FtrProviderContext) { actionTypeId: '.servicenow', config: { apiUrl: 'http://servicenow.mynonexistent.com', - incidentConfiguration: mockServiceNow.config.incidentConfiguration, - isCaseOwned: true, }, secrets: mockServiceNow.secrets, }) @@ -186,8 +151,6 @@ export default function servicenowTest({ getService }: FtrProviderContext) { actionTypeId: '.servicenow', config: { apiUrl: servicenowSimulatorURL, - incidentConfiguration: mockServiceNow.config.incidentConfiguration, - isCaseOwned: true, }, }) .expect(400) @@ -200,72 +163,6 @@ export default function servicenowTest({ getService }: FtrProviderContext) { }); }); }); - - it('should create a servicenow action without incidentConfiguration', async () => { - await supertest - .post('/api/actions/action') - .set('kbn-xsrf', 'foo') - .send({ - name: 'A servicenow action', - actionTypeId: '.servicenow', - config: { - apiUrl: servicenowSimulatorURL, - isCaseOwned: true, - }, - secrets: mockServiceNow.secrets, - }) - .expect(200); - }); - - it('should respond with a 400 Bad Request when creating a servicenow action with empty mapping', async () => { - await supertest - .post('/api/actions/action') - .set('kbn-xsrf', 'foo') - .send({ - name: 'A servicenow action', - actionTypeId: '.servicenow', - config: { - apiUrl: servicenowSimulatorURL, - incidentConfiguration: { mapping: [] }, - isCaseOwned: true, - }, - secrets: mockServiceNow.secrets, - }) - .expect(400) - .then((resp: any) => { - expect(resp.body).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: - 'error validating action type config: [incidentConfiguration.mapping]: expected non-empty but got empty', - }); - }); - }); - - it('should respond with a 400 Bad Request when creating a servicenow action with wrong actionType', async () => { - await supertest - .post('/api/actions/action') - .set('kbn-xsrf', 'foo') - .send({ - name: 'A servicenow action', - actionTypeId: '.servicenow', - config: { - apiUrl: servicenowSimulatorURL, - incidentConfiguration: { - mapping: [ - { - source: 'title', - target: 'description', - actionType: 'non-supported', - }, - ], - }, - isCaseOwned: true, - }, - secrets: mockServiceNow.secrets, - }) - .expect(400); - }); }); describe('ServiceNow - Executor', () => { @@ -281,8 +178,6 @@ export default function servicenowTest({ getService }: FtrProviderContext) { actionTypeId: '.servicenow', config: { apiUrl: servicenowSimulatorURL, - incidentConfiguration: mockServiceNow.config.incidentConfiguration, - isCaseOwned: true, }, secrets: mockServiceNow.secrets, }); @@ -370,7 +265,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.title]: expected value of type [string] but got [undefined]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.short_description]: expected value of type [string] but got [undefined]', }); }); }); @@ -393,7 +288,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.title]: expected value of type [string] but got [undefined]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.short_description]: expected value of type [string] but got [undefined]', }); }); }); @@ -406,12 +301,11 @@ export default function servicenowTest({ getService }: FtrProviderContext) { params: { ...mockServiceNow.params, subActionParams: { - ...mockServiceNow.params.subActionParams, - savedObjectId: 'success', - title: 'success', - createdAt: 'success', - createdBy: { username: 'elastic' }, - comments: [{}], + incident: { + ...mockServiceNow.params.subActionParams.incident, + short_description: 'success', + }, + comments: [{ comment: 'boo' }], }, }, }) @@ -421,7 +315,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments.0.commentId]: expected value of type [string] but got [undefined]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.commentId]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]', }); }); }); @@ -434,11 +328,10 @@ export default function servicenowTest({ getService }: FtrProviderContext) { params: { ...mockServiceNow.params, subActionParams: { - ...mockServiceNow.params.subActionParams, - savedObjectId: 'success', - title: 'success', - createdAt: 'success', - createdBy: { username: 'elastic' }, + incident: { + ...mockServiceNow.params.subActionParams.incident, + short_description: 'success', + }, comments: [{ commentId: 'success' }], }, }, @@ -449,7 +342,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments.0.comment]: expected value of type [string] but got [undefined]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.comment]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]', }); }); }); @@ -464,7 +357,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { params: { ...mockServiceNow.params, subActionParams: { - ...mockServiceNow.params.subActionParams, + incident: mockServiceNow.params.subActionParams.incident, comments: [], }, }, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/list_alert_types.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/list_alert_types.ts index 1ce04683f79bf..87cc355a58568 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/list_alert_types.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/list_alert_types.ts @@ -28,10 +28,12 @@ export default function listAlertTypes({ getService }: FtrProviderContext) { params: [], }, producer: 'alertsFixture', + minimumLicenseRequired: 'basic', recoveryActionGroup: { id: 'recovered', name: 'Recovered', }, + enabledInLicense: true, }; const expectedRestrictedNoOpType = { @@ -52,6 +54,8 @@ export default function listAlertTypes({ getService }: FtrProviderContext) { params: [], }, producer: 'alertsRestrictedFixture', + minimumLicenseRequired: 'basic', + enabledInLicense: true, }; describe('list_alert_types', () => { diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/migrations.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/migrations.ts index d46d60905da1c..214c161932f48 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/migrations.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/migrations.ts @@ -22,36 +22,18 @@ export default function createGetTests({ getService }: FtrProviderContext) { await esArchiver.unload('actions'); }); - it('7.10.0 migrates the `casesConfiguration` to be the `incidentConfiguration` in `config`', async () => { + it('7.10.0 migrates the `casesConfiguration` to be the `incidentConfiguration` in `config`, then 7.11.0 removes `incidentConfiguration`', async () => { const response = await supertest.get( `${getUrlPrefix(``)}/api/actions/action/791a2ab1-784a-46ea-aa68-04c837e5da2d` ); expect(response.status).to.eql(200); - expect(response.body.config).key('incidentConfiguration'); + expect(response.body.config).not.key('incidentConfiguration'); expect(response.body.config).not.key('casesConfiguration'); + expect(response.body.config).not.key('isCaseOwned'); expect(response.body.config).to.eql({ apiUrl: 'http://elastic:changeme@localhost:5620/api/_actions-FTS-external-service-simulators/jira', - incidentConfiguration: { - mapping: [ - { - actionType: 'overwrite', - source: 'title', - target: 'summary', - }, - { - actionType: 'overwrite', - source: 'description', - target: 'description', - }, - { - actionType: 'append', - source: 'comments', - target: 'comments', - }, - ], - }, projectKey: 'CK', }); }); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts index 2b24a75fab844..e97734f89c2cd 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts @@ -34,6 +34,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC loadTestFile(require.resolve('./alerts_space1')); loadTestFile(require.resolve('./alerts_default_space')); loadTestFile(require.resolve('./builtin_alert_types')); + loadTestFile(require.resolve('./mustache_templates.ts')); loadTestFile(require.resolve('./notify_when')); // note that this test will destroy existing spaces diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/list_alert_types.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/list_alert_types.ts index c76a43b05b172..74deaf4c7296f 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/list_alert_types.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/list_alert_types.ts @@ -40,6 +40,8 @@ export default function listAlertTypes({ getService }: FtrProviderContext) { name: 'Recovered', }, producer: 'alertsFixture', + minimumLicenseRequired: 'basic', + enabledInLicense: true, }); expect(Object.keys(authorizedConsumers)).to.contain('alertsFixture'); }); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/mustache_templates.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/mustache_templates.ts new file mode 100644 index 0000000000000..438438505f464 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/mustache_templates.ts @@ -0,0 +1,180 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * These tests ensure that the per-action mustache template escaping works + * for actions we have simulators for. It arranges to have an alert that + * schedules an action that will contain "escapable" characters in it, and + * then validates that the simulator receives the escaped versions. + */ + +import http from 'http'; +import getPort from 'get-port'; +import { URL, format as formatUrl } from 'url'; +import axios from 'axios'; + +import expect from '@kbn/expect'; +import { Spaces } from '../../scenarios'; +import { getUrlPrefix, getTestAlertData, ObjectRemover } from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { + getWebhookServer, + getSlackServer, +} from '../../../common/fixtures/plugins/actions_simulators/server/plugin'; + +// eslint-disable-next-line import/no-default-export +export default function executionStatusAlertTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const retry = getService('retry'); + + describe('mustacheTemplates', () => { + const objectRemover = new ObjectRemover(supertest); + let webhookSimulatorURL: string = ''; + let webhookServer: http.Server; + let slackSimulatorURL: string = ''; + let slackServer: http.Server; + + before(async () => { + let availablePort: number; + + webhookServer = await getWebhookServer(); + availablePort = await getPort({ port: 9000 }); + webhookServer.listen(availablePort); + webhookSimulatorURL = `http://localhost:${availablePort}`; + + slackServer = await getSlackServer(); + availablePort = await getPort({ port: getPort.makeRange(9000, 9100) }); + if (!slackServer.listening) { + slackServer.listen(availablePort); + } + slackSimulatorURL = `http://localhost:${availablePort}`; + }); + + after(async () => { + await objectRemover.removeAll(); + webhookServer.close(); + slackServer.close(); + }); + + it('should handle escapes in webhook', async () => { + const url = formatUrl(new URL(webhookSimulatorURL), { auth: false }); + const actionResponse = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`) + .set('kbn-xsrf', 'test') + .send({ + name: 'testing mustache escapes for webhook', + actionTypeId: '.webhook', + secrets: {}, + config: { + headers: { + 'Content-Type': 'text/plain', + }, + url, + }, + }); + expect(actionResponse.status).to.eql(200); + const createdAction = actionResponse.body; + objectRemover.add(Spaces.space1.id, createdAction.id, 'action', 'actions'); + + // from x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts + const varsTemplate = '{{context.escapableDoubleQuote}} -- {{context.escapableLineFeed}}'; + + const alertResponse = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + name: 'testing variable escapes for webhook', + alertTypeId: 'test.patternFiring', + params: { + pattern: { instance: [true] }, + }, + actions: [ + { + id: createdAction.id, + group: 'default', + params: { + body: `payload {{alertId}} - ${varsTemplate}`, + }, + }, + ], + }) + ); + expect(alertResponse.status).to.eql(200); + const createdAlert = alertResponse.body; + objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert', 'alerts'); + + const body = await retry.try(async () => + waitForActionBody(webhookSimulatorURL, createdAlert.id) + ); + expect(body).to.be(`\\"double quote\\" -- line\\nfeed`); + }); + + it('should handle escapes in slack', async () => { + const actionResponse = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`) + .set('kbn-xsrf', 'test') + .send({ + name: "testing backtic'd mustache escapes for slack", + actionTypeId: '.slack', + secrets: { + webhookUrl: slackSimulatorURL, + }, + }); + expect(actionResponse.status).to.eql(200); + const createdAction = actionResponse.body; + objectRemover.add(Spaces.space1.id, createdAction.id, 'action', 'actions'); + + // from x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts + const varsTemplate = + '{{context.escapableBacktic}} -- {{context.escapableBold}} -- {{context.escapableBackticBold}} -- {{context.escapableHtml}}'; + + const alertResponse = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + name: 'testing variable escapes for slack', + alertTypeId: 'test.patternFiring', + params: { + pattern: { instance: [true] }, + }, + actions: [ + { + id: createdAction.id, + group: 'default', + params: { + message: `message {{alertId}} - ${varsTemplate}`, + }, + }, + ], + }) + ); + expect(alertResponse.status).to.eql(200); + const createdAlert = alertResponse.body; + objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert', 'alerts'); + + const body = await retry.try(async () => + waitForActionBody(slackSimulatorURL, createdAlert.id) + ); + expect(body).to.be("back'tic -- `*bold*` -- `'*bold*'` -- <&>"); + }); + }); + + async function waitForActionBody(url: string, id: string): Promise { + const response = await axios.get(url); + expect(response.status).to.eql(200); + + for (const datum of response.data) { + const match = datum.match(/^(.*) - (.*)$/); + if (match == null) continue; + + if (match[1] === id) return match[2]; + } + + throw new Error(`no action body posted yet for id ${id}`); + } +} diff --git a/x-pack/test/apm_api_integration/basic/tests/alerts/chart_preview.ts b/x-pack/test/apm_api_integration/basic/tests/alerts/chart_preview.ts new file mode 100644 index 0000000000000..3119de47a8635 --- /dev/null +++ b/x-pack/test/apm_api_integration/basic/tests/alerts/chart_preview.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { format } from 'url'; +import archives from '../../../common/archives_metadata'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertest'); + const archiveName = 'apm_8.0.0'; + const { end } = archives[archiveName]; + const start = new Date(Date.parse(end) - 600000).toISOString(); + + describe('Alerting chart previews', () => { + describe('GET /api/apm/alerts/chart_preview/transaction_error_rate', () => { + const url = format({ + pathname: '/api/apm/alerts/chart_preview/transaction_error_rate', + query: { + start, + end, + transactionType: 'request', + serviceName: 'opbeans-java', + }, + }); + + describe('when data is not loaded', () => { + it('handles the empty state', async () => { + const response = await supertest.get(url); + + expect(response.status).to.be(200); + expect(response.body).to.eql([]); + }); + }); + + describe('when data is loaded', () => { + before(() => esArchiver.load(archiveName)); + after(() => esArchiver.unload(archiveName)); + + it('returns the correct data', async () => { + const response = await supertest.get(url); + + expect(response.status).to.be(200); + expect( + response.body.some((item: { x: number; y: number | null }) => item.x && item.y) + ).to.equal(true); + }); + }); + }); + + describe('GET /api/apm/alerts/chart_preview/transaction_error_count', () => { + const url = format({ + pathname: '/api/apm/alerts/chart_preview/transaction_error_count', + query: { + start, + end, + serviceName: 'opbeans-java', + }, + }); + + describe('when data is not loaded', () => { + it('handles the empty state', async () => { + const response = await supertest.get(url); + + expect(response.status).to.be(200); + expect(response.body).to.eql([]); + }); + }); + + describe('when data is loaded', () => { + before(() => esArchiver.load(archiveName)); + after(() => esArchiver.unload(archiveName)); + + it('returns the correct data', async () => { + const response = await supertest.get(url); + + expect(response.status).to.be(200); + expect( + response.body.some((item: { x: number; y: number | null }) => item.x && item.y) + ).to.equal(true); + }); + }); + }); + + describe('GET /api/apm/alerts/chart_preview/transaction_duration', () => { + const url = format({ + pathname: '/api/apm/alerts/chart_preview/transaction_duration', + query: { + start, + end, + serviceName: 'opbeans-java', + transactionType: 'request', + }, + }); + + describe('when data is not loaded', () => { + it('handles the empty state', async () => { + const response = await supertest.get(url); + + expect(response.status).to.be(200); + expect(response.body).to.eql([]); + }); + }); + + describe('when data is loaded', () => { + before(() => esArchiver.load(archiveName)); + after(() => esArchiver.unload(archiveName)); + + it('returns the correct data', async () => { + const response = await supertest.get(url); + + expect(response.status).to.be(200); + expect( + response.body.some((item: { x: number; y: number | null }) => item.x && item.y) + ).to.equal(true); + }); + }); + }); + }); +} diff --git a/x-pack/test/apm_api_integration/basic/tests/correlations/slow_transactions.ts b/x-pack/test/apm_api_integration/basic/tests/correlations/slow_transactions.ts index 91f13ae62a7f0..5217a4be40da7 100644 --- a/x-pack/test/apm_api_integration/basic/tests/correlations/slow_transactions.ts +++ b/x-pack/test/apm_api_integration/basic/tests/correlations/slow_transactions.ts @@ -57,43 +57,37 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); it('returns significant terms', () => { - expectSnapshot(response.body?.significantTerms?.map((term) => term.fieldName).sort()) - .toMatchInline(` + const sorted = response.body?.significantTerms?.sort(); + expectSnapshot(sorted?.map((term) => term.fieldName)).toMatchInline(` Array [ - "container.id", - "container.id", - "host.ip", + "user_agent.name", + "url.domain", "host.ip", "service.node.name", - "service.node.name", - "url.domain", + "container.id", "url.domain", "user_agent.name", - "user_agent.name", ] `); }); - it('returns a timeseries per term', () => { - // @ts-ignore - expectSnapshot(response.body?.significantTerms[0].timeseries.length).toMatchInline(`31`); - }); - it('returns a distribution per term', () => { - // @ts-ignore - expectSnapshot(response.body?.significantTerms[0].distribution.length).toMatchInline( - `42` - ); - }); - - it('returns overall timeseries', () => { - // @ts-ignore - expectSnapshot(response.body?.overall.timeseries.length).toMatchInline(`31`); + expectSnapshot(response.body?.significantTerms?.map((term) => term.distribution.length)) + .toMatchInline(` + Array [ + 11, + 11, + 11, + 11, + 11, + 11, + 11, + ] + `); }); it('returns overall distribution', () => { - // @ts-ignore - expectSnapshot(response.body?.overall.distribution.length).toMatchInline(`42`); + expectSnapshot(response.body?.overall?.distribution.length).toMatchInline(`11`); }); }); }); diff --git a/x-pack/test/apm_api_integration/basic/tests/index.ts b/x-pack/test/apm_api_integration/basic/tests/index.ts index 3e625688e2459..f50868ee76c1c 100644 --- a/x-pack/test/apm_api_integration/basic/tests/index.ts +++ b/x-pack/test/apm_api_integration/basic/tests/index.ts @@ -11,6 +11,10 @@ export default function apmApiIntegrationTests({ loadTestFile }: FtrProviderCont loadTestFile(require.resolve('./feature_controls')); + describe('Alerts', function () { + loadTestFile(require.resolve('./alerts/chart_preview')); + }); + describe('Service Maps', function () { loadTestFile(require.resolve('./service_maps/service_maps')); }); @@ -23,10 +27,10 @@ export default function apmApiIntegrationTests({ loadTestFile }: FtrProviderCont loadTestFile(require.resolve('./services/transaction_types')); }); - // TODO: we should not have a service overview. describe('Service overview', function () { loadTestFile(require.resolve('./service_overview/error_groups')); loadTestFile(require.resolve('./service_overview/dependencies')); + loadTestFile(require.resolve('./service_overview/instances')); }); describe('Settings', function () { diff --git a/x-pack/test/apm_api_integration/basic/tests/service_overview/instances.ts b/x-pack/test/apm_api_integration/basic/tests/service_overview/instances.ts new file mode 100644 index 0000000000000..084555387a690 --- /dev/null +++ b/x-pack/test/apm_api_integration/basic/tests/service_overview/instances.ts @@ -0,0 +1,214 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import url from 'url'; +import { pick, sortBy } from 'lodash'; +import { isFiniteNumber } from '../../../../../plugins/apm/common/utils/is_finite_number'; +import { APIReturnType } from '../../../../../plugins/apm/public/services/rest/createCallApmApi'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import archives from '../../../common/archives_metadata'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + const archiveName = 'apm_8.0.0'; + const { start, end } = archives[archiveName]; + + interface Response { + status: number; + body: APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances'>; + } + + describe('Service overview instances', () => { + describe('when data is not loaded', () => { + it('handles the empty state', async () => { + const response: Response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/service_overview_instances`, + query: { + start, + end, + numBuckets: 20, + transactionType: 'request', + uiFilters: '{}', + }, + }) + ); + + expect(response.status).to.be(200); + expect(response.body).to.eql([]); + }); + }); + + describe('when data is loaded', () => { + before(() => esArchiver.load(archiveName)); + after(() => esArchiver.unload(archiveName)); + + describe('fetching java data', () => { + let response: Response; + + beforeEach(async () => { + response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/service_overview_instances`, + query: { + start, + end, + numBuckets: 20, + transactionType: 'request', + uiFilters: '{}', + }, + }) + ); + }); + + it('returns a service node item', () => { + expect(response.body.length).to.be.greaterThan(0); + }); + + it('returns statistics for each service node', () => { + const item = response.body[0]; + + expect(isFiniteNumber(item.cpuUsage?.value)).to.be(true); + expect(isFiniteNumber(item.memoryUsage?.value)).to.be(true); + expect(isFiniteNumber(item.errorRate?.value)).to.be(true); + expect(isFiniteNumber(item.throughput?.value)).to.be(true); + expect(isFiniteNumber(item.latency?.value)).to.be(true); + + expect(item.cpuUsage?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true); + expect(item.memoryUsage?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true); + expect(item.errorRate?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true); + expect(item.throughput?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true); + expect(item.latency?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true); + }); + + it('returns the right data', () => { + const items = sortBy(response.body, 'serviceNodeName'); + + const serviceNodeNames = items.map((item) => item.serviceNodeName); + + expectSnapshot(items.length).toMatchInline(`1`); + + expectSnapshot(serviceNodeNames).toMatchInline(` + Array [ + "02950c4c5fbb0fda1cc98c47bf4024b473a8a17629db6530d95dcee68bd54c6c", + ] + `); + + const item = items[0]; + + const values = pick(item, [ + 'cpuUsage.value', + 'memoryUsage.value', + 'errorRate.value', + 'throughput.value', + 'latency.value', + ]); + + expectSnapshot(values).toMatchInline(` + Object { + "cpuUsage": Object { + "value": 0.0120166666666667, + }, + "errorRate": Object { + "value": 0.16, + }, + "latency": Object { + "value": 237339.813333333, + }, + "memoryUsage": Object { + "value": 0.941324615478516, + }, + "throughput": Object { + "value": 75, + }, + } + `); + }); + }); + + describe('fetching non-java data', () => { + let response: Response; + + beforeEach(async () => { + response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-ruby/service_overview_instances`, + query: { + start, + end, + numBuckets: 20, + transactionType: 'request', + uiFilters: '{}', + }, + }) + ); + }); + + it('returns statistics for each service node', () => { + const item = response.body[0]; + + expect(isFiniteNumber(item.cpuUsage?.value)).to.be(true); + expect(isFiniteNumber(item.memoryUsage?.value)).to.be(true); + expect(isFiniteNumber(item.errorRate?.value)).to.be(true); + expect(isFiniteNumber(item.throughput?.value)).to.be(true); + expect(isFiniteNumber(item.latency?.value)).to.be(true); + + expect(item.cpuUsage?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true); + expect(item.memoryUsage?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true); + expect(item.errorRate?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true); + expect(item.throughput?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true); + expect(item.latency?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true); + }); + + it('returns the right data', () => { + const items = sortBy(response.body, 'serviceNodeName'); + + const serviceNodeNames = items.map((item) => item.serviceNodeName); + + expectSnapshot(items.length).toMatchInline(`1`); + + expectSnapshot(serviceNodeNames).toMatchInline(` + Array [ + "_service_node_name_missing_", + ] + `); + + const item = items[0]; + + const values = pick( + item, + 'cpuUsage.value', + 'errorRate.value', + 'throughput.value', + 'latency.value' + ); + + expectSnapshot(values).toMatchInline(` + Object { + "cpuUsage": Object { + "value": 0.00111666666666667, + }, + "errorRate": Object { + "value": 0.0373134328358209, + }, + "latency": Object { + "value": 70518.9328358209, + }, + "throughput": Object { + "value": 134, + }, + } + `); + + expectSnapshot(values); + }); + }); + }); + }); +} diff --git a/x-pack/test/case_api_integration/basic/tests/cases/migrations.ts b/x-pack/test/case_api_integration/basic/tests/cases/migrations.ts index 36f07ef92b5f1..df200b34dc429 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/migrations.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/migrations.ts @@ -38,5 +38,18 @@ export default function createGetTests({ getService }: FtrProviderContext) { fields: null, }); }); + + it('7.11.0 migrates cases settings', async () => { + const { body } = await supertest + .get(`${CASES_URL}/e1900ac0-017f-11eb-93f8-d161651bf509`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(body).key('settings'); + expect(body.settings).to.eql({ + syncAlerts: true, + }); + }); }); } diff --git a/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts b/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts index 3cf0d6892377e..906033e1ddc45 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts @@ -18,15 +18,27 @@ import { getConfiguration, getServiceNowConnector, } from '../../../common/lib/utils'; +import { + ExternalServiceSimulator, + getExternalServiceSimulatorPath, +} from '../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); + const kibanaServer = getService('kibanaServer'); const es = getService('es'); describe('push_case', () => { const actionsRemover = new ActionsRemover(supertest); + let servicenowSimulatorURL: string = ''; + before(() => { + servicenowSimulatorURL = kibanaServer.resolveUrl( + getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) + ); + }); + afterEach(async () => { await deleteCases(es); await deleteComments(es); @@ -39,11 +51,13 @@ export default ({ getService }: FtrProviderContext): void => { const { body: connector } = await supertest .post('/api/actions/action') .set('kbn-xsrf', 'true') - .send(getServiceNowConnector()) + .send({ + ...getServiceNowConnector(), + config: { apiUrl: servicenowSimulatorURL }, + }) .expect(200); actionsRemover.add('default', connector.id, 'action', 'actions'); - const { body: configure } = await supertest .post(CASE_CONFIGURE_URL) .set('kbn-xsrf', 'true') @@ -55,7 +69,6 @@ export default ({ getService }: FtrProviderContext): void => { }) ) .expect(200); - const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') @@ -90,7 +103,10 @@ export default ({ getService }: FtrProviderContext): void => { const { body: connector } = await supertest .post('/api/actions/action') .set('kbn-xsrf', 'true') - .send(getServiceNowConnector()) + .send({ + ...getServiceNowConnector(), + config: { apiUrl: servicenowSimulatorURL }, + }) .expect(200); actionsRemover.add('default', connector.id, 'action', 'actions'); @@ -98,7 +114,13 @@ export default ({ getService }: FtrProviderContext): void => { const { body: configure } = await supertest .post(CASE_CONFIGURE_URL) .set('kbn-xsrf', 'true') - .send(getConfiguration(connector.id)) + .send( + getConfiguration({ + id: connector.id, + name: connector.name, + type: connector.actionTypeId, + }) + ) .expect(200); const { body: postedCase } = await supertest diff --git a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts index 6949052df4703..4eb87d2c2d2ce 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts @@ -20,14 +20,25 @@ import { } from '../../../../common/lib/utils'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; +import { + ExternalServiceSimulator, + getExternalServiceSimulatorPath, +} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); const actionsRemover = new ActionsRemover(supertest); + const kibanaServer = getService('kibanaServer'); describe('get_all_user_actions', () => { + let servicenowSimulatorURL: string = ''; + before(() => { + servicenowSimulatorURL = kibanaServer.resolveUrl( + getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) + ); + }); afterEach(async () => { await deleteCases(es); await deleteComments(es); @@ -36,7 +47,7 @@ export default ({ getService }: FtrProviderContext): void => { await actionsRemover.removeAll(); }); - it(`on new case, user action: 'create' should be called with actionFields: ['description', 'status', 'tags', 'title', 'connector']`, async () => { + it(`on new case, user action: 'create' should be called with actionFields: ['description', 'status', 'tags', 'title', 'connector', 'settings]`, async () => { const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') @@ -51,7 +62,14 @@ export default ({ getService }: FtrProviderContext): void => { expect(body.length).to.eql(1); - expect(body[0].action_field).to.eql(['description', 'status', 'tags', 'title', 'connector']); + expect(body[0].action_field).to.eql([ + 'description', + 'status', + 'tags', + 'title', + 'connector', + 'settings', + ]); expect(body[0].action).to.eql('create'); expect(body[0].old_value).to.eql(null); expect(body[0].new_value).to.eql(JSON.stringify(postCaseReq)); @@ -311,7 +329,10 @@ export default ({ getService }: FtrProviderContext): void => { const { body: connector } = await supertest .post('/api/actions/action') .set('kbn-xsrf', 'true') - .send(getServiceNowConnector()) + .send({ + ...getServiceNowConnector(), + config: { apiUrl: servicenowSimulatorURL }, + }) .expect(200); actionsRemover.add('default', connector.id, 'action', 'actions'); diff --git a/x-pack/test/case_api_integration/basic/tests/configure/get_connectors.ts b/x-pack/test/case_api_integration/basic/tests/configure/get_connectors.ts index 5195d28d84830..d55aca1780c86 100644 --- a/x-pack/test/case_api_integration/basic/tests/configure/get_connectors.ts +++ b/x-pack/test/case_api_integration/basic/tests/configure/get_connectors.ts @@ -13,8 +13,6 @@ import { getServiceNowConnector, getJiraConnector, getResilientConnector, - getConnectorWithoutCaseOwned, - getConnectorWithoutMapping, } from '../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export @@ -73,63 +71,17 @@ export default ({ getService }: FtrProviderContext): void => { .send(getResilientConnector()) .expect(200); - const { body: connectorWithoutCaseOwned } = await supertest - .post('/api/actions/action') - .set('kbn-xsrf', 'true') - .send(getConnectorWithoutCaseOwned()) - .expect(200); - - const { body: connectorNoMapping } = await supertest - .post('/api/actions/action') - .set('kbn-xsrf', 'true') - .send(getConnectorWithoutMapping()) - .expect(200); - actionsRemover.add('default', snConnector.id, 'action', 'actions'); actionsRemover.add('default', emailConnector.id, 'action', 'actions'); actionsRemover.add('default', jiraConnector.id, 'action', 'actions'); actionsRemover.add('default', resilientConnector.id, 'action', 'actions'); - actionsRemover.add('default', connectorWithoutCaseOwned.id, 'action', 'actions'); - actionsRemover.add('default', connectorNoMapping.id, 'action', 'actions'); const { body: connectors } = await supertest .get(`${CASE_CONFIGURE_CONNECTORS_URL}/_find`) .set('kbn-xsrf', 'true') .send() .expect(200); - expect(connectors).to.eql([ - { - id: connectorWithoutCaseOwned.id, - actionTypeId: '.resilient', - name: 'Connector without isCaseOwned', - config: { - apiUrl: 'http://some.non.existent.com', - orgId: 'pkey', - incidentConfiguration: { - mapping: [ - { - source: 'title', - target: 'name', - actionType: 'overwrite', - }, - { - source: 'description', - target: 'description', - actionType: 'overwrite', - }, - { - source: 'comments', - target: 'comments', - actionType: 'append', - }, - ], - }, - isCaseOwned: null, - }, - isPreconfigured: false, - referencedByCount: 0, - }, { id: jiraConnector.id, actionTypeId: '.jira', @@ -137,26 +89,6 @@ export default ({ getService }: FtrProviderContext): void => { config: { apiUrl: 'http://some.non.existent.com', projectKey: 'pkey', - incidentConfiguration: { - mapping: [ - { - source: 'title', - target: 'summary', - actionType: 'overwrite', - }, - { - source: 'description', - target: 'description', - actionType: 'overwrite', - }, - { - source: 'comments', - target: 'comments', - actionType: 'append', - }, - ], - }, - isCaseOwned: true, }, isPreconfigured: false, referencedByCount: 0, @@ -168,26 +100,6 @@ export default ({ getService }: FtrProviderContext): void => { config: { apiUrl: 'http://some.non.existent.com', orgId: 'pkey', - incidentConfiguration: { - mapping: [ - { - source: 'title', - target: 'name', - actionType: 'overwrite', - }, - { - source: 'description', - target: 'description', - actionType: 'overwrite', - }, - { - source: 'comments', - target: 'comments', - actionType: 'append', - }, - ], - }, - isCaseOwned: true, }, isPreconfigured: false, referencedByCount: 0, @@ -198,26 +110,6 @@ export default ({ getService }: FtrProviderContext): void => { name: 'ServiceNow Connector', config: { apiUrl: 'http://some.non.existent.com', - incidentConfiguration: { - mapping: [ - { - source: 'title', - target: 'short_description', - actionType: 'overwrite', - }, - { - source: 'description', - target: 'description', - actionType: 'append', - }, - { - source: 'comments', - target: 'comments', - actionType: 'append', - }, - ], - }, - isCaseOwned: true, }, isPreconfigured: false, referencedByCount: 0, diff --git a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts index 9a45dd541bb56..e0812d01d0fb8 100644 --- a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts +++ b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts @@ -391,6 +391,9 @@ export default ({ getService }: FtrProviderContext): void => { parent: null, }, }, + settings: { + syncAlerts: true, + }, }, }; @@ -442,6 +445,9 @@ export default ({ getService }: FtrProviderContext): void => { type: '.servicenow', fields: {}, }, + settings: { + syncAlerts: true, + }, }, }; @@ -673,7 +679,53 @@ export default ({ getService }: FtrProviderContext): void => { }); }); - it('should respond with a 400 Bad Request when missing attributes of type alert', async () => { + // TODO: Remove it when the creation of comments of type alert is supported + // https://github.com/elastic/kibana/issues/85750 + it('should fail adding a comment of type alert', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + + createdActionId = createdAction.id; + + const caseRes = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const params = { + subAction: 'addComment', + subActionParams: { + caseId: caseRes.body.id, + comment: { alertId: 'test-id', index: 'test-index', type: CommentType.alert }, + }, + }; + + const caseConnector = await supertest + .post(`/api/actions/action/${createdActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ params }) + .expect(200); + + expect(caseConnector.body).to.eql({ + status: 'error', + actionId: createdActionId, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: types that failed validation:\n - [subActionParams.comment.0.type]: expected value to equal [user]', + retry: false, + }); + }); + + // TODO: Enable when the creation of comments of type alert is supported + // https://github.com/elastic/kibana/issues/85750 + it.skip('should respond with a 400 Bad Request when missing attributes of type alert', async () => { const { body: createdAction } = await supertest .post('/api/actions/action') .set('kbn-xsrf', 'foo') @@ -754,13 +806,15 @@ export default ({ getService }: FtrProviderContext): void => { expect(caseConnector.body).to.eql({ status: 'error', actionId: createdActionId, - message: `error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: types that failed validation:\n - [subActionParams.comment.0.${attribute}]: definition for this key is missing\n - [subActionParams.comment.1.type]: expected value to equal [alert]`, + message: `error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: types that failed validation:\n - [subActionParams.comment.0.${attribute}]: definition for this key is missing`, retry: false, }); } }); - it('should respond with a 400 Bad Request when adding excess attributes for type alert', async () => { + // TODO: Enable when the creation of comments of type alert is supported + // https://github.com/elastic/kibana/issues/85750 + it.skip('should respond with a 400 Bad Request when adding excess attributes for type alert', async () => { const { body: createdAction } = await supertest .post('/api/actions/action') .set('kbn-xsrf', 'foo') @@ -892,7 +946,9 @@ export default ({ getService }: FtrProviderContext): void => { }); }); - it('should add a comment of type alert', async () => { + // TODO: Enable when the creation of comments of type alert is supported + // https://github.com/elastic/kibana/issues/85750 + it.skip('should add a comment of type alert', async () => { const { body: createdAction } = await supertest .post('/api/actions/action') .set('kbn-xsrf', 'foo') diff --git a/x-pack/test/case_api_integration/common/config.ts b/x-pack/test/case_api_integration/common/config.ts index 86d69266c6ec6..ee054508b7491 100644 --- a/x-pack/test/case_api_integration/common/config.ts +++ b/x-pack/test/case_api_integration/common/config.ts @@ -7,7 +7,10 @@ import { CA_CERT_PATH } from '@kbn/dev-utils'; import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +import path from 'path'; +import fs from 'fs'; import { services } from './services'; +import { getAllExternalServiceSimulatorPaths } from '../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; interface CreateTestConfigOptions { license: string; @@ -50,6 +53,34 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) }, }; + const allFiles = fs.readdirSync( + path.resolve( + __dirname, + '..', + '..', + 'alerting_api_integration', + 'common', + 'fixtures', + 'plugins' + ) + ); + const plugins = allFiles.filter((file) => + fs + .statSync( + path.resolve( + __dirname, + '..', + '..', + 'alerting_api_integration', + 'common', + 'fixtures', + 'plugins', + file + ) + ) + .isDirectory() + ); + return { testFiles: [require.resolve(`../${name}/tests/`)], servers, @@ -77,6 +108,20 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, '--xpack.eventLog.logEntries=true', ...disabledPlugins.map((key) => `--xpack.${key}.enabled=false`), + ...plugins.map( + (pluginDir) => + `--plugin-path=${path.resolve( + __dirname, + '..', + '..', + 'alerting_api_integration', + 'common', + 'fixtures', + 'plugins', + pluginDir + )}` + ), + `--server.xsrf.whitelist=${JSON.stringify(getAllExternalServiceSimulatorPaths())}`, ...(ssl ? [ `--elasticsearch.hosts=${servers.elasticsearch.protocol}://${servers.elasticsearch.hostname}:${servers.elasticsearch.port}`, diff --git a/x-pack/test/case_api_integration/common/lib/mock.ts b/x-pack/test/case_api_integration/common/lib/mock.ts index dac6b2005a9c3..d61b999a745a0 100644 --- a/x-pack/test/case_api_integration/common/lib/mock.ts +++ b/x-pack/test/case_api_integration/common/lib/mock.ts @@ -23,9 +23,12 @@ export const postCaseReq: CasePostRequest = { connector: { id: 'none', name: 'none', - type: '.none' as ConnectorTypes, + type: ConnectorTypes.none, fields: null, }, + settings: { + syncAlerts: true, + }, }; export const postCommentUserReq: CommentRequestUserType = { diff --git a/x-pack/test/case_api_integration/common/lib/utils.ts b/x-pack/test/case_api_integration/common/lib/utils.ts index 262e14fac6d8c..06d6dd7ac3b7a 100644 --- a/x-pack/test/case_api_integration/common/lib/utils.ts +++ b/x-pack/test/case_api_integration/common/lib/utils.ts @@ -15,7 +15,7 @@ import { export const getConfiguration = ({ id = 'connector-1', name = 'Connector 1', - type = '.none' as ConnectorTypes, + type = ConnectorTypes.none, fields = null, }: Partial = {}): CasesConfigureRequest => { return { @@ -32,6 +32,7 @@ export const getConfiguration = ({ export const getConfigurationOutput = (update = false): Partial => { return { ...getConfiguration(), + mappings: [], created_by: { email: null, full_name: null, username: 'elastic' }, updated_by: update ? { email: null, full_name: null, username: 'elastic' } : null, }; @@ -46,26 +47,6 @@ export const getServiceNowConnector = () => ({ }, config: { apiUrl: 'http://some.non.existent.com', - incidentConfiguration: { - mapping: [ - { - source: 'title', - target: 'short_description', - actionType: 'overwrite', - }, - { - source: 'description', - target: 'description', - actionType: 'append', - }, - { - source: 'comments', - target: 'comments', - actionType: 'append', - }, - ], - }, - isCaseOwned: true, }, }); @@ -79,96 +60,29 @@ export const getJiraConnector = () => ({ config: { apiUrl: 'http://some.non.existent.com', projectKey: 'pkey', - incidentConfiguration: { - mapping: [ - { - source: 'title', - target: 'summary', - actionType: 'overwrite', - }, - { - source: 'description', - target: 'description', - actionType: 'overwrite', - }, - { - source: 'comments', - target: 'comments', - actionType: 'append', - }, - ], - }, - isCaseOwned: true, }, }); -export const getResilientConnector = () => ({ - name: 'Resilient Connector', - actionTypeId: '.resilient', - secrets: { - apiKeyId: 'id', - apiKeySecret: 'secret', +export const getMappings = () => [ + { + source: 'title', + target: 'name', + actionType: 'overwrite', }, - config: { - apiUrl: 'http://some.non.existent.com', - orgId: 'pkey', - incidentConfiguration: { - mapping: [ - { - source: 'title', - target: 'name', - actionType: 'overwrite', - }, - { - source: 'description', - target: 'description', - actionType: 'overwrite', - }, - { - source: 'comments', - target: 'comments', - actionType: 'append', - }, - ], - }, - isCaseOwned: true, - }, -}); - -export const getConnectorWithoutCaseOwned = () => ({ - name: 'Connector without isCaseOwned', - actionTypeId: '.resilient', - secrets: { - apiKeyId: 'id', - apiKeySecret: 'secret', + { + source: 'description', + target: 'description', + actionType: 'overwrite', }, - config: { - apiUrl: 'http://some.non.existent.com', - orgId: 'pkey', - incidentConfiguration: { - mapping: [ - { - source: 'title', - target: 'name', - actionType: 'overwrite', - }, - { - source: 'description', - target: 'description', - actionType: 'overwrite', - }, - { - source: 'comments', - target: 'comments', - actionType: 'append', - }, - ], - }, + { + source: 'comments', + target: 'comments', + actionType: 'append', }, -}); +]; -export const getConnectorWithoutMapping = () => ({ - name: 'Connector without mapping', +export const getResilientConnector = () => ({ + name: 'Resilient Connector', actionTypeId: '.resilient', secrets: { apiKeyId: 'id', diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_signals_migrations.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_signals_migrations.ts new file mode 100644 index 0000000000000..3271a75fcd1d1 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_signals_migrations.ts @@ -0,0 +1,185 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { + DEFAULT_SIGNALS_INDEX, + DETECTION_ENGINE_SIGNALS_MIGRATION_URL, +} from '../../../../plugins/security_solution/common/constants'; +import { ROLES } from '../../../../plugins/security_solution/common/test'; +import { SIGNALS_TEMPLATE_VERSION } from '../../../../plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + createSignalsIndex, + deleteMigrations, + deleteSignalsIndex, + getIndexNameFromLoad, + waitForIndexToPopulate, +} from '../../utils'; +import { createUserAndRole } from '../roles_users_utils'; + +interface CreateResponse { + index: string; + migration_index: string; + migration_id: string; +} + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const es = getService('es'); + const esArchiver = getService('esArchiver'); + const kbnClient = getService('kibanaServer'); + const security = getService('security'); + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('Creating signals migrations', () => { + let createdMigrations: CreateResponse[]; + let legacySignalsIndexName: string; + let outdatedSignalsIndexName: string; + + beforeEach(async () => { + createdMigrations = []; + await createSignalsIndex(supertest); + + legacySignalsIndexName = getIndexNameFromLoad( + await esArchiver.load('signals/legacy_signals_index') + ); + outdatedSignalsIndexName = getIndexNameFromLoad( + await esArchiver.load('signals/outdated_signals_index') + ); + }); + + afterEach(async () => { + await esArchiver.unload('signals/outdated_signals_index'); + await esArchiver.unload('signals/legacy_signals_index'); + await deleteMigrations({ + kbnClient, + ids: createdMigrations.filter((m) => m?.migration_id).map((m) => m.migration_id), + }); + await deleteSignalsIndex(supertest); + }); + + it('returns the information necessary to finalize the migration', async () => { + const { body } = await supertest + .post(DETECTION_ENGINE_SIGNALS_MIGRATION_URL) + .set('kbn-xsrf', 'true') + .send({ index: [legacySignalsIndexName] }) + .expect(200); + createdMigrations = [...createdMigrations, ...body.indices]; + + expect(body.indices).length(1); + const [createdMigration] = body.indices; + + expect(createdMigration.index).to.eql(legacySignalsIndexName); + expect(createdMigration.migration_id).to.be.a('string'); + expect(createdMigration.migration_id.length).to.be.greaterThan(0); + expect(createdMigration.migration_index).not.to.eql(legacySignalsIndexName); + expect(createdMigration.migration_index).to.contain(legacySignalsIndexName); + }); + + it('creates a new index containing migrated signals', async () => { + const { body } = await supertest + .post(DETECTION_ENGINE_SIGNALS_MIGRATION_URL) + .set('kbn-xsrf', 'true') + .send({ index: [legacySignalsIndexName, outdatedSignalsIndexName] }) + .expect(200); + createdMigrations = [...createdMigrations, ...body.indices]; + const createResponses: CreateResponse[] = body.indices; + + expect(createResponses).length(2); + createResponses.forEach((response) => expect(response.migration_id).to.be.a('string')); + + const [{ migration_index: newIndex }] = createResponses; + await waitForIndexToPopulate(es, newIndex); + const { body: migrationResults } = await es.search({ index: newIndex }); + + expect(migrationResults.hits.hits).length(1); + const migratedSignal = migrationResults.hits.hits[0]._source.signal; + expect(migratedSignal._meta.version).to.equal(SIGNALS_TEMPLATE_VERSION); + }); + + it('specifying the signals alias itself is a bad request', async () => { + const signalsAlias = `${DEFAULT_SIGNALS_INDEX}-default`; + + const { body } = await supertest + .post(DETECTION_ENGINE_SIGNALS_MIGRATION_URL) + .set('kbn-xsrf', 'true') + .send({ index: [signalsAlias, legacySignalsIndexName] }) + .expect(400); + + expect(body).to.eql({ + message: + 'The following indices are not signals indices and cannot be migrated: [.siem-signals-default].', + status_code: 400, + }); + }); + + it('rejects extant non-signals indexes', async () => { + const unrelatedIndex = '.tasks'; + const { body } = await supertest + .post(DETECTION_ENGINE_SIGNALS_MIGRATION_URL) + .set('kbn-xsrf', 'true') + .send({ index: [legacySignalsIndexName, unrelatedIndex] }) + .expect(400); + + expect(body).to.eql({ + message: 'The following indices are not signals indices and cannot be migrated: [.tasks].', + status_code: 400, + }); + }); + + it('rejects if an unknown index is specified', async () => { + const { body } = await supertest + .post(DETECTION_ENGINE_SIGNALS_MIGRATION_URL) + .set('kbn-xsrf', 'true') + .send({ index: ['random-index', outdatedSignalsIndexName] }) + .expect(400); + + expect(body).to.eql({ + message: + 'The following indices are not signals indices and cannot be migrated: [random-index].', + status_code: 400, + }); + }); + + it('returns an inline error on a duplicated request as the destination index already exists', async () => { + const { body } = await supertest + .post(DETECTION_ENGINE_SIGNALS_MIGRATION_URL) + .set('kbn-xsrf', 'true') + .send({ index: [legacySignalsIndexName] }) + .expect(200); + createdMigrations = [...createdMigrations, ...body.indices]; + + const { body: duplicatedBody } = await supertest + .post(DETECTION_ENGINE_SIGNALS_MIGRATION_URL) + .set('kbn-xsrf', 'true') + .send({ index: [legacySignalsIndexName] }) + .expect(200); + + const [{ error, ...info }] = duplicatedBody.indices; + expect(info).to.eql({ + index: legacySignalsIndexName, + migration_index: null, + migration_id: null, + }); + expect(error.status_code).to.eql(400); + expect(error.message).to.contain('resource_already_exists_exception'); + }); + + it('rejects the request if the user does not have sufficient privileges', async () => { + await createUserAndRole(security, ROLES.t1_analyst); + + await supertestWithoutAuth + .post(DETECTION_ENGINE_SIGNALS_MIGRATION_URL) + .set('kbn-xsrf', 'true') + .auth(ROLES.t1_analyst, 'changeme') + .send({ index: [legacySignalsIndexName] }) + .expect(400); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_signals_migrations.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_signals_migrations.ts new file mode 100644 index 0000000000000..040c83c458eb3 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_signals_migrations.ts @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { + DEFAULT_SIGNALS_INDEX, + DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL, + DETECTION_ENGINE_SIGNALS_MIGRATION_URL, +} from '../../../../plugins/security_solution/common/constants'; +import { ROLES } from '../../../../plugins/security_solution/common/test'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { createSignalsIndex, deleteSignalsIndex, getIndexNameFromLoad, waitFor } from '../../utils'; +import { createUserAndRole } from '../roles_users_utils'; + +interface CreateResponse { + index: string; + migration_index: string; + migration_id: string; +} + +interface FinalizeResponse extends CreateResponse { + completed?: boolean; + error?: unknown; +} + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const es = getService('es'); + const esArchiver = getService('esArchiver'); + const security = getService('security'); + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('deleting signals migrations', () => { + let outdatedSignalsIndexName: string; + let createdMigration: CreateResponse; + let finalizedMigration: FinalizeResponse; + + beforeEach(async () => { + await createSignalsIndex(supertest); + outdatedSignalsIndexName = getIndexNameFromLoad( + await esArchiver.load('signals/outdated_signals_index') + ); + + ({ + body: { + indices: [createdMigration], + }, + } = await supertest + .post(DETECTION_ENGINE_SIGNALS_MIGRATION_URL) + .set('kbn-xsrf', 'true') + .send({ index: [outdatedSignalsIndexName] }) + .expect(200)); + + await waitFor(async () => { + ({ + body: { + migrations: [finalizedMigration], + }, + } = await supertest + .post(DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL) + .set('kbn-xsrf', 'true') + .send({ migration_ids: [createdMigration.migration_id] }) + .expect(200)); + + return finalizedMigration.completed ?? false; + }, `polling finalize_migration until all complete`); + }); + + afterEach(async () => { + await esArchiver.unload('signals/outdated_signals_index'); + await deleteSignalsIndex(supertest); + }); + + it('returns the deleted migration SavedObjects', async () => { + const { body } = await supertest + .delete(DETECTION_ENGINE_SIGNALS_MIGRATION_URL) + .set('kbn-xsrf', 'true') + .send({ migration_ids: [createdMigration.migration_id] }) + .expect(200); + + const deletedMigration = body.migrations[0]; + expect(deletedMigration.id).to.eql(createdMigration.migration_id); + expect(deletedMigration.sourceIndex).to.eql(outdatedSignalsIndexName); + }); + + it('marks the original index for deletion by applying our cleanup policy', async () => { + await supertest + .delete(DETECTION_ENGINE_SIGNALS_MIGRATION_URL) + .set('kbn-xsrf', 'true') + .send({ migration_ids: [createdMigration.migration_id] }) + .expect(200); + + const { body } = await es.indices.getSettings({ index: createdMigration.index }); + const indexSettings = body[createdMigration.index].settings.index; + expect(indexSettings.lifecycle.name).to.eql( + `${DEFAULT_SIGNALS_INDEX}-default-migration-cleanup` + ); + }); + + it('rejects the request if the user does not have sufficient privileges', async () => { + await createUserAndRole(security, ROLES.t1_analyst); + + const { body } = await supertestWithoutAuth + .delete(DETECTION_ENGINE_SIGNALS_MIGRATION_URL) + .set('kbn-xsrf', 'true') + .send({ migration_ids: [createdMigration.migration_id] }) + .auth(ROLES.t1_analyst, 'changeme') + .expect(200); + + const deletedMigration = body.migrations[0]; + + expect(deletedMigration.id).to.eql(createdMigration.migration_id); + expect(deletedMigration.error).to.eql({ + message: + 'security_exception: action [indices:admin/settings/update] is unauthorized for user [t1_analyst] on indices [], this action is granted by the privileges [manage,all]', + status_code: 403, + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/finalize_signals_migrations.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/finalize_signals_migrations.ts new file mode 100644 index 0000000000000..a754966cf18a9 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/finalize_signals_migrations.ts @@ -0,0 +1,255 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { + DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL, + DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL, + DETECTION_ENGINE_SIGNALS_MIGRATION_URL, +} from '../../../../plugins/security_solution/common/constants'; +import { ROLES } from '../../../../plugins/security_solution/common/test'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + createSignalsIndex, + deleteMigrations, + deleteSignalsIndex, + getIndexNameFromLoad, + waitFor, +} from '../../utils'; +import { createUserAndRole } from '../roles_users_utils'; + +interface StatusResponse { + index: string; + is_outdated: boolean; +} + +interface CreateResponse { + index: string; + migration_index: string; + migration_id: string; +} + +interface FinalizeResponse { + id: string; + completed?: boolean; + error?: unknown; +} + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const esArchiver = getService('esArchiver'); + const kbnClient = getService('kibanaServer'); + const security = getService('security'); + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('Finalizing signals migrations', () => { + let legacySignalsIndexName: string; + let outdatedSignalsIndexName: string; + let createdMigrations: CreateResponse[]; + let createdMigration: CreateResponse; + + beforeEach(async () => { + createdMigrations = []; + await createSignalsIndex(supertest); + legacySignalsIndexName = getIndexNameFromLoad( + await esArchiver.load('signals/legacy_signals_index') + ); + outdatedSignalsIndexName = getIndexNameFromLoad( + await esArchiver.load('signals/outdated_signals_index') + ); + + ({ + body: { indices: createdMigrations }, + } = await supertest + .post(DETECTION_ENGINE_SIGNALS_MIGRATION_URL) + .set('kbn-xsrf', 'true') + .send({ index: [legacySignalsIndexName] }) + .expect(200)); + + [createdMigration] = createdMigrations; + }); + + afterEach(async () => { + await esArchiver.unload('signals/outdated_signals_index'); + await esArchiver.unload('signals/legacy_signals_index'); + await deleteMigrations({ + kbnClient, + ids: createdMigrations.filter((m) => m?.migration_id).map((m) => m.migration_id), + }); + await deleteSignalsIndex(supertest); + }); + + it('replaces the original index alias with the migrated one', async () => { + const { body } = await supertest + .get(DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL) + .query({ from: '2020-10-10' }) + .set('kbn-xsrf', 'true') + .expect(200); + const statusResponses: StatusResponse[] = body.indices; + const indicesBefore = statusResponses.map((index) => index.index); + + expect(indicesBefore).to.contain(createdMigration.index); + expect(indicesBefore).not.to.contain(createdMigration.migration_index); + + await waitFor(async () => { + const { + body: { + migrations: [{ completed }], + }, + } = await supertest + .post(DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL) + .set('kbn-xsrf', 'true') + .send({ migration_ids: [createdMigration.migration_id] }) + .expect(200); + + return completed === true; + }, `polling finalize_migration until complete`); + + let statusAfter: StatusResponse[] = []; + await waitFor(async () => { + ({ + body: { indices: statusAfter }, + } = await supertest + .get(DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL) + .query({ from: '2020-10-10' }) + .set('kbn-xsrf', 'true') + .expect(200)); + return statusAfter.some((s) => !s.is_outdated); + }, `polling finalize_migration until complete`); + + const indicesAfter = statusAfter.map((s) => s.index); + + expect(indicesAfter).to.contain(createdMigration.migration_index); + expect(indicesAfter).not.to.contain(createdMigration.index); + }); + + it('finalizes an arbitrary number of indices', async () => { + // start our second migration + const { body } = await supertest + .post(DETECTION_ENGINE_SIGNALS_MIGRATION_URL) + .set('kbn-xsrf', 'true') + .send({ index: [outdatedSignalsIndexName] }) + .expect(200); + createdMigrations = [...createdMigrations, ...body.indices]; + + let finalizeResponse: FinalizeResponse[]; + await waitFor(async () => { + ({ + body: { migrations: finalizeResponse }, + } = await supertest + .post(DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL) + .set('kbn-xsrf', 'true') + .send({ migration_ids: createdMigrations.map((m) => m.migration_id) }) + .expect(200)); + + return finalizeResponse.every((index) => index.completed); + }, `polling finalize_migration until all complete`); + + const { body: bodyAfter } = await supertest + .get(DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL) + .query({ from: '2020-10-10' }) + .set('kbn-xsrf', 'true') + .expect(200); + + const statusAfter: StatusResponse[] = bodyAfter.indices; + expect(statusAfter.map((s) => s.index)).to.eql( + createdMigrations.map((c) => c.migration_index) + ); + expect(statusAfter.map((s) => s.is_outdated)).to.eql([false, false]); + }); + + it.skip('deletes the underlying migration task', async () => { + await waitFor(async () => { + const { + body: { + migrations: [{ completed }], + }, + } = await supertest + .post(DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL) + .set('kbn-xsrf', 'true') + .send({ migration_ids: [createdMigration.migration_id] }) + .expect(200); + + return completed; + }, `polling finalize_migration until complete`); + + // const [{ taskId }] = await getMigration({ id: migration.migration_id }); + // expect(taskId.length).greaterThan(0); + // const { statusCode } = await es.tasks.get({ task_id: taskId }, { ignore: [404] }); + // expect(statusCode).to.eql(404); + }); + + it('subsequent attempts at finalization are idempotent', async () => { + await waitFor(async () => { + const { + body: { + migrations: [{ completed }], + }, + } = await supertest + .post(DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL) + .set('kbn-xsrf', 'true') + .send({ migration_ids: [createdMigration.migration_id] }) + .expect(200); + + return completed; + }, `polling finalize_migration until complete`); + + const { body } = await supertest + .post(DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL) + .set('kbn-xsrf', 'true') + .send({ migration_ids: [createdMigration.migration_id] }) + .expect(200); + const finalizeResponse: FinalizeResponse = body.migrations[0]; + expect(finalizeResponse.completed).to.eql(true); + expect(finalizeResponse.id).to.eql(createdMigration.migration_id); + + const { body: bodyAfter } = await supertest + .get(DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL) + .query({ from: '2020-10-10' }) + .set('kbn-xsrf', 'true') + .expect(200); + + const statusAfter: StatusResponse[] = bodyAfter.indices; + const indicesAfter = statusAfter.map((index) => index.index); + + expect(indicesAfter).to.contain(createdMigration.migration_index); + expect(indicesAfter).not.to.contain(createdMigration.index); + }); + + it('returns an empty array indicating a no-op for DNE migrations', async () => { + const { body } = await supertest + .post(DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL) + .set('kbn-xsrf', 'true') + .send({ migration_ids: ['dne-migration'] }) + .expect(200); + + expect(body).to.eql({ migrations: [] }); + }); + + it('rejects the request if the user does not have sufficient privileges', async () => { + await createUserAndRole(security, ROLES.t1_analyst); + + const { body } = await supertestWithoutAuth + .post(DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL) + .set('kbn-xsrf', 'true') + .send({ migration_ids: [createdMigration.migration_id] }) + .auth(ROLES.t1_analyst, 'changeme') + .expect(200); + + const finalizeResponse: FinalizeResponse = body.migrations[0]; + + expect(finalizeResponse.id).to.eql(createdMigration.migration_id); + expect(finalizeResponse.completed).not.to.eql(true); + expect(finalizeResponse.error).to.eql({ + message: + 'security_exception: action [cluster:monitor/task/get] is unauthorized for user [t1_analyst]', + status_code: 403, + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/get_signals_migration_status.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/get_signals_migration_status.ts new file mode 100644 index 0000000000000..0a1b5b90c2794 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/get_signals_migration_status.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL } from '../../../../plugins/security_solution/common/constants'; +import { ROLES } from '../../../../plugins/security_solution/common/test'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { createSignalsIndex, deleteSignalsIndex, getIndexNameFromLoad } from '../../utils'; +import { createUserAndRole } from '../roles_users_utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const esArchiver = getService('esArchiver'); + const security = getService('security'); + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('Signals migration status', () => { + let legacySignalsIndexName: string; + beforeEach(async () => { + await createSignalsIndex(supertest); + legacySignalsIndexName = getIndexNameFromLoad( + await esArchiver.load('signals/legacy_signals_index') + ); + }); + + afterEach(async () => { + await esArchiver.unload('signals/legacy_signals_index'); + await deleteSignalsIndex(supertest); + }); + + it('returns no indexes if no signals exist in the specified range', async () => { + const { body } = await supertest + .get(DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL) + .query({ from: '2020-10-20' }) + .set('kbn-xsrf', 'true') + .expect(200); + + expect(body.indices).to.eql([]); + }); + + it('includes an index if its signals are within the specified range', async () => { + const { + body: { indices }, + } = await supertest + .get(DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL) + .query({ from: '2020-10-10' }) + .set('kbn-xsrf', 'true') + .expect(200); + + expect(indices).length(1); + expect(indices[0].index).to.eql(legacySignalsIndexName); + }); + + it("returns the mappings version and a breakdown of signals' version", async () => { + const outdatedIndexName = getIndexNameFromLoad( + await esArchiver.load('signals/outdated_signals_index') + ); + + const { body } = await supertest + .get(DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL) + .query({ from: '2020-10-10' }) + .set('kbn-xsrf', 'true') + .expect(200); + + expect(body.indices).to.eql([ + { + index: legacySignalsIndexName, + is_outdated: true, + migrations: [], + signal_versions: [ + { + count: 1, + version: 0, + }, + ], + version: 1, + }, + { + is_outdated: true, + index: outdatedIndexName, + migrations: [], + signal_versions: [ + { + count: 1, + version: 3, + }, + ], + version: 3, + }, + ]); + + await esArchiver.unload('signals/outdated_signals_index'); + }); + + it('rejects the request if the user does not have sufficient privileges', async () => { + await createUserAndRole(security, ROLES.t1_analyst); + + await supertestWithoutAuth + .get(DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL) + .set('kbn-xsrf', 'true') + .auth(ROLES.t1_analyst, 'changeme') + .query({ from: '2020-10-10' }) + .expect(403); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts index d49d6ed3eedb0..6eb74af910605 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts @@ -33,6 +33,9 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./patch_rules')); loadTestFile(require.resolve('./query_signals')); loadTestFile(require.resolve('./open_close_signals')); - loadTestFile(require.resolve('./migrating_signals')); + loadTestFile(require.resolve('./get_signals_migration_status')); + loadTestFile(require.resolve('./create_signals_migrations')); + loadTestFile(require.resolve('./finalize_signals_migrations')); + loadTestFile(require.resolve('./delete_signals_migrations')); }); }; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/migrating_signals.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/migrating_signals.ts deleted file mode 100644 index a256b026e5174..0000000000000 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/migrating_signals.ts +++ /dev/null @@ -1,521 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; - -import { - DEFAULT_SIGNALS_INDEX, - DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL, - DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL, - DETECTION_ENGINE_SIGNALS_MIGRATION_URL, -} from '../../../../plugins/security_solution/common/constants'; -import { ROLES } from '../../../../plugins/security_solution/common/test'; -import { encodeMigrationToken } from '../../../../plugins/security_solution/server/lib/detection_engine/migrations/helpers'; -import { SIGNALS_TEMPLATE_VERSION } from '../../../../plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template'; -import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { - createSignalsIndex, - deleteSignalsIndex, - getIndexNameFromLoad, - waitFor, - waitForIndexToPopulate, -} from '../../utils'; -import { createUserAndRole } from '../roles_users_utils'; - -// eslint-disable-next-line import/no-default-export -export default ({ getService }: FtrProviderContext): void => { - const es = getService('es'); - const esArchiver = getService('esArchiver'); - const security = getService('security'); - const supertest = getService('supertest'); - const supertestWithoutAuth = getService('supertestWithoutAuth'); - - describe('Migrating signals', () => { - beforeEach(async () => { - await createSignalsIndex(supertest); - }); - - afterEach(async () => { - await deleteSignalsIndex(supertest); - }); - - describe('migration status of signals indexes', async () => { - let legacySignalsIndexName: string; - - beforeEach(async () => { - legacySignalsIndexName = getIndexNameFromLoad( - await esArchiver.load('signals/legacy_signals_index') - ); - }); - - afterEach(async () => { - await esArchiver.unload('signals/legacy_signals_index'); - }); - - it('returns no indexes if no signals exist in the specified range', async () => { - const { body } = await supertest - .get(DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL) - .query({ from: '2020-10-20' }) - .set('kbn-xsrf', 'true') - .expect(200); - - expect(body.indices).to.eql([]); - }); - - it('includes an index if its signals are within the specified range', async () => { - const { - body: { indices }, - } = await supertest - .get(DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL) - .query({ from: '2020-10-10' }) - .set('kbn-xsrf', 'true') - .expect(200); - - expect(indices).length(1); - expect(indices[0].name).to.eql(legacySignalsIndexName); - }); - - it("returns the mappings version and a breakdown of signals' version", async () => { - const outdatedIndexName = getIndexNameFromLoad( - await esArchiver.load('signals/outdated_signals_index') - ); - - const { body } = await supertest - .get(DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL) - .query({ from: '2020-10-10' }) - .set('kbn-xsrf', 'true') - .expect(200); - - expect(body.indices).to.eql([ - { - name: legacySignalsIndexName, - is_outdated: true, - signal_versions: [ - { - doc_count: 1, - key: 0, - }, - ], - version: 1, - }, - { - is_outdated: true, - name: outdatedIndexName, - signal_versions: [ - { - doc_count: 1, - key: 3, - }, - ], - version: 3, - }, - ]); - - await esArchiver.unload('signals/outdated_signals_index'); - }); - - it('rejects the request if the user does not have sufficient privileges', async () => { - await createUserAndRole(security, ROLES.t1_analyst); - - await supertestWithoutAuth - .get(DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL) - .set('kbn-xsrf', 'true') - .auth(ROLES.t1_analyst, 'changeme') - .query({ from: '2020-10-10' }) - .expect(403); - }); - }); - - describe('Creating a signals migration', async () => { - let legacySignalsIndexName: string; - let outdatedSignalsIndexName: string; - - beforeEach(async () => { - legacySignalsIndexName = getIndexNameFromLoad( - await esArchiver.load('signals/legacy_signals_index') - ); - outdatedSignalsIndexName = getIndexNameFromLoad( - await esArchiver.load('signals/outdated_signals_index') - ); - }); - - afterEach(async () => { - await esArchiver.unload('signals/outdated_signals_index'); - await esArchiver.unload('signals/legacy_signals_index'); - }); - - it('returns the information necessary to finalize the migration', async () => { - const { body } = await supertest - .post(DETECTION_ENGINE_SIGNALS_MIGRATION_URL) - .set('kbn-xsrf', 'true') - .send({ index: [legacySignalsIndexName] }) - .expect(200); - - expect(body.indices).length(1); - const [index] = body.indices; - - expect(index.index).to.eql(legacySignalsIndexName); - expect(index.migration_token).to.be.a('string'); - expect(index.migration_token.length).to.be.greaterThan(0); - expect(index.migration_index).not.to.eql(legacySignalsIndexName); - expect(index.migration_index).to.contain(legacySignalsIndexName); - expect(index.migration_task_id).to.be.a('string'); - expect(index.migration_task_id.length).to.be.greaterThan(0); - }); - - it('creates a new index containing migrated signals', async () => { - const { body } = await supertest - .post(DETECTION_ENGINE_SIGNALS_MIGRATION_URL) - .set('kbn-xsrf', 'true') - .send({ index: [legacySignalsIndexName, outdatedSignalsIndexName] }) - .expect(200); - - const indices = body.indices as Array<{ migration_token: string; migration_index: string }>; - expect(indices).length(2); - indices.forEach((index) => expect(index.migration_token).to.be.a('string')); - - const [{ migration_index: newIndex }] = indices; - await waitForIndexToPopulate(es, newIndex); - const { body: migrationResults } = await es.search({ index: newIndex }); - - expect(migrationResults.hits.hits).length(1); - const migratedSignal = migrationResults.hits.hits[0]._source.signal; - expect(migratedSignal._meta.version).to.equal(SIGNALS_TEMPLATE_VERSION); - }); - - it('specifying the signals alias itself is a bad request', async () => { - const signalsAlias = `${DEFAULT_SIGNALS_INDEX}-default`; - - const { body } = await supertest - .post(DETECTION_ENGINE_SIGNALS_MIGRATION_URL) - .set('kbn-xsrf', 'true') - .send({ index: [signalsAlias, legacySignalsIndexName] }) - .expect(400); - - expect(body).to.eql({ - message: - 'The following indices are not signals indices and cannot be migrated: [.siem-signals-default].', - status_code: 400, - }); - }); - - it('rejects extant non-signals indexes', async () => { - const unrelatedIndex = '.tasks'; - const { body } = await supertest - .post(DETECTION_ENGINE_SIGNALS_MIGRATION_URL) - .set('kbn-xsrf', 'true') - .send({ index: [legacySignalsIndexName, unrelatedIndex] }) - .expect(400); - - expect(body).to.eql({ - message: - 'The following indices are not signals indices and cannot be migrated: [.tasks].', - status_code: 400, - }); - }); - - it('rejects if an unknown index is specified', async () => { - const { body } = await supertest - .post(DETECTION_ENGINE_SIGNALS_MIGRATION_URL) - .set('kbn-xsrf', 'true') - .send({ index: ['random-index', outdatedSignalsIndexName] }) - .expect(400); - - expect(body).to.eql({ - message: - 'The following indices are not signals indices and cannot be migrated: [random-index].', - status_code: 400, - }); - }); - - it('returns an inline error on a duplicated request as the destination index already exists', async () => { - await supertest - .post(DETECTION_ENGINE_SIGNALS_MIGRATION_URL) - .set('kbn-xsrf', 'true') - .send({ index: [legacySignalsIndexName] }) - .expect(200); - - const { body } = await supertest - .post(DETECTION_ENGINE_SIGNALS_MIGRATION_URL) - .set('kbn-xsrf', 'true') - .send({ index: [legacySignalsIndexName] }) - .expect(200); - - const [{ error, ...info }] = body.indices; - expect(info).to.eql({ - index: legacySignalsIndexName, - migration_index: null, - migration_task_id: null, - migration_token: null, - }); - expect(error.status_code).to.eql(400); - expect(error.message).to.contain('resource_already_exists_exception'); - }); - - it('rejects the request if the user does not have sufficient privileges', async () => { - await createUserAndRole(security, ROLES.t1_analyst); - - await supertestWithoutAuth - .post(DETECTION_ENGINE_SIGNALS_MIGRATION_URL) - .set('kbn-xsrf', 'true') - .auth(ROLES.t1_analyst, 'changeme') - .send({ index: [legacySignalsIndexName] }) - .expect(403); - }); - }); - - describe('finalizing signals migrations', async () => { - let legacySignalsIndexName: string; - let outdatedSignalsIndexName: string; - let migratingIndices: any[]; - - beforeEach(async () => { - legacySignalsIndexName = getIndexNameFromLoad( - await esArchiver.load('signals/legacy_signals_index') - ); - outdatedSignalsIndexName = getIndexNameFromLoad( - await esArchiver.load('signals/outdated_signals_index') - ); - - ({ - body: { indices: migratingIndices }, - } = await supertest - .post(DETECTION_ENGINE_SIGNALS_MIGRATION_URL) - .set('kbn-xsrf', 'true') - .send({ index: [legacySignalsIndexName, outdatedSignalsIndexName] }) - .expect(200)); - }); - - afterEach(async () => { - await esArchiver.unload('signals/outdated_signals_index'); - await esArchiver.unload('signals/legacy_signals_index'); - }); - - it('replaces the original index alias with the migrated one', async () => { - const [migratingIndex] = migratingIndices; - - const { body } = await supertest - .get(DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL) - .query({ from: '2020-10-10' }) - .set('kbn-xsrf', 'true') - .expect(200); - const indicesBefore = (body.indices as Array<{ name: string }>).map((index) => index.name); - - expect(indicesBefore).to.contain(migratingIndex.index); - expect(indicesBefore).not.to.contain(migratingIndex.migration_index); - - await waitFor(async () => { - const { - body: { completed }, - } = await supertest - .post(DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL) - .set('kbn-xsrf', 'true') - .send({ migration_token: migratingIndex.migration_token }) - .expect(200); - - return completed; - }, `polling finalize_migration until complete`); - - const { body: bodyAfter } = await supertest - .get(DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL) - .query({ from: '2020-10-10' }) - .set('kbn-xsrf', 'true') - .expect(200); - - const indicesAfter = (bodyAfter.indices as Array<{ name: string }>).map( - (index) => index.name - ); - - expect(indicesAfter).to.contain(migratingIndex.migration_index); - expect(indicesAfter).not.to.contain(migratingIndex.index); - }); - - it('marks the original index for deletion by applying our cleanup policy', async () => { - const [migratingIndex] = migratingIndices; - - await waitFor(async () => { - const { - body: { completed }, - } = await supertest - .post(DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL) - .set('kbn-xsrf', 'true') - .send({ migration_token: migratingIndex.migration_token }) - .expect(200); - - return completed; - }, `polling finalize_migration until complete`); - - const { body } = await es.indices.getSettings({ index: migratingIndex.index }); - const indexSettings = body[migratingIndex.index].settings.index; - expect(indexSettings.lifecycle.name).to.eql( - `${DEFAULT_SIGNALS_INDEX}-default-migration-cleanup` - ); - }); - - it('deletes the original index for deletion by applying our cleanup policy', async () => { - const [migratingIndex] = migratingIndices; - - await waitFor(async () => { - const { - body: { completed }, - } = await supertest - .post(DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL) - .set('kbn-xsrf', 'true') - .send({ migration_token: migratingIndex.migration_token }) - .expect(200); - - return completed; - }, `polling finalize_migration until complete`); - - const { statusCode } = await es.tasks.get( - { task_id: migratingIndex.migration_task_id }, - { ignore: [404] } - ); - expect(statusCode).to.eql(404); - }); - - it('subsequent attempts at finalization are 404s', async () => { - const [migratingIndex] = migratingIndices; - - await waitFor(async () => { - const { - body: { completed }, - } = await supertest - .post(DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL) - .set('kbn-xsrf', 'true') - .send({ migration_token: migratingIndex.migration_token }) - .expect(200); - - return completed; - }, `polling finalize_migration until complete`); - - const { body } = await supertest - .post(DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL) - .set('kbn-xsrf', 'true') - .send({ migration_token: migratingIndex.migration_token }) - .expect(404); - - expect(body.status_code).to.eql(404); - expect(body.message).to.contain('resource_not_found_exception'); - - const { body: bodyAfter } = await supertest - .get(DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL) - .query({ from: '2020-10-10' }) - .set('kbn-xsrf', 'true') - .expect(200); - - const indicesAfter = (bodyAfter.indices as Array<{ name: string }>).map( - (index) => index.name - ); - - expect(indicesAfter).to.contain(migratingIndex.migration_index); - expect(indicesAfter).not.to.contain(migratingIndex.index); - }); - - it('rejects if the provided token is invalid', async () => { - const requestBody = { migration_token: 'invalid_token' }; - const { body } = await supertest - .post(DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL) - .set('kbn-xsrf', 'true') - .send(requestBody) - .expect(400); - - expect(body).to.eql({ - message: 'An error occurred while decoding the migration token: [invalid_token]', - status_code: 400, - }); - }); - - it('rejects if the specified indexes do not match the task', async () => { - const [ - { migration_index: destinationIndex, index: sourceIndex, migration_task_id: taskId }, - ] = migratingIndices; - const migrationDetails = { destinationIndex, sourceIndex, taskId }; - const invalidToken = encodeMigrationToken({ - ...migrationDetails, - sourceIndex: 'bad-index', - }); - const requestBody = { migration_token: invalidToken }; - - let finalizeResponse: any; - - await waitFor(async () => { - const { body, status } = await supertest - .post(DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL) - .set('kbn-xsrf', 'true') - .send(requestBody); - finalizeResponse = body; - - return status !== 200; - }, `polling finalize_migration until task is complete (with error)`); - - expect(finalizeResponse).to.eql({ - message: `The specified task does not match the source and destination indexes. Task [${taskId}] did not specify source index [bad-index] and destination index [${destinationIndex}]`, - status_code: 400, - }); - }); - - it('rejects if the task is malformed', async () => { - const [ - { migration_index: destinationIndex, index: sourceIndex, migration_task_id: taskId }, - ] = migratingIndices; - const migrationDetails = { destinationIndex, sourceIndex, taskId }; - const invalidToken = encodeMigrationToken({ - ...migrationDetails, - taskId: 'bad-task-id', - }); - const requestBody = { migration_token: invalidToken }; - - const { body } = await supertest - .post(DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL) - .set('kbn-xsrf', 'true') - .send(requestBody) - .expect(400); - - expect(body).to.eql({ - message: 'illegal_argument_exception: malformed task id bad-task-id', - status_code: 400, - }); - }); - - it('rejects if the task does not exist', async () => { - const [ - { migration_index: destinationIndex, index: sourceIndex, migration_task_id: taskId }, - ] = migratingIndices; - const migrationDetails = { destinationIndex, sourceIndex, taskId }; - const invalidToken = encodeMigrationToken({ - ...migrationDetails, - taskId: 'oTUltX4IQMOUUVeiohTt8A:124', - }); - const requestBody = { migration_token: invalidToken }; - - const { body } = await supertest - .post(DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL) - .set('kbn-xsrf', 'true') - .send(requestBody) - .expect(404); - - expect(body).to.eql({ - message: - "resource_not_found_exception: task [oTUltX4IQMOUUVeiohTt8A:124] belongs to the node [oTUltX4IQMOUUVeiohTt8A] which isn't part of the cluster and there is no record of the task", - status_code: 404, - }); - }); - - it('rejects the request if the user does not have sufficient privileges', async () => { - const [migratingIndex] = migratingIndices; - await createUserAndRole(security, ROLES.t1_analyst); - - await supertestWithoutAuth - .post(DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL) - .set('kbn-xsrf', 'true') - .send({ migration_token: migratingIndex.migration_token }) - .auth(ROLES.t1_analyst, 'changeme') - .expect(403); - }); - }); - }); -}; diff --git a/x-pack/test/detection_engine_api_integration/utils.ts b/x-pack/test/detection_engine_api_integration/utils.ts index 8d8d62cc754a6..5a36b950b6a5b 100644 --- a/x-pack/test/detection_engine_api_integration/utils.ts +++ b/x-pack/test/detection_engine_api_integration/utils.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { KbnClient } from '@kbn/dev-utils'; import { ApiResponse, Client } from '@elastic/elasticsearch'; import { SuperTest } from 'supertest'; import supertestAsPromised from 'supertest-as-promised'; @@ -25,6 +26,7 @@ import { ExceptionListSchema, } from '../../plugins/lists/common'; import { Signal } from '../../plugins/security_solution/server/lib/detection_engine/signals/types'; +import { signalsMigrationType } from '../../plugins/security_solution/server/lib/detection_engine/migrations/saved_objects'; import { Status, SignalIds, @@ -1063,3 +1065,20 @@ export const waitForIndexToPopulate = async (es: Client, index: string): Promise return response.body.count > 0; }, `waitForIndexToPopulate: ${index}`); }; + +export const deleteMigrations = async ({ + ids, + kbnClient, +}: { + ids: string[]; + kbnClient: KbnClient; +}): Promise => { + await Promise.all( + ids.map((id) => + kbnClient.savedObjects.delete({ + id, + type: signalsMigrationType, + }) + ) + ); +}; diff --git a/x-pack/test/fleet_api_integration/apis/agents_setup.ts b/x-pack/test/fleet_api_integration/apis/agents_setup.ts index 4da1335da9a6c..85e533a569c87 100644 --- a/x-pack/test/fleet_api_integration/apis/agents_setup.ts +++ b/x-pack/test/fleet_api_integration/apis/agents_setup.ts @@ -62,10 +62,10 @@ export default function (providerContext: FtrProviderContext) { names: [ 'logs-*', 'metrics-*', - 'events-*', + 'traces-*', '.ds-logs-*', '.ds-metrics-*', - '.ds-events-*', + '.ds-traces-*', ], privileges: ['write', 'create_index', 'indices:admin/auto_create'], allow_restricted_indices: false, diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts index a7d46b9c6677e..1d5f864c27eea 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts @@ -459,6 +459,14 @@ const expectAssetsInstalled = ({ }, ], installed_es: [ + { + id: 'logs-all_assets.test_logs-all_assets', + type: 'data_stream_ilm_policy', + }, + { + id: 'metrics-all_assets.test_metrics-all_assets', + type: 'data_stream_ilm_policy', + }, { id: 'logs-all_assets.test_logs', type: 'index_template', @@ -496,6 +504,7 @@ const expectAssetsInstalled = ({ { id: '96c6eb85-fe2e-56c6-84be-5fda976796db', type: 'epm-packages-assets' }, { id: '2d73a161-fa69-52d0-aa09-1bdc691b95bb', type: 'epm-packages-assets' }, { id: '0a00c2d2-ce63-5b9c-9aa0-0cf1938f7362', type: 'epm-packages-assets' }, + { id: '691f0505-18c5-57a6-9f40-06e8affbdf7a', type: 'epm-packages-assets' }, { id: 'b36e6dd0-58f7-5dd0-a286-8187e4019274', type: 'epm-packages-assets' }, { id: 'f839c76e-d194-555a-90a1-3265a45789e4', type: 'epm-packages-assets' }, { id: '9af7bbb3-7d8a-50fa-acc9-9dde6f5efca2', type: 'epm-packages-assets' }, diff --git a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts index 37aa94beec8b0..7b264f949532e 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts @@ -293,6 +293,10 @@ export default function (providerContext: FtrProviderContext) { }, ], installed_es: [ + { + id: 'logs-all_assets.test_logs-all_assets', + type: 'data_stream_ilm_policy', + }, { id: 'logs-all_assets.test_logs-0.2.0', type: 'ingest_pipeline', diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/data_stream/test_metrics/elasticsearch/ilm_policy/all_assets.json b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/data_stream/test_metrics/elasticsearch/ilm_policy/all_assets.json new file mode 100644 index 0000000000000..7cf62e890f865 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/data_stream/test_metrics/elasticsearch/ilm_policy/all_assets.json @@ -0,0 +1,15 @@ +{ + "policy": { + "phases": { + "hot": { + "min_age": "0ms", + "actions": { + "rollover": { + "max_size": "50gb", + "max_age": "30d" + } + } + } + } + } +} \ No newline at end of file diff --git a/x-pack/test/functional/apps/discover/async_scripted_fields.js b/x-pack/test/functional/apps/discover/async_scripted_fields.js index 33a64e4f9cdd3..d71603cf3793f 100644 --- a/x-pack/test/functional/apps/discover/async_scripted_fields.js +++ b/x-pack/test/functional/apps/discover/async_scripted_fields.js @@ -18,7 +18,8 @@ export default function ({ getService, getPageObjects }) { const queryBar = getService('queryBar'); const security = getService('security'); - describe('async search with scripted fields', function () { + // Failing: See https://github.com/elastic/kibana/issues/78553 + describe.skip('async search with scripted fields', function () { this.tags(['skipFirefox']); before(async function () { @@ -40,7 +41,7 @@ export default function ({ getService, getPageObjects }) { await security.testUser.restoreDefaults(); }); - it('query should show failed shards pop up', async function () { + it.skip('query should show failed shards pop up', async function () { if (false) { /* If you had to modify the scripted fields, you could un-comment all this, run it, use es_archiver to update 'kibana_scripted_fields_on_logstash' */ @@ -72,7 +73,7 @@ export default function ({ getService, getPageObjects }) { }); }); - it('query return results with valid scripted field', async function () { + it.skip('query return results with valid scripted field', async function () { if (false) { /* the commented-out steps below were used to create the scripted fields in the logstash-* index pattern which are now saved in the esArchive. diff --git a/x-pack/test/functional/apps/uptime/ping_redirects.ts b/x-pack/test/functional/apps/uptime/ping_redirects.ts index b87e8c1748c82..82b9c74c896ff 100644 --- a/x-pack/test/functional/apps/uptime/ping_redirects.ts +++ b/x-pack/test/functional/apps/uptime/ping_redirects.ts @@ -18,7 +18,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const monitor = () => uptime.monitor; - describe('Ping redirects', () => { + // FLAKY: https://github.com/elastic/kibana/issues/84992 + describe.skip('Ping redirects', () => { const start = '~ 15 minutes ago'; const end = 'now'; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts index f7281a1d93a46..16b338c893736 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts @@ -349,7 +349,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await deleteAlerts([createdAlert.id]); }); - it('should delete all selection', async () => { + it.skip('should delete all selection', async () => { const namePrefix = generateUniqueKey(); let count = 0; const createdAlertsFirstPage = await Promise.all( diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts index 6584c5891a8b9..f6cbc52e7a421 100644 --- a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts @@ -22,11 +22,12 @@ export const noopAlertType: AlertType = { name: 'Test: Noop', actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', async executor() {}, producer: 'alerts', }; -export const alwaysFiringAlertType: any = { +export const alwaysFiringAlertType: AlertType = { id: 'test.always-firing', name: 'Always Firing', actionGroups: [ @@ -35,6 +36,7 @@ export const alwaysFiringAlertType: any = { ], defaultActionGroupId: 'default', producer: 'alerts', + minimumLicenseRequired: 'basic', async executor(alertExecutorOptions: any) { const { services, state, params } = alertExecutorOptions; @@ -52,7 +54,7 @@ export const alwaysFiringAlertType: any = { }, }; -export const failingAlertType: any = { +export const failingAlertType: AlertType = { id: 'test.failing', name: 'Test: Failing', actionGroups: [ @@ -63,6 +65,7 @@ export const failingAlertType: any = { ], producer: 'alerts', defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', async executor() { throw new Error('Failed to execute alert type'); }, diff --git a/x-pack/test/licensing_plugin/scenario.ts b/x-pack/test/licensing_plugin/scenario.ts index 9b73a7793c527..b351c7443cbb6 100644 --- a/x-pack/test/licensing_plugin/scenario.ts +++ b/x-pack/test/licensing_plugin/scenario.ts @@ -67,6 +67,31 @@ export function createScenario({ getService, getPageObjects }: FtrProviderContex expect(response.body.trial_was_started).to.be(true); }, + async startEnterprise() { + const response = await esSupertestWithoutAuth + .post('/_license/?acknowledge=true') + .send({ + license: { + uid: '00000000-d3ad-7357-c0d3-000000000000', + type: 'enterprise', + issue_date_in_millis: 1577836800000, + start_date_in_millis: 1577836800000, + // expires 2022-12-31 + expiry_date_in_millis: 1672531199999, + max_resource_units: 250, + max_nodes: null, + issued_to: 'Elastic Internal Use (development environments)', + issuer: 'Elastic', + signature: + 'AAAABQAAAA1gHUVis7hel8b8nNCAAAAAIAo5/x6hrsGh1GqqrJmy4qgmEC7gK0U4zQ6q5ZEMhm4jAAABAKMR+w3KZsMJfG5jNWgZXJLwRmiNqN7k94vKFgRdj1yM+gA9ufhXIn9d01OvFhPjilIqm+fxVjCxXwGKbFRiwtTWnTYjXPuNml+qCFGgUWguWEcVoIW6VU7/lYOqMJ4EB4zOMLe93P267iaDm542aelQrW1OJ69lGGuPBik8v9r1bNZzKBQ99VUr/qoosGDAm0udh2HxWzYoCL5lDML5Niy87xlVCubSSBXdUXzUgdZKKk6pKaMdHswB1gjvEfnwqPxEWAyrV0BCr/T1WehXd7U4p6/zt6sJ6cPh+34AZe9g4+3WPKrZhX4iaSHMDDHn4HNjO72CZ2oi42ZDNnJ37tA=', + }, + }) + .auth('license_manager_user', 'license_manager_user-password') + .expect(200); + + expect(response.body.license_status).to.be('valid'); + }, + async deleteLicense() { const response = await esSupertestWithoutAuth .delete('/_license') diff --git a/x-pack/test/licensing_plugin/server/updates.ts b/x-pack/test/licensing_plugin/server/updates.ts index e24b71939213c..ecfaae5f46620 100644 --- a/x-pack/test/licensing_plugin/server/updates.ts +++ b/x-pack/test/licensing_plugin/server/updates.ts @@ -62,5 +62,13 @@ export default function (ftrContext: FtrProviderContext) { // banner shown only when license expired not just deleted await testSubjects.missingOrFail('licenseExpiredBanner'); }); + + it('properly recognize an enterprise license', async () => { + await scenario.startEnterprise(); + await scenario.waitForPluginToDetectLicenseUpdate(); + + const enterpriseLicense = await scenario.getLicense(); + expect(enterpriseLicense.license?.type).to.be('enterprise'); + }); }); } diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/fleet_integrations.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/fleet_integrations.ts new file mode 100644 index 0000000000000..1f50ba4d460df --- /dev/null +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/fleet_integrations.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const { fleetIntegrations, trustedApps } = getPageObjects(['trustedApps', 'fleetIntegrations']); + const policyTestResources = getService('policyTestResources'); + const testSubjects = getService('testSubjects'); + + describe('When in the Fleet application', function () { + this.tags(['ciGroup7']); + + describe('and on the Endpoint Integration details page', () => { + beforeEach(async () => { + await fleetIntegrations.navigateToIntegrationDetails( + await policyTestResources.getEndpointPkgKey() + ); + }); + + it('should show the Custom tab', async () => { + await fleetIntegrations.integrationDetailCustomTabExistsOrFail(); + }); + + it('should display the endpoint custom content', async () => { + await (await fleetIntegrations.findIntegrationDetailCustomTab()).click(); + await testSubjects.existOrFail('fleetEndpointPackageCustomContent'); + }); + + it('should show the Trusted Apps page when link is clicked', async () => { + await (await fleetIntegrations.findIntegrationDetailCustomTab()).click(); + await (await testSubjects.find('linkToTrustedApps')).click(); + await trustedApps.ensureIsOnTrustedAppsListPage(); + }); + }); + }); +} diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/index.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/index.ts index 3103d461669f1..bb740ef8acb88 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/index.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/index.ts @@ -33,5 +33,6 @@ export default function (providerContext: FtrProviderContext) { loadTestFile(require.resolve('./resolver')); loadTestFile(require.resolve('./endpoint_telemetry')); loadTestFile(require.resolve('./trusted_apps_list')); + loadTestFile(require.resolve('./fleet_integrations')); }); } diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts index 355e494cb459e..1a5c99294c281 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts @@ -553,35 +553,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { } }); - it('should show callout', async () => { - await testSubjects.existOrFail('endpointPackagePolicy_edit'); - }); - - it('should show actions button with expected action items', async () => { - const actionsButton = await pageObjects.ingestManagerCreatePackagePolicy.findEndpointActionsButton(); - await actionsButton.click(); - const menuPanel = await testSubjects.find('endpointActionsMenuPanel'); - const actionItems = await menuPanel.findAllByTagName<'button'>('button'); - const expectedItems = ['Edit Trusted Applications']; - - for (const action of actionItems) { - const buttonText = await action.getVisibleText(); - expect(buttonText).to.be(expectedItems.find((item) => item === buttonText)); - } - }); - - it('should navigate to Trusted Apps', async () => { - await pageObjects.ingestManagerCreatePackagePolicy.selectEndpointAction('trustedApps'); - await pageObjects.trustedApps.ensureIsOnTrustedAppsListPage(); - }); - - it('should show the back button on Trusted Apps Page and navigate back to fleet', async () => { - await pageObjects.ingestManagerCreatePackagePolicy.selectEndpointAction('trustedApps'); - const backButton = await pageObjects.trustedApps.findTrustedAppsListPageBackButton(); - await backButton.click(); - await pageObjects.ingestManagerCreatePackagePolicy.ensureOnEditPageOrFail(); - }); - it('should show the endpoint policy form', async () => { await testSubjects.existOrFail('endpointIntegrationPolicyForm'); }); diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/resolver.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/resolver.ts index 1d7b2861a1a31..debde49e35871 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/resolver.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/resolver.ts @@ -12,10 +12,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const esArchiver = getService('esArchiver'); const browser = getService('browser'); - const queryBar = getService('queryBar'); - // FLAKY: https://github.com/elastic/kibana/issues/85085 - describe.skip('Endpoint Event Resolver', function () { + describe('Endpoint Event Resolver', function () { before(async () => { await pageObjects.hosts.navigateToSecurityHostsPage(); await pageObjects.common.dismissBanner(); @@ -28,7 +26,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { before(async () => { await esArchiver.load('empty_kibana'); await esArchiver.load('endpoint/resolver_tree/functions', { useCreate: true }); - await pageObjects.hosts.navigateToEventsPanel(); await pageObjects.hosts.executeQueryAndOpenResolver('event.dataset : endpoint.events.file'); }); after(async () => { @@ -194,114 +191,74 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { } await (await testSubjects.find('resolver:graph-controls:zoom-in')).click(); }); - - it('Check Related Events for event.file Node', async () => { - const expectedData = [ - '17 authentication', - '1 registry', - '17 session', - '8 file', - '1 registry', - ]; - await pageObjects.hosts.runNodeEvents(expectedData); - }); }); - describe('Resolver Tree events', function () { - const expectedData = [ - '17 authentication', - '1 registry', - '17 session', - '80 registry', - '8 network', - '60 registry', - ]; + describe('node related event pills', function () { + /** + * Verifies that the pills of a node have the correct text. + * + * @param id the node ID to verify the pills for. + * @param expectedPills a map of expected pills for all nodes + */ + const verifyPills = async (id: string, expectedPills: Set) => { + const relatedEventPills = await pageObjects.hosts.findNodePills(id); + expect(relatedEventPills.length).to.equal(expectedPills.size); + for (const pill of relatedEventPills) { + const pillText = await pill._webElement.getText(); + // check that we have the pill text in our expected map + expect(expectedPills.has(pillText)).to.equal(true); + } + }; + before(async () => { await esArchiver.load('empty_kibana'); - await esArchiver.load('endpoint/resolver_tree/events', { useCreate: true }); - await queryBar.setQuery(''); - await queryBar.submitQuery(); + await esArchiver.load('endpoint/resolver_tree/alert_events', { useCreate: true }); }); after(async () => { await pageObjects.hosts.deleteDataStreams(); }); - it('Check Related Events for event.process Node', async () => { - await pageObjects.hosts.navigateToEventsPanel(); - await pageObjects.hosts.executeQueryAndOpenResolver( - 'event.dataset : endpoint.events.process' - ); - await pageObjects.hosts.runNodeEvents(expectedData); - }); + describe('endpoint.alerts filter', () => { + before(async () => { + await pageObjects.hosts.executeQueryAndOpenResolver('event.dataset : endpoint.alerts'); + await pageObjects.hosts.clickZoomOut(); + await browser.setWindowSize(2100, 1500); + }); - it('Check Related Events for event.security Node', async () => { - await pageObjects.hosts.navigateToEventsPanel(); - await pageObjects.hosts.executeQueryAndOpenResolver( - 'event.dataset : endpoint.events.security' - ); - await pageObjects.hosts.runNodeEvents(expectedData); - }); + it('has the correct pill text', async () => { + const expectedData: Map> = new Map([ + [ + 'MTk0YzBmOTgtNjA4My1jNWE4LTYzNjYtZjVkNzI2YWU2YmIyLTc2MzYtMTMyNDc2MTQ0NDIuOTU5MTE2NjAw', + new Set(['1 library']), + ], + [ + 'MTk0YzBmOTgtNjA4My1jNWE4LTYzNjYtZjVkNzI2YWU2YmIyLTMxMTYtMTMyNDcyNDk0MjQuOTg4ODI4NjAw', + new Set(['157 file', '520 registry']), + ], + [ + 'MTk0YzBmOTgtNjA4My1jNWE4LTYzNjYtZjVkNzI2YWU2YmIyLTUwODQtMTMyNDc2MTQ0NDIuOTcyODQ3MjAw', + new Set(), + ], + [ + 'MTk0YzBmOTgtNjA4My1jNWE4LTYzNjYtZjVkNzI2YWU2YmIyLTg2OTYtMTMyNDc2MTQ0MjEuNjc1MzY0OTAw', + new Set(['3 file']), + ], + [ + 'MTk0YzBmOTgtNjA4My1jNWE4LTYzNjYtZjVkNzI2YWU2YmIyLTcyNjAtMTMyNDc2MTQ0MjIuMjQwNDI2MTAw', + new Set(), + ], + [ + 'MTk0YzBmOTgtNjA4My1jNWE4LTYzNjYtZjVkNzI2YWU2YmIyLTczMDAtMTMyNDc2MTQ0MjEuNjg2NzI4NTAw', + new Set(), + ], + ]); - it('Check Related Events for event.registry Node', async () => { - await pageObjects.hosts.navigateToEventsPanel(); - await pageObjects.hosts.executeQueryAndOpenResolver( - 'event.dataset : endpoint.events.registry' - ); - await pageObjects.hosts.runNodeEvents(expectedData); - }); - - it('Check Related Events for event.network Node', async () => { - await pageObjects.hosts.navigateToEventsPanel(); - await pageObjects.hosts.executeQueryAndOpenResolver( - 'event.dataset : endpoint.events.network' - ); - await pageObjects.hosts.runNodeEvents(expectedData); - }); - - it('Check Related Events for event.library Node', async () => { - await esArchiver.load('empty_kibana'); - await esArchiver.load('endpoint/resolver_tree/library_events', { useCreate: true }); - await queryBar.setQuery(''); - await queryBar.submitQuery(); - const expectedLibraryData = [ - '1 authentication', - '1 session', - '329 network', - '1 library', - '1 library', - ]; - await pageObjects.hosts.navigateToEventsPanel(); - await pageObjects.hosts.executeQueryAndOpenResolver( - 'event.dataset : endpoint.events.library' - ); - // This lines will move the resolver view for clear visibility of the related events. - for (let i = 0; i < 7; i++) { - await (await testSubjects.find('resolver:graph-controls:west-button')).click(); - } - await pageObjects.hosts.runNodeEvents(expectedLibraryData); - }); - - it('Check Related Events for event.alert Node', async () => { - await esArchiver.load('empty_kibana'); - await esArchiver.load('endpoint/resolver_tree/alert_events', { useCreate: true }); - await queryBar.setQuery(''); - await queryBar.submitQuery(); - const expectedAlertData = [ - '1 library', - '157 file', - '520 registry', - '3 file', - '5 library', - '5 library', - ]; - await pageObjects.hosts.navigateToEventsPanel(); - await pageObjects.hosts.executeQueryAndOpenResolver('event.dataset : endpoint.alerts'); - await (await testSubjects.find('resolver:graph-controls:zoom-out')).click(); - await browser.setWindowSize(2100, 1500); - for (let i = 0; i < 2; i++) { - await (await testSubjects.find('resolver:graph-controls:east-button')).click(); - } - await pageObjects.hosts.runNodeEvents(expectedAlertData); + for (const [id, expectedPills] of expectedData.entries()) { + // center the node in the view + await pageObjects.hosts.clickNodeLinkInPanel(id); + await verifyPills(id, expectedPills); + } + }); }); }); }); 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 new file mode 100644 index 0000000000000..3c747afab48c8 --- /dev/null +++ b/x-pack/test/security_solution_endpoint/page_objects/fleet_integrations_page.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; +import { PLUGIN_ID } from '../../../plugins/fleet/common'; + +// NOTE: import path below should be the deep path to the actual module - else we get CI errors +import { pagePathGetters } from '../../../plugins/fleet/public/applications/fleet/constants/page_paths'; + +export function FleetIntegrations({ getService, getPageObjects }: FtrProviderContext) { + const pageObjects = getPageObjects(['common']); + const testSubjects = getService('testSubjects'); + + return { + async navigateToIntegrationDetails(pkgkey: string) { + await pageObjects.common.navigateToApp(PLUGIN_ID, { + hash: pagePathGetters.integration_details({ pkgkey }), + }); + }, + + async integrationDetailCustomTabExistsOrFail() { + await testSubjects.existOrFail('tab-custom'); + }, + + async findIntegrationDetailCustomTab() { + return await testSubjects.find('tab-custom'); + }, + }; +} diff --git a/x-pack/test/security_solution_endpoint/page_objects/hosts_page.ts b/x-pack/test/security_solution_endpoint/page_objects/hosts_page.ts index c76a5a7c22f60..09160a6ada15a 100644 --- a/x-pack/test/security_solution_endpoint/page_objects/hosts_page.ts +++ b/x-pack/test/security_solution_endpoint/page_objects/hosts_page.ts @@ -4,13 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; +import { WebElementWrapper } from 'test/functional/services/lib/web_element_wrapper'; +import { nudgeAnimationDuration } from '../../../plugins/security_solution/public/resolver/store/camera/scaling_constants'; import { FtrProviderContext } from '../ftr_provider_context'; -import { deleteEventsStream } from '../../security_solution_endpoint_api_int/apis/data_stream_helper'; -import { deleteAlertsStream } from '../../security_solution_endpoint_api_int/apis/data_stream_helper'; -import { deleteMetadataStream } from '../../security_solution_endpoint_api_int/apis/data_stream_helper'; -import { deletePolicyStream } from '../../security_solution_endpoint_api_int/apis/data_stream_helper'; -import { deleteTelemetryStream } from '../../security_solution_endpoint_api_int/apis/data_stream_helper'; +import { + deleteEventsStream, + deleteAlertsStream, + deleteMetadataStream, + deletePolicyStream, + deleteTelemetryStream, +} from '../../security_solution_endpoint_api_int/apis/data_stream_helper'; + export interface DataStyle { left: string; top: string; @@ -22,6 +26,109 @@ export function SecurityHostsPageProvider({ getService, getPageObjects }: FtrPro const pageObjects = getPageObjects(['common', 'header']); const testSubjects = getService('testSubjects'); const queryBar = getService('queryBar'); + const find = getService('find'); + + /** + * Returns the node IDs for the visible nodes in the resolver graph. + */ + const findVisibleNodeIDs = async (): Promise => { + const visibleNodes = await testSubjects.findAll('resolver:node'); + return Promise.all( + visibleNodes.map(async (node: WebElementWrapper) => { + return node.getAttribute('data-test-resolver-node-id'); + }) + ); + }; + + /** + * This assumes you are on the process list in the panel and will find and click the node + * with the given ID to bring it into view in the graph. + * + * @param id the ID of the node to find and click. + */ + const clickNodeLinkInPanel = async (id: string): Promise => { + await navigateToProcessListInPanel(); + const panelNodeButton = await find.byCssSelector( + `[data-test-subj='resolver:node-list:node-link'][data-test-node-id='${id}']` + ); + + await panelNodeButton?.click(); + // ensure that we wait longer than the animation time + await pageObjects.common.sleep(nudgeAnimationDuration * 2); + }; + + /** + * Finds all the pills for a particular node. + * + * @param id the ID of the node + */ + const findNodePills = async (id: string): Promise => { + return testSubjects.findAllDescendant( + 'resolver:map:node-submenu-item', + await find.byCssSelector( + `[data-test-subj='resolver:node'][data-test-resolver-node-id='${id}']` + ) + ); + }; + + /** + * Navigate back to the process list view in the panel. + */ + const navigateToProcessListInPanel = async () => { + const [ + isOnNodeListPage, + isOnCategoryPage, + isOnNodeDetailsPage, + isOnRelatedEventDetailsPage, + ] = await Promise.all([ + testSubjects.exists('resolver:node-list', { timeout: 1 }), + testSubjects.exists('resolver:node-events-in-category:breadcrumbs:node-list-link', { + timeout: 1, + }), + testSubjects.exists('resolver:node-detail:breadcrumbs:node-list-link', { timeout: 1 }), + testSubjects.exists('resolver:event-detail:breadcrumbs:node-list-link', { timeout: 1 }), + ]); + + if (isOnNodeListPage) { + return; + } else if (isOnCategoryPage) { + await ( + await testSubjects.find('resolver:node-events-in-category:breadcrumbs:node-list-link') + ).click(); + } else if (isOnNodeDetailsPage) { + await (await testSubjects.find('resolver:node-detail:breadcrumbs:node-list-link')).click(); + } else if (isOnRelatedEventDetailsPage) { + await (await testSubjects.find('resolver:event-detail:breadcrumbs:node-list-link')).click(); + } else { + // unknown page + return; + } + + await pageObjects.common.sleep(100); + }; + + /** + * Click the zoom out control. + */ + const clickZoomOut = async () => { + await (await testSubjects.find('resolver:graph-controls:zoom-out')).click(); + }; + + /** + * Navigate to Events Panel + */ + const navigateToEventsPanel = async () => { + const isFullScreen = await testSubjects.exists('exit-full-screen', { timeout: 400 }); + if (isFullScreen) { + await (await testSubjects.find('exit-full-screen')).click(); + } + + if (!(await testSubjects.exists('investigate-in-resolver-button', { timeout: 400 }))) { + await (await testSubjects.find('navigation-hosts')).click(); + await testSubjects.click('navigation-events'); + await testSubjects.existOrFail('event'); + } + }; /** * @function parseStyles @@ -54,101 +161,82 @@ export function SecurityHostsPageProvider({ getService, getPageObjects }: FtrPro }), {} ); + + /** + * Navigate to the Security Hosts page + */ + const navigateToSecurityHostsPage = async () => { + await pageObjects.common.navigateToUrlWithBrowserHistory('security', '/hosts/AllHosts'); + await pageObjects.header.waitUntilLoadingHasFinished(); + }; + + /** + * Finds a table and returns the data in a nested array with row 0 is the headers if they exist. + * It uses euiTableCellContent to avoid polluting the array data with the euiTableRowCell__mobileHeader data. + * @param dataTestSubj + * @param element + * @returns Promise + */ + const getEndpointEventResolverNodeData = async (dataTestSubj: string, element: string) => { + await testSubjects.exists(dataTestSubj); + const Elements = await testSubjects.findAll(dataTestSubj); + const $ = []; + for (const value of Elements) { + $.push(await value.getAttribute(element)); + } + return $; + }; + + /** + * Gets a array of not parsed styles and returns the Array of parsed styles. + * @returns Promise + */ + const parseStyles = async () => { + const tableData = await getEndpointEventResolverNodeData('resolver:node', 'style'); + const styles: DataStyle[] = []; + for (let i = 1; i < tableData.length; i++) { + const eachStyle = parseStyle(tableData[i]); + styles.push({ + top: eachStyle.top ?? '', + height: eachStyle.height ?? '', + left: eachStyle.left ?? '', + width: eachStyle.width ?? '', + }); + } + return styles; + }; + /** + * Deletes DataStreams from Index Management. + */ + const deleteDataStreams = async () => { + await deleteEventsStream(getService); + await deleteAlertsStream(getService); + await deletePolicyStream(getService); + await deleteMetadataStream(getService); + await deleteTelemetryStream(getService); + }; + + /** + * execute Query And Open Resolver + */ + const executeQueryAndOpenResolver = async (query: string) => { + await navigateToEventsPanel(); + await queryBar.setQuery(query); + await queryBar.submitQuery(); + await testSubjects.click('full-screen'); + await testSubjects.click('investigate-in-resolver-button'); + }; + return { - /** - * Navigate to the Security Hosts page - */ - async navigateToSecurityHostsPage() { - await pageObjects.common.navigateToUrlWithBrowserHistory('security', '/hosts/AllHosts'); - await pageObjects.header.waitUntilLoadingHasFinished(); - }, - /** - * Finds a table and returns the data in a nested array with row 0 is the headers if they exist. - * It uses euiTableCellContent to avoid poluting the array data with the euiTableRowCell__mobileHeader data. - * @param dataTestSubj - * @param element - * @returns Promise - */ - async getEndpointEventResolverNodeData(dataTestSubj: string, element: string) { - await testSubjects.exists(dataTestSubj); - const Elements = await testSubjects.findAll(dataTestSubj); - const $ = []; - for (const value of Elements) { - $.push(await value.getAttribute(element)); - } - return $; - }, - - /** - * Gets a array of not parsed styles and returns the Array of parsed styles. - * @returns Promise - */ - async parseStyles() { - const tableData = await this.getEndpointEventResolverNodeData('resolver:node', 'style'); - const styles: DataStyle[] = []; - for (let i = 1; i < tableData.length; i++) { - const eachStyle = parseStyle(tableData[i]); - styles.push({ - top: eachStyle.top ?? '', - height: eachStyle.height ?? '', - left: eachStyle.left ?? '', - width: eachStyle.width ?? '', - }); - } - return styles; - }, - /** - * Deletes DataStreams from Index Management. - */ - async deleteDataStreams() { - await deleteEventsStream(getService); - await deleteAlertsStream(getService); - await deletePolicyStream(getService); - await deleteMetadataStream(getService); - await deleteTelemetryStream(getService); - }, - /** - * Runs Nodes Events - */ - async runNodeEvents(expectedData: string[]) { - await testSubjects.exists('resolver:submenu:button', { timeout: 400 }); - const NodeSubmenuButtons = await testSubjects.findAll('resolver:submenu:button'); - for (let b = 0; b < NodeSubmenuButtons.length; b++) { - await (await testSubjects.findAll('resolver:submenu:button'))[b].click(); - } - await testSubjects.exists('resolver:map:node-submenu-item', { timeout: 400 }); - const NodeSubmenuItems = await testSubjects.findAll('resolver:map:node-submenu-item'); - for (let i = 0; i < NodeSubmenuItems.length; i++) { - await (await testSubjects.findAll('resolver:map:node-submenu-item'))[i].click(); - const Events = await testSubjects.findAll('resolver:map:node-submenu-item'); - // this sleep is for the AMP enabled run - await pageObjects.common.sleep(300); - const EventName = await Events[i]._webElement.getText(); - const LinkText = await testSubjects.find('resolver:breadcrumbs:last'); - const linkText = await LinkText._webElement.getText(); - expect(EventName).to.equal(linkText); - expect(EventName).to.equal(expectedData[i]); - } - await testSubjects.click('full-screen'); - }, - /** - * Navigate to Events Panel - */ - async navigateToEventsPanel() { - if (!(await testSubjects.exists('investigate-in-resolver-button', { timeout: 400 }))) { - await (await testSubjects.find('navigation-hosts')).click(); - await testSubjects.click('navigation-events'); - await testSubjects.existOrFail('event'); - } - }, - /** - * execute Query And Open Resolver - */ - async executeQueryAndOpenResolver(query: string) { - await queryBar.setQuery(query); - await queryBar.submitQuery(); - await testSubjects.click('full-screen'); - await testSubjects.click('investigate-in-resolver-button'); - }, + navigateToProcessListInPanel, + findNodePills, + clickNodeLinkInPanel, + findVisibleNodeIDs, + clickZoomOut, + navigateToEventsPanel, + navigateToSecurityHostsPage, + parseStyles, + deleteDataStreams, + executeQueryAndOpenResolver, }; } diff --git a/x-pack/test/security_solution_endpoint/page_objects/index.ts b/x-pack/test/security_solution_endpoint/page_objects/index.ts index 3664a2033d8b7..2fb441464e7ee 100644 --- a/x-pack/test/security_solution_endpoint/page_objects/index.ts +++ b/x-pack/test/security_solution_endpoint/page_objects/index.ts @@ -11,6 +11,7 @@ import { TrustedAppsPageProvider } from './trusted_apps_page'; import { EndpointPageUtils } from './page_utils'; import { IngestManagerCreatePackagePolicy } from './ingest_manager_create_package_policy_page'; import { SecurityHostsPageProvider } from './hosts_page'; +import { FleetIntegrations } from './fleet_integrations_page'; export const pageObjects = { ...xpackFunctionalPageObjects, @@ -20,4 +21,5 @@ export const pageObjects = { endpointPageUtils: EndpointPageUtils, ingestManagerCreatePackagePolicy: IngestManagerCreatePackagePolicy, hosts: SecurityHostsPageProvider, + fleetIntegrations: FleetIntegrations, }; diff --git a/x-pack/test/security_solution_endpoint/services/endpoint_policy.ts b/x-pack/test/security_solution_endpoint/services/endpoint_policy.ts index 1b1d0bf96a187..5f54ab2539c5d 100644 --- a/x-pack/test/security_solution_endpoint/services/endpoint_policy.ts +++ b/x-pack/test/security_solution_endpoint/services/endpoint_policy.ts @@ -19,6 +19,9 @@ import { import { factory as policyConfigFactory } from '../../../plugins/security_solution/common/endpoint/models/policy_config'; import { Immutable } from '../../../plugins/security_solution/common/endpoint/types'; +// NOTE: import path below should be the deep path to the actual module - else we get CI errors +import { pkgKeyFromPackageInfo } from '../../../plugins/fleet/public/applications/fleet/services/pkg_key_from_package_info'; + const INGEST_API_ROOT = '/api/fleet'; const INGEST_API_AGENT_POLICIES = `${INGEST_API_ROOT}/agent_policies`; const INGEST_API_AGENT_POLICIES_DELETE = `${INGEST_API_AGENT_POLICIES}/delete`; @@ -106,6 +109,14 @@ export function EndpointPolicyTestResourcesProvider({ getService }: FtrProviderC })(); return { + /** + * Returns the endpoint package key for the currently installed package. This `pkgkey` can then + * be used to build URLs for Fleet pages or APIs + */ + async getEndpointPkgKey() { + return pkgKeyFromPackageInfo((await retrieveEndpointPackageInfo())!); + }, + /** * Retrieves the full Agent policy, which mirrors what the Elastic Agent would get * once they checkin. diff --git a/yarn.lock b/yarn.lock index cc7edd2aaeccb..2cf4f36f7e593 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1439,10 +1439,10 @@ resolved "https://registry.yarnpkg.com/@elastic/eslint-plugin-eui/-/eslint-plugin-eui-0.0.2.tgz#56b9ef03984a05cc213772ae3713ea8ef47b0314" integrity sha512-IoxURM5zraoQ7C8f+mJb9HYSENiZGgRVcG4tLQxE61yHNNRDXtGDWTZh8N1KIHcsqN1CEPETjuzBXkJYF/fDiQ== -"@elastic/eui@30.5.1": - version "30.5.1" - resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-30.5.1.tgz#c9782c2f4763d563de6afcc2fc56d81c1e5b183c" - integrity sha512-W8rW49prYG0XHNdMWGTxNW50Kef3/fh+IL5mzMOKLao1W4h0F45efIDbnIHyjGl//akknIIEa6bwdTU4dmLBgA== +"@elastic/eui@30.6.0": + version "30.6.0" + resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-30.6.0.tgz#6653223223f52407ac05303825d9bd08382df1d5" + integrity sha512-40Jiy54MpJAx3lD3NSZZLkMkVySwKpX6RxIKnvT3somE95pwIjXrWB688m2nL2g05y7kNhjrhwfdctVzNXZENA== dependencies: "@types/chroma-js" "^2.0.0" "@types/lodash" "^4.14.160" @@ -1476,6 +1476,7 @@ tabbable "^3.0.0" text-diff "^1.0.1" unified "^9.2.0" + url-parse "^1.4.7" uuid "^8.3.0" vfile "^4.2.0" @@ -27898,7 +27899,7 @@ url-parse-lax@^3.0.0: dependencies: prepend-http "^2.0.0" -url-parse@^1.4.3: +url-parse@^1.4.3, url-parse@^1.4.7: version "1.4.7" resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.4.7.tgz#a8a83535e8c00a316e403a5db4ac1b9b853ae278" integrity sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg==