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/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/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/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 3afd4a5cb98e8..07c07728d9084 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -135,9 +135,18 @@ export class DocLinksService { }, ml: { guide: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/index.html`, + aggregations: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-configuring-aggregation.html`, anomalyDetection: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/xpack-ml.html`, anomalyDetectionJobs: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-jobs.html`, + anomalyDetectionJobTips: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/create-jobs.html#job-tips`, + calendars: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-calendars.html`, + classificationEvaluation: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfanalytics-evaluate.html#ml-dfanalytics-classification`, + customRules: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-rules.html`, + customUrls: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-configuring-url.html`, dataFrameAnalytics: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfanalytics.html`, + featureImportance: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-feature-importance.html`, + outlierDetectionRoc: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfanalytics-evaluate.html#ml-dfanalytics-roc`, + regressionEvaluation: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfanalytics-evaluate.html#ml-dfanalytics-regression-evaluation`, }, visualize: { guide: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/visualize.html`, @@ -248,12 +257,7 @@ export interface DocLinksStart { 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/src/core/public/public.api.md b/src/core/public/public.api.md index 3852792547062..50e8cca75737e 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -573,12 +573,7 @@ export interface DocLinksStart { 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/src/dev/run_find_plugins_with_circular_deps.ts b/src/dev/run_find_plugins_with_circular_deps.ts index 65a03a87525d7..f4662820a1fb0 100644 --- a/src/dev/run_find_plugins_with_circular_deps.ts +++ b/src/dev/run_find_plugins_with_circular_deps.ts @@ -42,6 +42,7 @@ const allowedList: CircularDepList = new Set([ 'src/plugins/vis_default_editor -> src/plugins/visualize', 'src/plugins/visualizations -> src/plugins/visualize', 'x-pack/plugins/actions -> x-pack/plugins/case', + 'x-pack/plugins/case -> x-pack/plugins/security_solution', 'x-pack/plugins/apm -> x-pack/plugins/infra', 'x-pack/plugins/lists -> x-pack/plugins/security_solution', 'x-pack/plugins/security -> x-pack/plugins/spaces', diff --git a/src/plugins/data/common/field_formats/field_format.ts b/src/plugins/data/common/field_formats/field_format.ts index 15ecf6e4fc3ef..e999e80d26e98 100644 --- a/src/plugins/data/common/field_formats/field_format.ts +++ b/src/plugins/data/common/field_formats/field_format.ts @@ -83,6 +83,7 @@ export abstract class FieldFormat { * @private */ public type: any = this.constructor; + public allowsNumericalAggregations?: boolean; protected readonly _params: any; protected getConfig: FieldFormatsGetConfigFn | undefined; diff --git a/src/plugins/data/common/field_formats/types.ts b/src/plugins/data/common/field_formats/types.ts index 5a830586b8d05..90d2c1ed38245 100644 --- a/src/plugins/data/common/field_formats/types.ts +++ b/src/plugins/data/common/field_formats/types.ts @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ -import { PublicMethodsOf } from '@kbn/utility-types'; import { GetConfigFn } from '../types'; import { FieldFormat } from './field_format'; import { FieldFormatsRegistry } from './field_formats_registry'; @@ -77,7 +76,7 @@ export interface FieldFormatConfig { export type FieldFormatsGetConfigFn = GetConfigFn; -export type IFieldFormat = PublicMethodsOf; +export type IFieldFormat = FieldFormat; /** * @string id type is needed for creating custom converters. diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 35546c33aaa80..656034546d02f 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -850,6 +850,8 @@ export const extractSearchSourceReferences: (state: SearchSourceFields) => [Sear export abstract class FieldFormat { // Warning: (ae-forgotten-export) The symbol "IFieldFormatMetaParams" needs to be exported by the entry point index.d.ts constructor(_params?: IFieldFormatMetaParams, getConfig?: FieldFormatsGetConfigFn); + // (undocumented) + allowsNumericalAggregations?: boolean; // Warning: (ae-forgotten-export) The symbol "HtmlContextTypeOptions" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "TextContextTypeOptions" needs to be exported by the entry point index.d.ts convert(value: any, contentType?: FieldFormatsContentType, options?: HtmlContextTypeOptions | TextContextTypeOptions): string; @@ -1091,7 +1093,7 @@ export type IEsSearchResponse = IKibanaSearchResponse; +export type IFieldFormat = FieldFormat; // Warning: (ae-missing-release-tag) "IFieldFormatsRegistry" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // diff --git a/src/plugins/discover/public/__mocks__/es_hits.ts b/src/plugins/discover/public/__mocks__/es_hits.ts new file mode 100644 index 0000000000000..e282bdbd1ca93 --- /dev/null +++ b/src/plugins/discover/public/__mocks__/es_hits.ts @@ -0,0 +1,55 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +export const esHits = [ + { + _index: 'i', + _id: '1', + _score: 1, + _type: '_doc', + _source: { date: '2020-20-01T12:12:12.123', message: 'test1', bytes: 20 }, + }, + { + _index: 'i', + _id: '2', + _score: 1, + _type: '_doc', + _source: { date: '2020-20-01T12:12:12.124', name: 'test2', extension: 'jpg' }, + }, + { + _index: 'i', + _id: '3', + _score: 1, + _type: '_doc', + _source: { date: '2020-20-01T12:12:12.124', name: 'test3', extension: 'gif', bytes: 50 }, + }, + { + _index: 'i', + _id: '4', + _score: 1, + _type: '_doc', + _source: { date: '2020-20-01T12:12:12.125', name: 'test4', extension: 'png', bytes: 50 }, + }, + { + _index: 'i', + _id: '5', + _score: 1, + _type: '_doc', + _source: { date: '2020-20-01T12:12:12.128', name: 'test5', extension: 'doc', bytes: 50 }, + }, +]; diff --git a/src/plugins/discover/public/__mocks__/index_pattern.ts b/src/plugins/discover/public/__mocks__/index_pattern.ts index 696079ec72a73..706118cb71350 100644 --- a/src/plugins/discover/public/__mocks__/index_pattern.ts +++ b/src/plugins/discover/public/__mocks__/index_pattern.ts @@ -17,8 +17,9 @@ * under the License. */ -import { IndexPattern, indexPatterns } from '../kibana_services'; import { IIndexPatternFieldList } from '../../../data/common/index_patterns/fields'; +import { IndexPattern } from '../../../data/common'; +import { indexPatterns } from '../../../data/public'; const fields = [ { @@ -67,8 +68,10 @@ const indexPattern = ({ getComputedFields: () => ({}), getSourceFiltering: () => ({}), getFieldByName: () => ({}), + timeFieldName: '', } as unknown) as IndexPattern; indexPattern.flattenHit = indexPatterns.flattenHitWrapper(indexPattern, indexPattern.metaFields); +indexPattern.isTimeBased = () => !!indexPattern.timeFieldName; export const indexPatternMock = indexPattern; diff --git a/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts b/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts new file mode 100644 index 0000000000000..c898ab1112549 --- /dev/null +++ b/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts @@ -0,0 +1,83 @@ +/* + * 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 { IIndexPatternFieldList } from '../../../data/common/index_patterns/fields'; +import { IndexPattern } from '../../../data/common'; +import { indexPatterns } from '../../../data/public'; + +const fields = [ + { + name: '_index', + type: 'string', + scripted: false, + filterable: true, + }, + { + name: 'timestamp', + type: 'date', + scripted: false, + filterable: true, + }, + { + name: 'message', + type: 'string', + scripted: false, + filterable: false, + }, + { + name: 'extension', + type: 'string', + scripted: false, + filterable: true, + }, + { + name: 'bytes', + type: 'number', + scripted: false, + filterable: true, + }, + { + name: 'scripted', + type: 'number', + scripted: true, + filterable: false, + }, +] as IIndexPatternFieldList; + +fields.getByName = (name: string) => { + return fields.find((field) => field.name === name); +}; + +const indexPattern = ({ + id: 'index-pattern-with-timefield-id', + title: 'index-pattern-without-timefield', + metaFields: ['_index', '_score'], + flattenHit: undefined, + formatHit: jest.fn((hit) => hit._source), + fields, + getComputedFields: () => ({}), + getSourceFiltering: () => ({}), + getFieldByName: () => ({}), + timeFieldName: 'timestamp', +} as unknown) as IndexPattern; + +indexPattern.flattenHit = indexPatterns.flattenHitWrapper(indexPattern, indexPattern.metaFields); +indexPattern.isTimeBased = () => !!indexPattern.timeFieldName; + +export const indexPatternWithTimefieldMock = indexPattern; diff --git a/src/plugins/discover/public/__mocks__/ui_settings.ts b/src/plugins/discover/public/__mocks__/ui_settings.ts new file mode 100644 index 0000000000000..8454907e9d6c1 --- /dev/null +++ b/src/plugins/discover/public/__mocks__/ui_settings.ts @@ -0,0 +1,28 @@ +/* + * 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 { IUiSettingsClient } from 'kibana/public'; +import { SAMPLE_SIZE_SETTING } from '../../common'; + +export const uiSettingsMock = ({ + get: (key: string) => { + if (key === SAMPLE_SIZE_SETTING) { + return 10; + } + }, +} as unknown) as IUiSettingsClient; diff --git a/src/plugins/discover/public/application/components/discover_legacy.test.tsx b/src/plugins/discover/public/application/components/discover_legacy.test.tsx new file mode 100644 index 0000000000000..e2f4ba7ab6e2e --- /dev/null +++ b/src/plugins/discover/public/application/components/discover_legacy.test.tsx @@ -0,0 +1,130 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { shallowWithIntl } from '@kbn/test/jest'; +import { DiscoverLegacy } from './discover_legacy'; +import { inspectorPluginMock } from '../../../../inspector/public/mocks'; +import { esHits } from '../../__mocks__/es_hits'; +import { indexPatternMock } from '../../__mocks__/index_pattern'; +import { getTopNavLinks } from './top_nav/get_top_nav_links'; +import { DiscoverServices } from '../../build_services'; +import { GetStateReturn } from '../angular/discover_state'; +import { savedSearchMock } from '../../__mocks__/saved_search'; +import { createSearchSourceMock } from '../../../../data/common/search/search_source/mocks'; +import { dataPluginMock } from '../../../../data/public/mocks'; +import { createFilterManagerMock } from '../../../../data/public/query/filter_manager/filter_manager.mock'; +import { uiSettingsMock } from '../../__mocks__/ui_settings'; +import { IndexPattern, IndexPatternAttributes } from '../../../../data/common/index_patterns'; +import { SavedObject } from '../../../../../core/types'; +import { navigationPluginMock } from '../../../../navigation/public/mocks'; +import { indexPatternWithTimefieldMock } from '../../__mocks__/index_pattern_with_timefield'; +import { calcFieldCounts } from '../helpers/calc_field_counts'; + +const mockNavigation = navigationPluginMock.createStartContract(); + +jest.mock('../../kibana_services', () => { + return { + getServices: () => ({ + metadata: { + branch: 'test', + }, + capabilities: { + discover: { + save: true, + }, + }, + navigation: mockNavigation, + }), + }; +}); + +function getProps(indexPattern: IndexPattern) { + const searchSourceMock = createSearchSourceMock({}); + const state = ({} as unknown) as GetStateReturn; + const services = ({ + capabilities: { + discover: { + save: true, + }, + }, + } as unknown) as DiscoverServices; + + return { + addColumn: jest.fn(), + fetch: jest.fn(), + fetchCounter: 0, + fetchError: undefined, + fieldCounts: calcFieldCounts({}, esHits, indexPattern), + hits: esHits.length, + indexPattern, + minimumVisibleRows: 10, + onAddFilter: jest.fn(), + onChangeInterval: jest.fn(), + onMoveColumn: jest.fn(), + onRemoveColumn: jest.fn(), + onSetColumns: jest.fn(), + onSkipBottomButtonClick: jest.fn(), + onSort: jest.fn(), + opts: { + config: uiSettingsMock, + data: dataPluginMock.createStartContract(), + fixedScroll: jest.fn(), + filterManager: createFilterManagerMock(), + indexPatternList: (indexPattern as unknown) as Array>, + sampleSize: 10, + savedSearch: savedSearchMock, + setHeaderActionMenu: jest.fn(), + timefield: indexPattern.timeFieldName || '', + setAppState: jest.fn(), + }, + resetQuery: jest.fn(), + resultState: 'ready', + rows: esHits, + searchSource: searchSourceMock, + setIndexPattern: jest.fn(), + showSaveQuery: true, + state: { columns: [] }, + timefilterUpdateHandler: jest.fn(), + topNavMenu: getTopNavLinks({ + getFieldCounts: jest.fn(), + indexPattern, + inspectorAdapters: inspectorPluginMock, + navigateTo: jest.fn(), + savedSearch: savedSearchMock, + services, + state, + }), + updateQuery: jest.fn(), + updateSavedQueryId: jest.fn(), + }; +} + +describe('Descover legacy component', () => { + test('selected index pattern without time field displays no chart toggle', () => { + const component = shallowWithIntl(); + expect(component.find('[data-test-subj="discoverChartToggle"]').length).toBe(0); + }); + test('selected index pattern with time field displays chart toggle', () => { + const component = shallowWithIntl( + + ); + expect(component.find('[data-test-subj="discoverChartToggle"]').length).toBe(1); + }); +}); diff --git a/src/plugins/discover/public/application/components/discover_legacy.tsx b/src/plugins/discover/public/application/components/discover_legacy.tsx index 56f8fa46a9f69..d228be66990bd 100644 --- a/src/plugins/discover/public/application/components/discover_legacy.tsx +++ b/src/plugins/discover/public/application/components/discover_legacy.tsx @@ -66,9 +66,9 @@ export interface DiscoverProps { addColumn: (column: string) => void; fetch: () => void; fetchCounter: number; - fetchError: Error; + fetchError?: Error; fieldCounts: Record; - histogramData: Chart; + histogramData?: Chart; hits: number; indexPattern: IndexPattern; minimumVisibleRows: number; @@ -266,23 +266,26 @@ export function DiscoverLegacy({ /> )} - - { - toggleChart(!toggleOn); - }} - > - {toggleOn - ? i18n.translate('discover.hideChart', { - defaultMessage: 'Hide chart', - }) - : i18n.translate('discover.showChart', { - defaultMessage: 'Show chart', - })} - - + {opts.timefield && ( + + { + toggleChart(!toggleOn); + }} + data-test-subj="discoverChartToggle" + > + {toggleOn + ? i18n.translate('discover.hideChart', { + defaultMessage: 'Hide chart', + }) + : i18n.translate('discover.showChart', { + defaultMessage: 'Show chart', + })} + + + )} @@ -297,7 +300,7 @@ export function DiscoverLegacy({ )} className="dscTimechart" > - {opts.chartAggConfigs && rows.length !== 0 && ( + {opts.chartAggConfigs && rows.length !== 0 && histogramData && (
; diff --git a/src/plugins/vis_type_table/kibana.json b/src/plugins/vis_type_table/kibana.json index b8a68909dc857..dce9bce0e8886 100644 --- a/src/plugins/vis_type_table/kibana.json +++ b/src/plugins/vis_type_table/kibana.json @@ -11,8 +11,10 @@ ], "requiredBundles": [ "kibanaUtils", + "kibanaReact", "share", "charts", "visDefaultEditor" - ] + ], + "optionalPlugins": ["usageCollection"] } diff --git a/src/plugins/vis_type_table/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_type_table/public/__snapshots__/to_ast.test.ts.snap index bb63a6f4e5e6a..abe6f01c17e65 100644 --- a/src/plugins/vis_type_table/public/__snapshots__/to_ast.test.ts.snap +++ b/src/plugins/vis_type_table/public/__snapshots__/to_ast.test.ts.snap @@ -35,7 +35,7 @@ Object { Object { "arguments": Object { "visConfig": Array [ - "{\\"perPage\\":20,\\"percentageCol\\":\\"Count\\",\\"showMetricsAtAllLevels\\":true,\\"showPartialRows\\":true,\\"showTotal\\":true,\\"sort\\":{\\"columnIndex\\":null,\\"direction\\":null},\\"totalFunc\\":\\"sum\\",\\"dimensions\\":{\\"metrics\\":[{\\"accessor\\":1,\\"format\\":{\\"id\\":\\"number\\"},\\"params\\":{},\\"label\\":\\"Count\\",\\"aggType\\":\\"count\\"}],\\"buckets\\":[{\\"accessor\\":0,\\"format\\":{\\"id\\":\\"date\\",\\"params\\":{\\"pattern\\":\\"YYYY-MM-DD HH:mm\\"}},\\"params\\":{},\\"label\\":\\"order_date per 3 hours\\",\\"aggType\\":\\"date_histogram\\"}]}}", + "{\\"perPage\\":20,\\"percentageCol\\":\\"Count\\",\\"showMetricsAtAllLevels\\":true,\\"showPartialRows\\":true,\\"showTotal\\":true,\\"showToolbar\\":false,\\"totalFunc\\":\\"sum\\",\\"dimensions\\":{\\"metrics\\":[{\\"accessor\\":1,\\"format\\":{\\"id\\":\\"number\\"},\\"params\\":{},\\"label\\":\\"Count\\",\\"aggType\\":\\"count\\"}],\\"buckets\\":[{\\"accessor\\":0,\\"format\\":{\\"id\\":\\"date\\",\\"params\\":{\\"pattern\\":\\"YYYY-MM-DD HH:mm\\"}},\\"params\\":{},\\"label\\":\\"order_date per 3 hours\\",\\"aggType\\":\\"date_histogram\\"}]}}", ], }, "function": "kibana_table", diff --git a/src/plugins/vis_type_table/public/components/index.ts b/src/plugins/vis_type_table/public/components/index.ts new file mode 100644 index 0000000000000..1ae21997325b4 --- /dev/null +++ b/src/plugins/vis_type_table/public/components/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { TableOptions } from './table_vis_options_lazy'; diff --git a/src/plugins/vis_type_table/public/components/table_vis_basic.tsx b/src/plugins/vis_type_table/public/components/table_vis_basic.tsx new file mode 100644 index 0000000000000..66abc71cf113c --- /dev/null +++ b/src/plugins/vis_type_table/public/components/table_vis_basic.tsx @@ -0,0 +1,164 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { memo, useCallback, useMemo } from 'react'; +import { EuiDataGrid, EuiDataGridProps, EuiDataGridSorting, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { orderBy } from 'lodash'; + +import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; +import { createTableVisCell } from './table_vis_cell'; +import { Table } from '../table_vis_response_handler'; +import { TableVisConfig, TableVisUseUiStateProps } from '../types'; +import { useFormattedColumnsAndRows, usePagination } from '../utils'; +import { TableVisControls } from './table_vis_controls'; +import { createGridColumns } from './table_vis_columns'; + +interface TableVisBasicProps { + fireEvent: IInterpreterRenderHandlers['event']; + table: Table; + visConfig: TableVisConfig; + title?: string; + uiStateProps: TableVisUseUiStateProps; +} + +export const TableVisBasic = memo( + ({ + fireEvent, + table, + visConfig, + title, + uiStateProps: { columnsWidth, sort, setColumnsWidth, setSort }, + }: TableVisBasicProps) => { + const { columns, rows } = useFormattedColumnsAndRows(table, visConfig); + + // custom sorting is in place until the EuiDataGrid sorting gets rid of flaws -> https://github.com/elastic/eui/issues/4108 + const sortedRows = useMemo( + () => + sort.columnIndex !== null && sort.direction + ? orderBy(rows, columns[sort.columnIndex]?.id, sort.direction) + : rows, + [columns, rows, sort] + ); + + // renderCellValue is a component which renders a cell based on column and row indexes + const renderCellValue = useMemo(() => createTableVisCell(columns, sortedRows), [ + columns, + sortedRows, + ]); + + // Columns config + const gridColumns = createGridColumns(table, columns, columnsWidth, sortedRows, fireEvent); + + // Pagination config + const pagination = usePagination(visConfig, rows.length); + // Sorting config + const sortingColumns = useMemo( + () => + sort.columnIndex !== null && sort.direction + ? [{ id: columns[sort.columnIndex]?.id, direction: sort.direction }] + : [], + [columns, sort] + ); + const onSort = useCallback( + (sortingCols: EuiDataGridSorting['columns'] | []) => { + // data table vis sorting now only handles one column sorting + // if data grid provides more columns to sort, pick only the next column to sort + const newSortValue = sortingCols.length <= 1 ? sortingCols[0] : sortingCols[1]; + setSort( + newSortValue && { + columnIndex: columns.findIndex((c) => c.id === newSortValue.id), + direction: newSortValue.direction, + } + ); + }, + [columns, setSort] + ); + + const dataGridAriaLabel = + title || + visConfig.title || + i18n.translate('visTypeTable.defaultAriaLabel', { + defaultMessage: 'Data table visualization', + }); + + const onColumnResize: EuiDataGridProps['onColumnResize'] = useCallback( + ({ columnId, width }) => { + const colIndex = columns.findIndex((c) => c.id === columnId); + setColumnsWidth({ + colIndex, + width, + }); + }, + [columns, setColumnsWidth] + ); + + return ( + <> + {title && ( + +

{title}

+
+ )} + id), + setVisibleColumns: () => {}, + }} + toolbarVisibility={ + visConfig.showToolbar && { + showColumnSelector: false, + showFullScreenSelector: false, + showSortSelector: false, + showStyleSelector: false, + additionalControls: ( + + ), + } + } + renderCellValue={renderCellValue} + renderFooterCellValue={ + visConfig.showTotal + ? // @ts-expect-error + ({ colIndex }) => columns[colIndex].formattedTotal || null + : undefined + } + pagination={pagination} + sorting={{ columns: sortingColumns, onSort }} + onColumnResize={onColumnResize} + minSizeForControls={1} + /> + + ); + } +); diff --git a/src/plugins/vis_type_table/public/components/table_vis_cell.tsx b/src/plugins/vis_type_table/public/components/table_vis_cell.tsx new file mode 100644 index 0000000000000..08a9873cb1312 --- /dev/null +++ b/src/plugins/vis_type_table/public/components/table_vis_cell.tsx @@ -0,0 +1,50 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { EuiDataGridCellValueElementProps } from '@elastic/eui'; + +import { Table } from '../table_vis_response_handler'; +import { FormattedColumn } from '../types'; + +export const createTableVisCell = (formattedColumns: FormattedColumn[], rows: Table['rows']) => ({ + // @ts-expect-error + colIndex, + rowIndex, + columnId, +}: EuiDataGridCellValueElementProps) => { + const rowValue = rows[rowIndex][columnId]; + const column = formattedColumns[colIndex]; + const content = column.formatter.convert(rowValue, 'html'); + + const cellContent = ( +
+ ); + + return cellContent; +}; diff --git a/src/plugins/vis_type_table/public/components/table_vis_columns.tsx b/src/plugins/vis_type_table/public/components/table_vis_columns.tsx new file mode 100644 index 0000000000000..175a7aeffb713 --- /dev/null +++ b/src/plugins/vis_type_table/public/components/table_vis_columns.tsx @@ -0,0 +1,185 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { EuiDataGridColumnCellActionProps, EuiDataGridColumn } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; +import { Table } from '../table_vis_response_handler'; +import { FormattedColumn, TableVisUiState } from '../types'; + +interface FilterCellData { + /** + * Row index + */ + row: number; + /** + * Column index + */ + column: number; + value: unknown; +} + +export const createGridColumns = ( + table: Table, + columns: FormattedColumn[], + columnsWidth: TableVisUiState['colWidth'], + rows: Table['rows'], + fireEvent: IInterpreterRenderHandlers['event'] +) => { + const onFilterClick = (data: FilterCellData, negate: boolean) => { + /** + * Visible column index and the actual one from the source table could be different. + * e.x. a column could be filtered out if it's not a dimension - + * see formattedColumns in use_formatted_columns.ts file, + * or an extra percantage column could be added, which doesn't exist in the raw table + */ + const rawTableActualColumnIndex = table.columns.findIndex( + (c) => c.id === columns[data.column].id + ); + fireEvent({ + name: 'filterBucket', + data: { + data: [ + { + table: { + ...table, + rows, + }, + ...data, + column: rawTableActualColumnIndex, + }, + ], + negate, + }, + }); + }; + + return columns.map( + (col, colIndex): EuiDataGridColumn => { + const cellActions = col.filterable + ? [ + ({ rowIndex, columnId, Component, closePopover }: EuiDataGridColumnCellActionProps) => { + const rowValue = rows[rowIndex][columnId]; + const contentsIsDefined = rowValue !== null && rowValue !== undefined; + const cellContent = col.formatter.convert(rowValue); + + const filterForText = i18n.translate( + 'visTypeTable.tableCellFilter.filterForValueText', + { + defaultMessage: 'Filter for value', + } + ); + const filterForAriaLabel = i18n.translate( + 'visTypeTable.tableCellFilter.filterForValueAriaLabel', + { + defaultMessage: 'Filter for value: {cellContent}', + values: { + cellContent, + }, + } + ); + + return ( + contentsIsDefined && ( + { + onFilterClick({ row: rowIndex, column: colIndex, value: rowValue }, false); + closePopover(); + }} + iconType="plusInCircle" + > + {filterForText} + + ) + ); + }, + ({ rowIndex, columnId, Component, closePopover }: EuiDataGridColumnCellActionProps) => { + const rowValue = rows[rowIndex][columnId]; + const contentsIsDefined = rowValue !== null && rowValue !== undefined; + const cellContent = col.formatter.convert(rowValue); + + const filterOutText = i18n.translate( + 'visTypeTable.tableCellFilter.filterOutValueText', + { + defaultMessage: 'Filter out value', + } + ); + const filterOutAriaLabel = i18n.translate( + 'visTypeTable.tableCellFilter.filterOutValueAriaLabel', + { + defaultMessage: 'Filter out value: {cellContent}', + values: { + cellContent, + }, + } + ); + + return ( + contentsIsDefined && ( + { + onFilterClick({ row: rowIndex, column: colIndex, value: rowValue }, true); + closePopover(); + }} + iconType="minusInCircle" + > + {filterOutText} + + ) + ); + }, + ] + : undefined; + + const initialWidth = columnsWidth.find((c) => c.colIndex === colIndex); + const column: EuiDataGridColumn = { + id: col.id, + display: col.title, + displayAsText: col.title, + actions: { + showHide: false, + showMoveLeft: false, + showMoveRight: false, + showSortAsc: { + label: i18n.translate('visTypeTable.sort.ascLabel', { + defaultMessage: 'Sort asc', + }), + }, + showSortDesc: { + label: i18n.translate('visTypeTable.sort.descLabel', { + defaultMessage: 'Sort desc', + }), + }, + }, + cellActions, + }; + + if (initialWidth) { + column.initialWidth = initialWidth.width; + } + + return column; + } + ); +}; diff --git a/src/plugins/vis_type_table/public/components/table_vis_controls.tsx b/src/plugins/vis_type_table/public/components/table_vis_controls.tsx new file mode 100644 index 0000000000000..d8fecbfea5a0a --- /dev/null +++ b/src/plugins/vis_type_table/public/components/table_vis_controls.tsx @@ -0,0 +1,102 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { memo, useState, useCallback } from 'react'; +import { EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { DatatableRow } from 'src/plugins/expressions'; +import { CoreStart } from 'kibana/public'; +import { useKibana } from '../../../kibana_react/public'; +import { FormattedColumn } from '../types'; +import { Table } from '../table_vis_response_handler'; +import { exportAsCsv } from '../utils'; + +interface TableVisControlsProps { + dataGridAriaLabel: string; + filename?: string; + cols: FormattedColumn[]; + rows: DatatableRow[]; + table: Table; +} + +export const TableVisControls = memo(({ dataGridAriaLabel, ...props }: TableVisControlsProps) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const togglePopover = useCallback(() => setIsPopoverOpen((state) => !state), []); + const closePopover = useCallback(() => setIsPopoverOpen(false), []); + + const { + services: { uiSettings }, + } = useKibana(); + + const onClickExport = useCallback( + (formatted: boolean) => + exportAsCsv(formatted, { + ...props, + uiSettings, + }), + [props, uiSettings] + ); + + const exportBtnAriaLabel = i18n.translate('visTypeTable.vis.controls.exportButtonAriaLabel', { + defaultMessage: 'Export {dataGridAriaLabel} as CSV', + values: { + dataGridAriaLabel, + }, + }); + + const button = ( + + + + ); + + const items = [ + onClickExport(false)}> + + , + onClickExport(true)}> + + , + ]; + + return ( + + + + ); +}); diff --git a/src/plugins/vis_type_table/public/components/table_vis_options.tsx b/src/plugins/vis_type_table/public/components/table_vis_options.tsx index c4d333134237a..b81f0425011da 100644 --- a/src/plugins/vis_type_table/public/components/table_vis_options.tsx +++ b/src/plugins/vis_type_table/public/components/table_vis_options.tsx @@ -114,6 +114,15 @@ function TableOptions({ data-test-subj="showPartialRows" /> + + { + return ( + <> + {tables.map(({ tables: dataTable, key, title }) => ( +
+ +
+ ))} + + ); + } +); diff --git a/src/plugins/vis_type_table/public/components/table_visualization.scss b/src/plugins/vis_type_table/public/components/table_visualization.scss new file mode 100644 index 0000000000000..7bc51ed5c3d93 --- /dev/null +++ b/src/plugins/vis_type_table/public/components/table_visualization.scss @@ -0,0 +1,33 @@ +// Prefix all styles with "tbv" to avoid conflicts. +// Examples +// tbvChart +// tbvChart__legend +// tbvChart__legend--small +// tbvChart__legend-isLoading + +.tbvChart { + display: flex; + flex-direction: column; + flex: 1 0 0; + overflow: auto; + + @include euiScrollBar; +} + +.tbvChart__split { + padding: $euiSizeS; + margin-bottom: $euiSizeL; + + > h3 { + text-align: center; + } +} + +.tbvChart__splitColumns { + flex-direction: row; + align-items: flex-start; +} + +.tbvChartCellContent { + @include euiTextTruncate; +} diff --git a/src/plugins/vis_type_table/public/components/table_visualization.tsx b/src/plugins/vis_type_table/public/components/table_visualization.tsx new file mode 100644 index 0000000000000..2d38acc57519f --- /dev/null +++ b/src/plugins/vis_type_table/public/components/table_visualization.tsx @@ -0,0 +1,86 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import './table_visualization.scss'; +import React, { useEffect } from 'react'; +import classNames from 'classnames'; + +import { CoreStart } from 'kibana/public'; +import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; +import { KibanaContextProvider } from '../../../kibana_react/public'; +import { TableVisConfig } from '../types'; +import { TableContext } from '../table_vis_response_handler'; +import { TableVisBasic } from './table_vis_basic'; +import { TableVisSplit } from './table_vis_split'; +import { useUiState } from '../utils'; + +interface TableVisualizationComponentProps { + core: CoreStart; + handlers: IInterpreterRenderHandlers; + visData: TableContext; + visConfig: TableVisConfig; +} + +const TableVisualizationComponent = ({ + core, + handlers, + visData: { direction, table, tables }, + visConfig, +}: TableVisualizationComponentProps) => { + useEffect(() => { + handlers.done(); + }, [handlers]); + + const uiStateProps = useUiState(handlers.uiState); + + const className = classNames('tbvChart', { + // eslint-disable-next-line @typescript-eslint/naming-convention + tbvChart__splitColumns: direction === 'column', + }); + + return ( + + +
+ {table ? ( +
+ +
+ ) : ( + + )} +
+
+
+ ); +}; + +// default export required for React.Lazy +// eslint-disable-next-line import/no-default-export +export { TableVisualizationComponent as default }; diff --git a/src/plugins/vis_type_table/public/legacy/__snapshots__/table_vis_legacy_fn.test.ts.snap b/src/plugins/vis_type_table/public/legacy/__snapshots__/table_vis_legacy_fn.test.ts.snap new file mode 100644 index 0000000000000..a32609c2e3d34 --- /dev/null +++ b/src/plugins/vis_type_table/public/legacy/__snapshots__/table_vis_legacy_fn.test.ts.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`interpreter/functions#table returns an object with the correct structure 1`] = ` +Object { + "as": "table_vis", + "type": "render", + "value": Object { + "visConfig": Object { + "dimensions": Object { + "buckets": Array [], + "metrics": Array [ + Object { + "accessor": 0, + "aggType": "count", + "format": Object { + "id": "number", + }, + "params": Object {}, + }, + ], + }, + "perPage": 10, + "showMetricsAtAllLevels": false, + "showPartialRows": false, + "showTotal": false, + "sort": Object { + "columnIndex": null, + "direction": null, + }, + "title": "My Chart title", + "totalFunc": "sum", + }, + "visData": Object { + "tables": Array [ + Object { + "columns": Array [], + "rows": Array [], + }, + ], + }, + "visType": "table", + }, +} +`; diff --git a/src/plugins/vis_type_table/public/legacy/index.ts b/src/plugins/vis_type_table/public/legacy/index.ts new file mode 100644 index 0000000000000..a81b88aaf32f2 --- /dev/null +++ b/src/plugins/vis_type_table/public/legacy/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { registerLegacyVis } from './register_legacy_vis'; diff --git a/src/plugins/vis_type_table/public/legacy/register_legacy_vis.ts b/src/plugins/vis_type_table/public/legacy/register_legacy_vis.ts new file mode 100644 index 0000000000000..4e59378eb93ef --- /dev/null +++ b/src/plugins/vis_type_table/public/legacy/register_legacy_vis.ts @@ -0,0 +1,37 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { METRIC_TYPE } from '@kbn/analytics'; +import { PluginInitializerContext, CoreSetup } from 'kibana/public'; + +import { TablePluginSetupDependencies, TablePluginStartDependencies } from '../plugin'; +import { createTableVisLegacyFn } from './table_vis_legacy_fn'; +import { getTableVisLegacyRenderer } from './table_vis_legacy_renderer'; +import { tableVisLegacyTypeDefinition } from './table_vis_legacy_type'; + +export const registerLegacyVis = ( + core: CoreSetup, + { expressions, visualizations, usageCollection }: TablePluginSetupDependencies, + context: PluginInitializerContext +) => { + usageCollection?.reportUiCounter('vis_type_table', METRIC_TYPE.LOADED, 'legacyVisEnabled'); + expressions.registerFunction(createTableVisLegacyFn); + expressions.registerRenderer(getTableVisLegacyRenderer(core, context)); + visualizations.createBaseVisualization(tableVisLegacyTypeDefinition); +}; diff --git a/src/plugins/vis_type_table/public/legacy/table_vis_controller.test.ts b/src/plugins/vis_type_table/public/legacy/table_vis_controller.test.ts index a2c5fc2c7de72..4a76b09b4177e 100644 --- a/src/plugins/vis_type_table/public/legacy/table_vis_controller.test.ts +++ b/src/plugins/vis_type_table/public/legacy/table_vis_controller.test.ts @@ -24,10 +24,10 @@ import $ from 'jquery'; import { getAngularModule } from './get_inner_angular'; import { initTableVisLegacyModule } from './table_vis_legacy_module'; -import { tableVisTypeDefinition } from '../table_vis_type'; +import { tableVisLegacyTypeDefinition } from './table_vis_legacy_type'; import { Vis } from '../../../visualizations/public'; import { stubFields } from '../../../data/public/stubs'; -import { tableVisResponseHandler } from '../table_vis_response_handler'; +import { tableVisLegacyResponseHandler } from './table_vis_legacy_response_handler'; import { coreMock } from '../../../../core/public/mocks'; import { IAggConfig, search } from '../../../data/public'; import { getStubIndexPattern } from '../../../data/public/test_utils'; @@ -94,7 +94,7 @@ describe('Table Vis - Controller', () => { angular.mock.inject((_$rootScope_: IRootScopeService, _$compile_: ICompileService) => { $rootScope = _$rootScope_; $compile = _$compile_; - tableAggResponse = tableVisResponseHandler; + tableAggResponse = tableVisLegacyResponseHandler; }) ); @@ -110,8 +110,8 @@ describe('Table Vis - Controller', () => { function getRangeVis(params?: object) { return ({ - type: tableVisTypeDefinition, - params: Object.assign({}, tableVisTypeDefinition.visConfig?.defaults, params), + type: tableVisLegacyTypeDefinition, + params: Object.assign({}, tableVisLegacyTypeDefinition.visConfig?.defaults, params), data: { aggs: createAggConfigs(stubIndexPattern, [ { type: 'count', schema: 'metric' }, diff --git a/src/plugins/vis_type_table/public/legacy/table_vis_legacy_fn.test.ts b/src/plugins/vis_type_table/public/legacy/table_vis_legacy_fn.test.ts new file mode 100644 index 0000000000000..3916f80c24f14 --- /dev/null +++ b/src/plugins/vis_type_table/public/legacy/table_vis_legacy_fn.test.ts @@ -0,0 +1,78 @@ +/* + * 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 { createTableVisLegacyFn } from './table_vis_legacy_fn'; +import { tableVisLegacyResponseHandler } from './table_vis_legacy_response_handler'; + +import { functionWrapper } from '../../../expressions/common/expression_functions/specs/tests/utils'; + +jest.mock('./table_vis_legacy_response_handler', () => ({ + tableVisLegacyResponseHandler: jest.fn().mockReturnValue({ + tables: [{ columns: [], rows: [] }], + }), +})); + +describe('interpreter/functions#table', () => { + const fn = functionWrapper(createTableVisLegacyFn()); + const context = { + type: 'datatable', + rows: [{ 'col-0-1': 0 }], + columns: [{ id: 'col-0-1', name: 'Count' }], + }; + const visConfig = { + title: 'My Chart title', + perPage: 10, + showPartialRows: false, + showMetricsAtAllLevels: false, + sort: { + columnIndex: null, + direction: null, + }, + showTotal: false, + totalFunc: 'sum', + dimensions: { + metrics: [ + { + accessor: 0, + format: { + id: 'number', + }, + params: {}, + aggType: 'count', + }, + ], + buckets: [], + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns an object with the correct structure', async () => { + const actual = await fn(context, { visConfig: JSON.stringify(visConfig) }, undefined); + expect(actual).toMatchSnapshot(); + }); + + it('calls response handler with correct values', async () => { + await fn(context, { visConfig: JSON.stringify(visConfig) }, undefined); + expect(tableVisLegacyResponseHandler).toHaveBeenCalledTimes(1); + expect(tableVisLegacyResponseHandler).toHaveBeenCalledWith(context, visConfig.dimensions); + }); +}); diff --git a/src/plugins/vis_type_table/public/legacy/table_vis_legacy_fn.ts b/src/plugins/vis_type_table/public/legacy/table_vis_legacy_fn.ts new file mode 100644 index 0000000000000..fa8dd4ee6fecf --- /dev/null +++ b/src/plugins/vis_type_table/public/legacy/table_vis_legacy_fn.ts @@ -0,0 +1,72 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition, Datatable, Render } from 'src/plugins/expressions/public'; +import { tableVisLegacyResponseHandler, TableContext } from './table_vis_legacy_response_handler'; +import { TableVisConfig } from '../types'; + +export type Input = Datatable; + +interface Arguments { + visConfig: string | null; +} + +export interface TableVisRenderValue { + visData: TableContext; + visType: 'table'; + visConfig: TableVisConfig; +} + +export type TableExpressionFunctionDefinition = ExpressionFunctionDefinition< + 'kibana_table', + Input, + Arguments, + Render +>; + +export const createTableVisLegacyFn = (): TableExpressionFunctionDefinition => ({ + name: 'kibana_table', + type: 'render', + inputTypes: ['datatable'], + help: i18n.translate('visTypeTable.function.help', { + defaultMessage: 'Table visualization', + }), + args: { + visConfig: { + types: ['string', 'null'], + default: '"{}"', + help: '', + }, + }, + fn(input, args) { + const visConfig = args.visConfig && JSON.parse(args.visConfig); + const convertedData = tableVisLegacyResponseHandler(input, visConfig.dimensions); + + return { + type: 'render', + as: 'table_vis', + value: { + visData: convertedData, + visType: 'table', + visConfig, + }, + }; + }, +}); diff --git a/src/plugins/vis_type_table/public/legacy/table_vis_legacy_response_handler.ts b/src/plugins/vis_type_table/public/legacy/table_vis_legacy_response_handler.ts new file mode 100644 index 0000000000000..312fd28c942cb --- /dev/null +++ b/src/plugins/vis_type_table/public/legacy/table_vis_legacy_response_handler.ts @@ -0,0 +1,99 @@ +/* + * 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 { Required } from '@kbn/utility-types'; + +import { getFormatService } from '../services'; +import { Dimensions } from '../types'; +import { Input } from './table_vis_legacy_fn'; + +export interface TableContext { + tables: Array; + direction?: 'row' | 'column'; +} + +export interface TableGroup { + $parent: TableContext; + table: Input; + tables: Table[]; + title: string; + name: string; + key: any; + column: number; + row: number; +} + +export interface Table { + $parent?: TableGroup; + columns: Input['columns']; + rows: Input['rows']; +} + +export function tableVisLegacyResponseHandler(table: Input, dimensions: Dimensions): TableContext { + const converted: TableContext = { + tables: [], + }; + + const split = dimensions.splitColumn || dimensions.splitRow; + + if (split) { + converted.direction = dimensions.splitRow ? 'row' : 'column'; + const splitColumnIndex = split[0].accessor; + const splitColumnFormatter = getFormatService().deserialize(split[0].format); + const splitColumn = table.columns[splitColumnIndex]; + const splitMap: Record = {}; + let splitIndex = 0; + + table.rows.forEach((row, rowIndex) => { + const splitValue = row[splitColumn.id]; + + if (!splitMap.hasOwnProperty(splitValue)) { + splitMap[splitValue] = splitIndex++; + const tableGroup: Required = { + $parent: converted, + title: `${splitColumnFormatter.convert(splitValue)}: ${splitColumn.name}`, + name: splitColumn.name, + key: splitValue, + column: splitColumnIndex, + row: rowIndex, + table, + tables: [], + }; + + tableGroup.tables.push({ + $parent: tableGroup, + columns: table.columns, + rows: [], + }); + + converted.tables.push(tableGroup); + } + + const tableIndex = splitMap[splitValue]; + (converted.tables[tableIndex] as TableGroup).tables[0].rows.push(row); + }); + } else { + converted.tables.push({ + columns: table.columns, + rows: table.rows, + }); + } + + return converted; +} diff --git a/src/plugins/vis_type_table/public/legacy/table_vis_legacy_type.ts b/src/plugins/vis_type_table/public/legacy/table_vis_legacy_type.ts new file mode 100644 index 0000000000000..5aef3fc26fa6c --- /dev/null +++ b/src/plugins/vis_type_table/public/legacy/table_vis_legacy_type.ts @@ -0,0 +1,95 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { AggGroupNames } from '../../../data/public'; +import { Schemas } from '../../../vis_default_editor/public'; +import { BaseVisTypeOptions } from '../../../visualizations/public'; + +import { TableOptions } from '../components/table_vis_options_lazy'; +import { VIS_EVENT_TO_TRIGGER } from '../../../visualizations/public'; +import { toExpressionAst } from '../to_ast'; +import { TableVisParams } from '../types'; + +export const tableVisLegacyTypeDefinition: BaseVisTypeOptions = { + name: 'table', + title: i18n.translate('visTypeTable.tableVisTitle', { + defaultMessage: 'Data table', + }), + icon: 'visTable', + description: i18n.translate('visTypeTable.tableVisDescription', { + defaultMessage: 'Display data in rows and columns.', + }), + getSupportedTriggers: () => { + return [VIS_EVENT_TO_TRIGGER.filter]; + }, + visConfig: { + defaults: { + perPage: 10, + showPartialRows: false, + showMetricsAtAllLevels: false, + sort: { + columnIndex: null, + direction: null, + }, + showTotal: false, + totalFunc: 'sum', + percentageCol: '', + }, + }, + editorConfig: { + optionsTemplate: TableOptions, + schemas: new Schemas([ + { + group: AggGroupNames.Metrics, + name: 'metric', + title: i18n.translate('visTypeTable.tableVisEditorConfig.schemas.metricTitle', { + defaultMessage: 'Metric', + }), + aggFilter: ['!geo_centroid', '!geo_bounds'], + aggSettings: { + top_hits: { + allowStrings: true, + }, + }, + min: 1, + defaults: [{ type: 'count', schema: 'metric' }], + }, + { + group: AggGroupNames.Buckets, + name: 'bucket', + title: i18n.translate('visTypeTable.tableVisEditorConfig.schemas.bucketTitle', { + defaultMessage: 'Split rows', + }), + aggFilter: ['!filter'], + }, + { + group: AggGroupNames.Buckets, + name: 'split', + title: i18n.translate('visTypeTable.tableVisEditorConfig.schemas.splitTitle', { + defaultMessage: 'Split table', + }), + min: 0, + max: 1, + aggFilter: ['!filter'], + }, + ]), + }, + toExpressionAst, + hierarchicalData: (vis) => vis.params.showPartialRows || vis.params.showMetricsAtAllLevels, +}; diff --git a/src/plugins/vis_type_table/public/plugin.ts b/src/plugins/vis_type_table/public/plugin.ts index 35ef5fc831cb7..4ca00e67e2e9f 100644 --- a/src/plugins/vis_type_table/public/plugin.ts +++ b/src/plugins/vis_type_table/public/plugin.ts @@ -19,18 +19,21 @@ import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'kibana/public'; import { Plugin as ExpressionsPublicPlugin } from '../../expressions/public'; import { VisualizationsSetup } from '../../visualizations/public'; +import { UsageCollectionSetup } from '../../usage_collection/public'; -import { createTableVisFn } from './table_vis_fn'; -import { tableVisTypeDefinition } from './table_vis_type'; import { DataPublicPluginStart } from '../../data/public'; import { setFormatService } from './services'; import { KibanaLegacyStart } from '../../kibana_legacy/public'; -import { getTableVisLegacyRenderer } from './legacy/table_vis_legacy_renderer'; + +interface ClientConfigType { + legacyVisEnabled: boolean; +} /** @internal */ export interface TablePluginSetupDependencies { expressions: ReturnType; visualizations: VisualizationsSetup; + usageCollection?: UsageCollectionSetup; } /** @internal */ @@ -43,8 +46,7 @@ export interface TablePluginStartDependencies { export class TableVisPlugin implements Plugin, void, TablePluginSetupDependencies, TablePluginStartDependencies> { - initializerContext: PluginInitializerContext; - createBaseVisualization: any; + initializerContext: PluginInitializerContext; constructor(initializerContext: PluginInitializerContext) { this.initializerContext = initializerContext; @@ -52,11 +54,17 @@ export class TableVisPlugin public async setup( core: CoreSetup, - { expressions, visualizations }: TablePluginSetupDependencies + deps: TablePluginSetupDependencies ) { - expressions.registerFunction(createTableVisFn); - expressions.registerRenderer(getTableVisLegacyRenderer(core, this.initializerContext)); - visualizations.createBaseVisualization(tableVisTypeDefinition); + const { legacyVisEnabled } = this.initializerContext.config.get(); + + if (legacyVisEnabled) { + const { registerLegacyVis } = await import('./legacy'); + registerLegacyVis(core, deps, this.initializerContext); + } else { + const { registerTableVis } = await import('./register_vis'); + registerTableVis(core, deps, this.initializerContext); + } } public start(core: CoreStart, { data }: TablePluginStartDependencies) { diff --git a/src/plugins/vis_type_table/public/register_vis.ts b/src/plugins/vis_type_table/public/register_vis.ts new file mode 100644 index 0000000000000..efbd3ad4ef7a7 --- /dev/null +++ b/src/plugins/vis_type_table/public/register_vis.ts @@ -0,0 +1,36 @@ +/* + * 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 { PluginInitializerContext, CoreSetup } from 'kibana/public'; + +import { TablePluginSetupDependencies, TablePluginStartDependencies } from './plugin'; +import { createTableVisFn } from './table_vis_fn'; +import { getTableVisRenderer } from './table_vis_renderer'; +import { tableVisTypeDefinition } from './table_vis_type'; + +export const registerTableVis = async ( + core: CoreSetup, + { expressions, visualizations }: TablePluginSetupDependencies, + context: PluginInitializerContext +) => { + const [coreStart] = await core.getStartServices(); + expressions.registerFunction(createTableVisFn); + expressions.registerRenderer(getTableVisRenderer(coreStart)); + visualizations.createBaseVisualization(tableVisTypeDefinition); +}; diff --git a/src/plugins/vis_type_table/public/table_vis_renderer.tsx b/src/plugins/vis_type_table/public/table_vis_renderer.tsx new file mode 100644 index 0000000000000..bb46b2e5bab9f --- /dev/null +++ b/src/plugins/vis_type_table/public/table_vis_renderer.tsx @@ -0,0 +1,59 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { lazy } from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; + +import { CoreStart } from 'kibana/public'; +import { VisualizationContainer } from '../../visualizations/public'; +import { ExpressionRenderDefinition } from '../../expressions/common/expression_renderers'; +import { TableVisRenderValue } from './table_vis_fn'; + +const TableVisualizationComponent = lazy(() => import('./components/table_visualization')); + +export const getTableVisRenderer: ( + core: CoreStart +) => ExpressionRenderDefinition = (core) => ({ + name: 'table_vis', + reuseDomNode: true, + render: async (domNode, { visData, visConfig }, handlers) => { + handlers.onDestroy(() => { + unmountComponentAtNode(domNode); + }); + + const showNoResult = + visData.table?.rows.length === 0 || (!visData.table && visData.tables.length === 0); + + render( + + + , + domNode + ); + }, +}); diff --git a/src/plugins/vis_type_table/public/table_vis_response_handler.ts b/src/plugins/vis_type_table/public/table_vis_response_handler.ts index 4bf33c876dfff..060fe1fbd8cf6 100644 --- a/src/plugins/vis_type_table/public/table_vis_response_handler.ts +++ b/src/plugins/vis_type_table/public/table_vis_response_handler.ts @@ -21,78 +21,81 @@ import { Required } from '@kbn/utility-types'; import { getFormatService } from './services'; import { Input } from './table_vis_fn'; +import { Dimensions } from './types'; export interface TableContext { - tables: Array; + table?: Table; + tables: TableGroup[]; direction?: 'row' | 'column'; } export interface TableGroup { - $parent: TableContext; table: Input; tables: Table[]; title: string; name: string; - key: any; + key: string | number; column: number; row: number; } export interface Table { - $parent?: TableGroup; columns: Input['columns']; rows: Input['rows']; } -export function tableVisResponseHandler(table: Input, dimensions: any): TableContext { - const converted: TableContext = { - tables: [], - }; +export function tableVisResponseHandler(input: Input, dimensions: Dimensions): TableContext { + let table: Table | undefined; + let tables: TableGroup[] = []; + let direction: TableContext['direction']; const split = dimensions.splitColumn || dimensions.splitRow; if (split) { - converted.direction = dimensions.splitRow ? 'row' : 'column'; + tables = []; + direction = dimensions.splitRow ? 'row' : 'column'; const splitColumnIndex = split[0].accessor; const splitColumnFormatter = getFormatService().deserialize(split[0].format); - const splitColumn = table.columns[splitColumnIndex]; - const splitMap = {}; + const splitColumn = input.columns[splitColumnIndex]; + const splitMap: { [key: string]: number } = {}; let splitIndex = 0; - table.rows.forEach((row, rowIndex) => { - const splitValue: any = row[splitColumn.id]; + input.rows.forEach((row, rowIndex) => { + const splitValue: string | number = row[splitColumn.id]; - if (!splitMap.hasOwnProperty(splitValue as any)) { - (splitMap as any)[splitValue] = splitIndex++; + if (!splitMap.hasOwnProperty(splitValue)) { + splitMap[splitValue] = splitIndex++; const tableGroup: Required = { - $parent: converted, title: `${splitColumnFormatter.convert(splitValue)}: ${splitColumn.name}`, name: splitColumn.name, key: splitValue, column: splitColumnIndex, row: rowIndex, - table, + table: input, tables: [], }; tableGroup.tables.push({ - $parent: tableGroup, - columns: table.columns, + columns: input.columns, rows: [], }); - converted.tables.push(tableGroup); + tables.push(tableGroup); } - const tableIndex = (splitMap as any)[splitValue]; - (converted.tables[tableIndex] as any).tables[0].rows.push(row); + const tableIndex = splitMap[splitValue]; + tables[tableIndex].tables[0].rows.push(row); }); } else { - converted.tables.push({ - columns: table.columns, - rows: table.rows, - }); + table = { + columns: input.columns, + rows: input.rows, + }; } - return converted; + return { + direction, + table, + tables, + }; } diff --git a/src/plugins/vis_type_table/public/table_vis_type.ts b/src/plugins/vis_type_table/public/table_vis_type.ts index 8546886e8350e..bfe1427d38496 100644 --- a/src/plugins/vis_type_table/public/table_vis_type.ts +++ b/src/plugins/vis_type_table/public/table_vis_type.ts @@ -43,11 +43,8 @@ export const tableVisTypeDefinition: BaseVisTypeOptions = { perPage: 10, showPartialRows: false, showMetricsAtAllLevels: false, - sort: { - columnIndex: null, - direction: null, - }, showTotal: false, + showToolbar: false, totalFunc: 'sum', percentageCol: '', }, @@ -91,7 +88,5 @@ export const tableVisTypeDefinition: BaseVisTypeOptions = { ]), }, toExpressionAst, - hierarchicalData: (vis) => { - return Boolean(vis.params.showPartialRows || vis.params.showMetricsAtAllLevels); - }, + hierarchicalData: (vis) => vis.params.showPartialRows || vis.params.showMetricsAtAllLevels, }; diff --git a/src/plugins/vis_type_table/public/to_ast.test.ts b/src/plugins/vis_type_table/public/to_ast.test.ts index 045b5814944b0..0f4e4077dc840 100644 --- a/src/plugins/vis_type_table/public/to_ast.test.ts +++ b/src/plugins/vis_type_table/public/to_ast.test.ts @@ -70,7 +70,7 @@ describe('table vis toExpressionAst function', () => { showMetricsAtAllLevels: true, showPartialRows: true, showTotal: true, - sort: { columnIndex: null, direction: null }, + showToolbar: false, totalFunc: AggTypes.SUM, }; const actual = toExpressionAst(vis, {} as any); diff --git a/src/plugins/vis_type_table/public/to_ast.ts b/src/plugins/vis_type_table/public/to_ast.ts index 78d6efd31a115..b92aea97e3ac1 100644 --- a/src/plugins/vis_type_table/public/to_ast.ts +++ b/src/plugins/vis_type_table/public/to_ast.ts @@ -30,14 +30,15 @@ const buildTableVisConfig = ( schemas: ReturnType, visParams: TableVisParams ) => { - const visConfig = {} as any; const metrics = schemas.metric; const buckets = schemas.bucket || []; - visConfig.dimensions = { - metrics, - buckets, - splitRow: schemas.split_row, - splitColumn: schemas.split_column, + const visConfig = { + dimensions: { + metrics, + buckets, + splitRow: schemas.split_row, + splitColumn: schemas.split_column, + }, }; if (visParams.showPartialRows && !visParams.showMetricsAtAllLevels) { diff --git a/src/plugins/vis_type_table/public/types.ts b/src/plugins/vis_type_table/public/types.ts index c0a995ad5da69..d5d883b4c21bf 100644 --- a/src/plugins/vis_type_table/public/types.ts +++ b/src/plugins/vis_type_table/public/types.ts @@ -17,7 +17,8 @@ * under the License. */ -import { SchemaConfig } from '../../visualizations/public'; +import { IFieldFormat } from 'src/plugins/data/public'; +import { SchemaConfig } from 'src/plugins/visualizations/public'; export enum AggTypes { SUM = 'sum', @@ -30,16 +31,35 @@ export enum AggTypes { export interface Dimensions { buckets: SchemaConfig[]; metrics: SchemaConfig[]; + splitColumn?: SchemaConfig[]; + splitRow?: SchemaConfig[]; +} + +export interface ColumnWidthData { + colIndex: number; + width: number; +} + +export interface TableVisUiState { + sort: { + columnIndex: number | null; + direction: 'asc' | 'desc' | null; + }; + colWidth: ColumnWidthData[]; +} + +export interface TableVisUseUiStateProps { + columnsWidth: TableVisUiState['colWidth']; + sort: TableVisUiState['sort']; + setSort: (s?: TableVisUiState['sort']) => void; + setColumnsWidth: (column: ColumnWidthData) => void; } export interface TableVisParams { perPage: number | ''; showPartialRows: boolean; showMetricsAtAllLevels: boolean; - sort: { - columnIndex: number | null; - direction: string | null; - }; + showToolbar: boolean; showTotal: boolean; totalFunc: AggTypes; percentageCol: string; @@ -49,3 +69,13 @@ export interface TableVisConfig extends TableVisParams { title: string; dimensions: Dimensions; } + +export interface FormattedColumn { + id: string; + title: string; + formatter: IFieldFormat; + formattedTotal?: string | number; + filterable: boolean; + sumTotal?: number; + total?: number; +} diff --git a/src/plugins/vis_type_table/public/utils/add_percentage_column.ts b/src/plugins/vis_type_table/public/utils/add_percentage_column.ts new file mode 100644 index 0000000000000..947d68214050b --- /dev/null +++ b/src/plugins/vis_type_table/public/utils/add_percentage_column.ts @@ -0,0 +1,64 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { DatatableRow } from 'src/plugins/expressions'; +import { getFormatService } from '../services'; +import { FormattedColumn } from '../types'; +import { Table } from '../table_vis_response_handler'; + +function insertColumn(arr: FormattedColumn[], index: number, col: FormattedColumn) { + const newArray = [...arr]; + newArray.splice(index + 1, 0, col); + return newArray; +} + +/** + * @param columns - the formatted columns that will be displayed + * @param title - the title of the column to add to + * @param rows - the row data for the columns + * @param insertAtIndex - the index to insert the percentage column at + * @returns cols and rows for the table to render now included percentage column(s) + */ +export function addPercentageColumn( + columns: FormattedColumn[], + title: string, + rows: Table['rows'], + insertAtIndex: number +) { + const { id, sumTotal } = columns[insertAtIndex]; + const newId = `${id}-percents`; + const formatter = getFormatService().deserialize({ id: 'percent' }); + const i18nTitle = i18n.translate('visTypeTable.params.percentageTableColumnName', { + defaultMessage: '{title} percentages', + values: { title }, + }); + const newCols = insertColumn(columns, insertAtIndex, { + title: i18nTitle, + id: newId, + formatter, + filterable: false, + }); + const newRows = rows.map((row) => ({ + [newId]: (row[id] as number) / (sumTotal as number), + ...row, + })); + + return { cols: newCols, rows: newRows }; +} diff --git a/src/plugins/vis_type_table/public/utils/export_as_csv.ts b/src/plugins/vis_type_table/public/utils/export_as_csv.ts new file mode 100644 index 0000000000000..3592fed08f18b --- /dev/null +++ b/src/plugins/vis_type_table/public/utils/export_as_csv.ts @@ -0,0 +1,75 @@ +/* + * 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 { isObject } from 'lodash'; +// @ts-ignore +import { saveAs } from '@elastic/filesaver'; + +import { CoreStart } from 'kibana/public'; +import { DatatableRow } from 'src/plugins/expressions'; +import { CSV_SEPARATOR_SETTING, CSV_QUOTE_VALUES_SETTING } from '../../../share/public'; +import { FormattedColumn } from '../types'; +import { Table } from '../table_vis_response_handler'; + +const nonAlphaNumRE = /[^a-zA-Z0-9]/; +const allDoubleQuoteRE = /"/g; + +interface ToCsvData { + filename?: string; + cols: FormattedColumn[]; + rows: DatatableRow[]; + table: Table; + uiSettings: CoreStart['uiSettings']; +} + +const toCsv = (formatted: boolean, { cols, rows, table, uiSettings }: ToCsvData) => { + const separator = uiSettings.get(CSV_SEPARATOR_SETTING); + const quoteValues = uiSettings.get(CSV_QUOTE_VALUES_SETTING); + + function escape(val: unknown) { + if (!formatted && isObject(val)) val = val.valueOf(); + val = String(val); + if (quoteValues && nonAlphaNumRE.test(val as string)) { + val = '"' + (val as string).replace(allDoubleQuoteRE, '""') + '"'; + } + return val as string; + } + + const csvRows: string[][] = []; + + for (const row of rows) { + const rowArray: string[] = []; + for (const col of cols) { + const value = row[col.id]; + const formattedValue = formatted ? escape(col.formatter.convert(value)) : escape(value); + rowArray.push(formattedValue); + } + csvRows.push(rowArray); + } + + // add headers to the rows + csvRows.unshift(cols.map(({ title }) => escape(title))); + + return csvRows.map((row) => row.join(separator) + '\r\n').join(''); +}; + +export const exportAsCsv = (formatted: boolean, data: ToCsvData) => { + const csv = new Blob([toCsv(formatted, data)], { type: 'text/plain;charset=utf-8' }); + saveAs(csv, `${data.filename || 'unsaved'}.csv`); +}; diff --git a/src/plugins/vis_type_table/public/utils/index.ts b/src/plugins/vis_type_table/public/utils/index.ts new file mode 100644 index 0000000000000..ab8f4911a5fa2 --- /dev/null +++ b/src/plugins/vis_type_table/public/utils/index.ts @@ -0,0 +1,21 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './use'; +export * from './export_as_csv'; diff --git a/src/plugins/vis_type_table/public/utils/use/index.ts b/src/plugins/vis_type_table/public/utils/use/index.ts new file mode 100644 index 0000000000000..f515858a8f865 --- /dev/null +++ b/src/plugins/vis_type_table/public/utils/use/index.ts @@ -0,0 +1,22 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './use_formatted_columns'; +export * from './use_pagination'; +export * from './use_ui_state'; diff --git a/src/plugins/vis_type_table/public/utils/use/use_formatted_columns.ts b/src/plugins/vis_type_table/public/utils/use/use_formatted_columns.ts new file mode 100644 index 0000000000000..72138455f0c6e --- /dev/null +++ b/src/plugins/vis_type_table/public/utils/use/use_formatted_columns.ts @@ -0,0 +1,125 @@ +/* + * 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 { useMemo } from 'react'; +import { chain, findIndex } from 'lodash'; + +import { Table } from '../../table_vis_response_handler'; +import { FormattedColumn, TableVisConfig, AggTypes } from '../../types'; +import { getFormatService } from '../../services'; +import { addPercentageColumn } from '../add_percentage_column'; + +export const useFormattedColumnsAndRows = (table: Table, visConfig: TableVisConfig) => { + const { formattedColumns: columns, formattedRows: rows } = useMemo(() => { + const { buckets, metrics } = visConfig.dimensions; + let formattedRows = table.rows; + + let formattedColumns = table.columns + .map((col, i) => { + const isBucket = buckets.find(({ accessor }) => accessor === i); + const dimension = isBucket || metrics.find(({ accessor }) => accessor === i); + + if (!dimension) return undefined; + + const formatter = getFormatService().deserialize(dimension.format); + const formattedColumn: FormattedColumn = { + id: col.id, + title: col.name, + formatter, + filterable: !!isBucket, + }; + + const isDate = dimension.format.id === 'date' || dimension.format.params?.id === 'date'; + const allowsNumericalAggregations = formatter.allowsNumericalAggregations; + + if (allowsNumericalAggregations || isDate || visConfig.totalFunc === AggTypes.COUNT) { + const sumOfColumnValues = table.rows.reduce((prev, curr) => { + // some metrics return undefined for some of the values + // derivative is an example of this as it returns undefined in the first row + if (curr[col.id] === undefined) return prev; + return prev + (curr[col.id] as number); + }, 0); + + formattedColumn.sumTotal = sumOfColumnValues; + + switch (visConfig.totalFunc) { + case AggTypes.SUM: { + if (!isDate) { + formattedColumn.formattedTotal = formatter.convert(sumOfColumnValues); + formattedColumn.total = sumOfColumnValues; + } + break; + } + case AggTypes.AVG: { + if (!isDate) { + const total = sumOfColumnValues / table.rows.length; + formattedColumn.formattedTotal = formatter.convert(total); + formattedColumn.total = total; + } + break; + } + case AggTypes.MIN: { + const total = chain(table.rows).map(col.id).min().value() as number; + formattedColumn.formattedTotal = formatter.convert(total); + formattedColumn.total = total; + break; + } + case AggTypes.MAX: { + const total = chain(table.rows).map(col.id).max().value() as number; + formattedColumn.formattedTotal = formatter.convert(total); + formattedColumn.total = total; + break; + } + case AggTypes.COUNT: { + const total = table.rows.length; + formattedColumn.formattedTotal = total; + formattedColumn.total = total; + break; + } + default: + break; + } + } + + return formattedColumn; + }) + .filter((column): column is FormattedColumn => !!column); + + if (visConfig.percentageCol) { + const insertAtIndex = findIndex(formattedColumns, { title: visConfig.percentageCol }); + + // column to show percentage for was removed + if (insertAtIndex < 0) return { formattedColumns, formattedRows }; + + const { cols, rows: rowsWithPercentage } = addPercentageColumn( + formattedColumns, + visConfig.percentageCol, + table.rows, + insertAtIndex + ); + + formattedRows = rowsWithPercentage; + formattedColumns = cols; + } + + return { formattedColumns, formattedRows }; + }, [table, visConfig.dimensions, visConfig.percentageCol, visConfig.totalFunc]); + + return { columns, rows }; +}; diff --git a/src/plugins/vis_type_table/public/utils/use/use_pagination.ts b/src/plugins/vis_type_table/public/utils/use/use_pagination.ts new file mode 100644 index 0000000000000..080f64b6a5743 --- /dev/null +++ b/src/plugins/vis_type_table/public/utils/use/use_pagination.ts @@ -0,0 +1,64 @@ +/* + * 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 { useCallback, useEffect, useMemo, useState } from 'react'; +import { TableVisParams } from '../../types'; + +export const usePagination = (visParams: TableVisParams, rowCount: number) => { + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: visParams.perPage || 0, + }); + const onChangeItemsPerPage = useCallback( + (pageSize: number) => setPagination((pag) => ({ ...pag, pageSize, pageIndex: 0 })), + [] + ); + const onChangePage = useCallback( + (pageIndex: number) => setPagination((pag) => ({ ...pag, pageIndex })), + [] + ); + + useEffect(() => { + const pageSize = visParams.perPage || 0; + const lastPageIndex = Math.ceil(rowCount / pageSize) - 1; + /** + * When the underlying data changes, there might be a case when actual pagination page + * doesn't exist anymore - if the number of rows has decreased. + * Set the last page as an actual. + */ + setPagination((pag) => ({ + pageIndex: pag.pageIndex > lastPageIndex ? lastPageIndex : pag.pageIndex, + pageSize, + })); + }, [visParams.perPage, rowCount]); + + const paginationMemoized = useMemo( + () => + pagination.pageSize + ? { + ...pagination, + onChangeItemsPerPage, + onChangePage, + } + : undefined, + [onChangeItemsPerPage, onChangePage, pagination] + ); + + return paginationMemoized; +}; diff --git a/src/plugins/vis_type_table/public/utils/use/use_ui_state.ts b/src/plugins/vis_type_table/public/utils/use/use_ui_state.ts new file mode 100644 index 0000000000000..68bad16972ec2 --- /dev/null +++ b/src/plugins/vis_type_table/public/utils/use/use_ui_state.ts @@ -0,0 +1,139 @@ +/* + * 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 { debounce, isEqual } from 'lodash'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; + +import { ColumnWidthData, TableVisUiState, TableVisUseUiStateProps } from '../../types'; + +const defaultSort = { + columnIndex: null, + direction: null, +}; + +export const useUiState = ( + uiState: IInterpreterRenderHandlers['uiState'] +): TableVisUseUiStateProps => { + const [sort, setSortState] = useState( + uiState?.get('vis.params.sort') || defaultSort + ); + + const [columnsWidth, setColumnsWidthState] = useState( + uiState?.get('vis.params.colWidth') || [] + ); + + const uiStateValues = useRef<{ + columnsWidth: ColumnWidthData[]; + sort: TableVisUiState['sort']; + /** + * Property to filter out the changes, which were done internally via local state. + */ + pendingUpdate: boolean; + }>({ + columnsWidth: uiState?.get('vis.params.colWidth'), + sort: uiState?.get('vis.params.sort'), + pendingUpdate: false, + }); + + const setSort = useCallback( + (s: TableVisUiState['sort'] = defaultSort) => { + setSortState(s || defaultSort); + + uiStateValues.current.sort = s; + uiStateValues.current.pendingUpdate = true; + + /** + * Since the visualize app state is listening for uiState changes, + * it synchronously re-renders an editor frame. + * Setting new uiState values in the new event loop task, + * helps to update the visualization frame firstly and not to block the rendering flow + */ + setTimeout(() => { + uiState?.set('vis.params.sort', s); + uiStateValues.current.pendingUpdate = false; + }); + }, + [uiState] + ); + + const setColumnsWidth = useCallback( + (col: ColumnWidthData) => { + setColumnsWidthState((prevState) => { + const updated = [...prevState]; + const idx = prevState.findIndex((c) => c.colIndex === col.colIndex); + + if (idx >= 0) { + updated[idx] = col; + } else { + updated.push(col); + } + + uiStateValues.current.columnsWidth = updated; + uiStateValues.current.pendingUpdate = true; + + /** + * Since the visualize app state is listening for uiState changes, + * it synchronously re-renders an editor frame. + * Setting new uiState values in the new event loop task, + * helps to update the visualization frame firstly and not to block the rendering flow + */ + setTimeout(() => { + uiState?.set('vis.params.colWidth', updated); + uiStateValues.current.pendingUpdate = false; + }); + return updated; + }); + }, + [uiState] + ); + + useEffect(() => { + /** + * Debounce is in place since there are couple of synchronous updates of the uiState, + * which are also handled synchronously. + */ + const updateOnChange = debounce(() => { + // skip uiState updates if there are pending internal state updates + if (uiStateValues.current.pendingUpdate) { + return; + } + + const { vis } = uiState?.getChanges(); + + if (!isEqual(vis?.params.colWidth, uiStateValues.current.columnsWidth)) { + uiStateValues.current.columnsWidth = vis?.params.colWidth; + setColumnsWidthState(vis?.params.colWidth || []); + } + + if (!isEqual(vis?.params.sort, uiStateValues.current.sort)) { + uiStateValues.current.sort = vis?.params.sort; + setSortState(vis?.params.sort || defaultSort); + } + }); + + uiState?.on('change', updateOnChange); + + return () => { + uiState?.off('change', updateOnChange); + }; + }, [uiState]); + + return { columnsWidth, sort, setColumnsWidth, setSort }; +}; diff --git a/src/plugins/vis_type_table/server/index.ts b/src/plugins/vis_type_table/server/index.ts index 882958a28777d..c876b18f5d2cc 100644 --- a/src/plugins/vis_type_table/server/index.ts +++ b/src/plugins/vis_type_table/server/index.ts @@ -22,6 +22,9 @@ import { PluginConfigDescriptor } from 'kibana/server'; import { configSchema, ConfigSchema } from '../config'; export const config: PluginConfigDescriptor = { + exposeToBrowser: { + legacyVisEnabled: true, + }, schema: configSchema, deprecations: ({ renameFromRoot }) => [ renameFromRoot('table_vis.enabled', 'vis_type_table.enabled'), diff --git a/src/plugins/vis_type_vislib/public/gauge.ts b/src/plugins/vis_type_vislib/public/gauge.ts index bd3bdd1a01e9d..7cc8e03c9e4c2 100644 --- a/src/plugins/vis_type_vislib/public/gauge.ts +++ b/src/plugins/vis_type_vislib/public/gauge.ts @@ -25,7 +25,7 @@ import { GaugeOptions } from './components/options'; import { getGaugeCollections, Alignments, GaugeTypes } from './utils/collections'; import { ColorModes, ColorSchemas, ColorSchemaParams, Labels, Style } from '../../charts/public'; import { toExpressionAst } from './to_ast'; -import { BaseVisTypeOptions } from '../../visualizations/public'; +import { BaseVisTypeOptions, VIS_EVENT_TO_TRIGGER } from '../../visualizations/public'; import { BasicVislibParams } from './types'; export interface Gauge extends ColorSchemaParams { @@ -63,6 +63,7 @@ export const gaugeVisTypeDefinition: BaseVisTypeOptions = { description: i18n.translate('visTypeVislib.gauge.gaugeDescription', { defaultMessage: 'Show the status of a metric.', }), + getSupportedTriggers: () => [VIS_EVENT_TO_TRIGGER.filter], toExpressionAst, visConfig: { defaults: { diff --git a/src/plugins/vis_type_vislib/public/vislib/visualizations/gauge_chart.js b/src/plugins/vis_type_vislib/public/vislib/visualizations/gauge_chart.js index b772aa18ed48d..a2b830ffaa781 100644 --- a/src/plugins/vis_type_vislib/public/vislib/visualizations/gauge_chart.js +++ b/src/plugins/vis_type_vislib/public/vislib/visualizations/gauge_chart.js @@ -31,7 +31,10 @@ export class GaugeChart extends Chart { addEvents(element) { const events = this.events; - return element.call(events.addHoverEvent()).call(events.addMouseoutEvent()); + return element + .call(events.addHoverEvent()) + .call(events.addMouseoutEvent()) + .call(events.addClickEvent()); } /** diff --git a/src/plugins/vis_type_vislib/public/vislib/visualizations/gauges/meter.js b/src/plugins/vis_type_vislib/public/vislib/visualizations/gauges/meter.js index a252002534775..e246fd2b466f2 100644 --- a/src/plugins/vis_type_vislib/public/vislib/visualizations/gauges/meter.js +++ b/src/plugins/vis_type_vislib/public/vislib/visualizations/gauges/meter.js @@ -344,7 +344,9 @@ export class MeterGauge { const transformX = width / 2; const transformY = height / 2 > maxRadius ? height / 2 : maxRadius; - svg.attr('transform', `translate(${transformX}, ${transformY})`); + svg + .attr('transform', `translate(${transformX}, ${transformY})`) + .attr('data-test-subj', `visGauge__meter--${data.label}`); return series; } diff --git a/src/plugins/visualizations/server/saved_objects/visualization_migrations.test.ts b/src/plugins/visualizations/server/saved_objects/visualization_migrations.test.ts index c4ee92194ec36..17c55af32005d 100644 --- a/src/plugins/visualizations/server/saved_objects/visualization_migrations.test.ts +++ b/src/plugins/visualizations/server/saved_objects/visualization_migrations.test.ts @@ -1654,4 +1654,26 @@ describe('migration visualization', () => { expect(attributes).toEqual(oldAttributes); }); }); + + describe('7.11.0 Data table vis - enable toolbar', () => { + const migrate = (doc: any) => + visualizationSavedObjectTypeMigrations['7.11.0']( + doc as Parameters[0], + savedObjectMigrationContext + ); + + const testDoc = { + attributes: { + title: 'My data table vis', + description: 'Data table vis for test.', + visState: `{"type":"table","params": {"perPage": 10,"showPartialRows": false,"showTotal": false,"totalFunc": "sum"}}`, + }, + }; + + it('should enable toolbar in visState.params', () => { + const migratedDataTableVisDoc = migrate(testDoc); + const visState = JSON.parse(migratedDataTableVisDoc.attributes.visState); + expect(visState.params.showToolbar).toEqual(true); + }); + }); }); diff --git a/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts b/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts index fbeefacf6035f..bdd87e355499b 100644 --- a/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts +++ b/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts @@ -757,6 +757,35 @@ const removeTSVBSearchSource: SavedObjectMigrationFn = (doc) => { return doc; }; +// [Data table visualization] Enable toolbar by default +const enableDataTableVisToolbar: SavedObjectMigrationFn = (doc) => { + let visState; + + try { + visState = JSON.parse(doc.attributes.visState); + } catch (e) { + // Let it go, the data is invalid and we'll leave it as is + } + + if (visState?.type === 'table') { + return { + ...doc, + attributes: { + ...doc.attributes, + visState: JSON.stringify({ + ...visState, + params: { + ...visState.params, + showToolbar: true, + }, + }), + }, + }; + } + + return doc; +}; + export const visualizationSavedObjectTypeMigrations = { /** * We need to have this migration twice, once with a version prior to 7.0.0 once with a version @@ -790,4 +819,5 @@ export const visualizationSavedObjectTypeMigrations = { '7.8.0': flow(migrateTsvbDefaultColorPalettes), '7.9.3': flow(migrateMatchAllQuery), '7.10.0': flow(migrateFilterRatioQuery, removeTSVBSearchSource), + '7.11.0': flow(enableDataTableVisToolbar), }; diff --git a/test/api_integration/apis/saved_objects/find.js b/test/api_integration/apis/saved_objects/find.js index 8e8730b1e574a..59a10379a9423 100644 --- a/test/api_integration/apis/saved_objects/find.js +++ b/test/api_integration/apis/saved_objects/find.js @@ -78,7 +78,8 @@ export default function ({ getService }) { })); }); - describe('page beyond total', () => { + // FLAKY: https://github.com/elastic/kibana/issues/85911 + describe.skip('page beyond total', () => { it('should return 200 with empty response', async () => await supertest .get('/api/saved_objects/_find?type=visualization&page=100&per_page=100') @@ -195,7 +196,7 @@ export default function ({ getService }) { }, id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', migrationVersion: { - visualization: '7.10.0', + visualization: '7.11.0', }, namespaces: ['foo-ns'], references: [ @@ -419,7 +420,7 @@ export default function ({ getService }) { })); }); - describe('without kibana index', () => { + describe.skip('without kibana index', () => { before( async () => // just in case the kibana server has recreated it diff --git a/test/functional/apps/dashboard/dashboard_filtering.js b/test/functional/apps/dashboard/dashboard_filtering.js index b3812af38c348..45950963cfded 100644 --- a/test/functional/apps/dashboard/dashboard_filtering.js +++ b/test/functional/apps/dashboard/dashboard_filtering.js @@ -84,7 +84,7 @@ export default function ({ getService, getPageObjects }) { }); it('data tables are filtered', async () => { - await dashboardExpect.dataTableRowCount(0); + await dashboardExpect.dataTableNoResult(); }); it('goal and guages are filtered', async () => { @@ -145,7 +145,7 @@ export default function ({ getService, getPageObjects }) { }); it('data tables are filtered', async () => { - await dashboardExpect.dataTableRowCount(0); + await dashboardExpect.dataTableNoResult(); }); it('goal and guages are filtered', async () => { diff --git a/test/functional/apps/dashboard/embeddable_rendering.js b/test/functional/apps/dashboard/embeddable_rendering.js index b7b795ae11c96..77fe28a01f86d 100644 --- a/test/functional/apps/dashboard/embeddable_rendering.js +++ b/test/functional/apps/dashboard/embeddable_rendering.js @@ -79,7 +79,7 @@ export default function ({ getService, getPageObjects }) { const expectNoDataRenders = async () => { await pieChart.expectPieSliceCount(0); await dashboardExpect.seriesElementCount(0); - await dashboardExpect.dataTableRowCount(0); + await dashboardExpect.dataTableNoResult(); await dashboardExpect.savedSearchRowCount(0); await dashboardExpect.inputControlItemCount(5); await dashboardExpect.metricValuesExist(['0']); diff --git a/test/functional/apps/visualize/_data_table.js b/test/functional/apps/visualize/_data_table.ts similarity index 87% rename from test/functional/apps/visualize/_data_table.js rename to test/functional/apps/visualize/_data_table.ts index 5b0b7af56b332..ab5ee31f8b00f 100644 --- a/test/functional/apps/visualize/_data_table.js +++ b/test/functional/apps/visualize/_data_table.ts @@ -18,8 +18,9 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, getPageObjects }) { +export default function ({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); const inspector = getService('inspector'); const testSubjects = getService('testSubjects'); @@ -97,12 +98,10 @@ export default function ({ getService, getPageObjects }) { it('should show percentage columns', async () => { async function expectValidTableData() { - const data = await PageObjects.visChart.getTableVisData(); - expect(data.trim().split('\n')).to.be.eql([ - '≥ 0B and < 1,000B', - '1,351 64.703%', - '≥ 1,000B and < 1.953KB', - '737 35.297%', + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([ + ['≥ 0B and < 1,000B', '1,351', '64.703%'], + ['≥ 1,000B and < 1.953KB', '737', '35.297%'], ]); } @@ -142,12 +141,10 @@ export default function ({ getService, getPageObjects }) { await PageObjects.visEditor.clickGo(); await PageObjects.visEditor.clickOptionsTab(); - const data = await PageObjects.visChart.getTableVisData(); - expect(data.trim().split('\n')).to.be.eql([ - '≥ 0B and < 1,000B', - '344.094B', - '≥ 1,000B and < 1.953KB', - '1.697KB', + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([ + ['≥ 0B and < 1,000B', '344.094B'], + ['≥ 1,000B and < 1.953KB', '1.697KB'], ]); }); @@ -158,34 +155,11 @@ export default function ({ getService, getPageObjects }) { await PageObjects.timePicker.setDefaultAbsoluteRange(); await PageObjects.visEditor.clickBucket('Metric', 'metrics'); await PageObjects.visEditor.selectAggregation('Average Bucket', 'metrics'); - await PageObjects.visEditor.selectAggregation('Terms', 'metrics', 'buckets'); - await PageObjects.visEditor.selectField('geo.src', 'metrics', 'buckets'); + await PageObjects.visEditor.selectAggregation('Terms', 'metrics', true); + await PageObjects.visEditor.selectField('geo.src', 'metrics', true); await PageObjects.visEditor.clickGo(); - const data = await PageObjects.visChart.getTableVisData(); - log.debug(data.split('\n')); - expect(data.trim().split('\n')).to.be.eql(['14,004 1,412.6']); - }); - - it('should show correct data for a data table with date histogram', async () => { - await PageObjects.visualize.navigateToNewAggBasedVisualization(); - await PageObjects.visualize.clickDataTable(); - await PageObjects.visualize.clickNewSearch(); - await PageObjects.timePicker.setDefaultAbsoluteRange(); - await PageObjects.visEditor.clickBucket('Split rows'); - await PageObjects.visEditor.selectAggregation('Date Histogram'); - await PageObjects.visEditor.selectField('@timestamp'); - await PageObjects.visEditor.setInterval('Day'); - await PageObjects.visEditor.clickGo(); - const data = await PageObjects.visChart.getTableVisData(); - log.debug(data.split('\n')); - expect(data.trim().split('\n')).to.be.eql([ - '2015-09-20', - '4,757', - '2015-09-21', - '4,614', - '2015-09-22', - '4,633', - ]); + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([['14,004', '1,412.6']]); }); it('should show correct data for a data table with date histogram', async () => { @@ -198,14 +172,11 @@ export default function ({ getService, getPageObjects }) { await PageObjects.visEditor.selectField('@timestamp'); await PageObjects.visEditor.setInterval('Day'); await PageObjects.visEditor.clickGo(); - const data = await PageObjects.visChart.getTableVisData(); - expect(data.trim().split('\n')).to.be.eql([ - '2015-09-20', - '4,757', - '2015-09-21', - '4,614', - '2015-09-22', - '4,633', + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([ + ['2015-09-20', '4,757'], + ['2015-09-21', '4,614'], + ['2015-09-22', '4,633'], ]); }); @@ -219,14 +190,11 @@ export default function ({ getService, getPageObjects }) { await PageObjects.visEditor.selectField('UTC time'); await PageObjects.visEditor.setInterval('Day'); await PageObjects.visEditor.clickGo(); - const data = await PageObjects.visChart.getTableVisData(); - expect(data.trim().split('\n')).to.be.eql([ - '2015-09-20', - '4,757', - '2015-09-21', - '4,614', - '2015-09-22', - '4,633', + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([ + ['2015-09-20', '4,757'], + ['2015-09-21', '4,614'], + ['2015-09-22', '4,633'], ]); const header = await PageObjects.visChart.getTableVisHeader(); expect(header).to.contain('UTC time'); @@ -235,15 +203,15 @@ export default function ({ getService, getPageObjects }) { it('should correctly filter for applied time filter on the main timefield', async () => { await filterBar.addFilter('@timestamp', 'is between', '2015-09-19', '2015-09-21'); await PageObjects.visChart.waitForVisualizationRenderingStabilized(); - const data = await PageObjects.visChart.getTableVisData(); - expect(data.trim().split('\n')).to.be.eql(['2015-09-20', '4,757']); + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([['2015-09-20', '4,757']]); }); it('should correctly filter for pinned filters', async () => { await filterBar.toggleFilterPinned('@timestamp'); await PageObjects.visChart.waitForVisualizationRenderingStabilized(); - const data = await PageObjects.visChart.getTableVisData(); - expect(data.trim().split('\n')).to.be.eql(['2015-09-20', '4,757']); + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([['2015-09-20', '4,757']]); }); it('should show correct data for a data table with top hits', async () => { @@ -255,7 +223,7 @@ export default function ({ getService, getPageObjects }) { await PageObjects.visEditor.selectAggregation('Top Hit', 'metrics'); await PageObjects.visEditor.selectField('agent.raw', 'metrics'); await PageObjects.visEditor.clickGo(); - const data = await PageObjects.visChart.getTableVisData(); + const data = await PageObjects.visChart.getTableVisContent(); log.debug(data); expect(data.length).to.be.greaterThan(0); }); @@ -269,12 +237,10 @@ export default function ({ getService, getPageObjects }) { await PageObjects.visEditor.selectAggregation('Range'); await PageObjects.visEditor.selectField('bytes'); await PageObjects.visEditor.clickGo(); - const data = await PageObjects.visChart.getTableVisData(); - expect(data.trim().split('\n')).to.be.eql([ - '≥ 0B and < 1,000B', - '1,351', - '≥ 1,000B and < 1.953KB', - '737', + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([ + ['≥ 0B and < 1,000B', '1,351'], + ['≥ 1,000B and < 1.953KB', '737'], ]); }); diff --git a/test/functional/apps/visualize/_data_table_nontimeindex.js b/test/functional/apps/visualize/_data_table_nontimeindex.ts similarity index 87% rename from test/functional/apps/visualize/_data_table_nontimeindex.js rename to test/functional/apps/visualize/_data_table_nontimeindex.ts index f45e40970a57f..247107a07a550 100644 --- a/test/functional/apps/visualize/_data_table_nontimeindex.js +++ b/test/functional/apps/visualize/_data_table_nontimeindex.ts @@ -18,8 +18,9 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, getPageObjects }) { +export default function ({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); const inspector = getService('inspector'); const retry = getService('retry'); @@ -104,12 +105,11 @@ export default function ({ getService, getPageObjects }) { ); await PageObjects.visEditor.clickBucket('Metric', 'metrics'); await PageObjects.visEditor.selectAggregation('Average Bucket', 'metrics'); - await PageObjects.visEditor.selectAggregation('Terms', 'metrics', 'buckets'); - await PageObjects.visEditor.selectField('geo.src', 'metrics', 'buckets'); + await PageObjects.visEditor.selectAggregation('Terms', 'metrics', true); + await PageObjects.visEditor.selectField('geo.src', 'metrics', true); await PageObjects.visEditor.clickGo(); - const data = await PageObjects.visChart.getTableVisData(); - log.debug(data.split('\n')); - expect(data.trim().split('\n')).to.be.eql(['14,004 1,412.6']); + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([['14,004', '1,412.6']]); }); describe('data table with date histogram', async () => { @@ -127,15 +127,11 @@ export default function ({ getService, getPageObjects }) { }); it('should show correct data', async () => { - const data = await PageObjects.visChart.getTableVisData(); - log.debug(data.split('\n')); - expect(data.trim().split('\n')).to.be.eql([ - '2015-09-20', - '4,757', - '2015-09-21', - '4,614', - '2015-09-22', - '4,633', + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([ + ['2015-09-20', '4,757'], + ['2015-09-21', '4,614'], + ['2015-09-22', '4,633'], ]); }); @@ -143,16 +139,16 @@ export default function ({ getService, getPageObjects }) { await filterBar.addFilter('@timestamp', 'is between', '2015-09-19', '2015-09-21'); await PageObjects.header.waitUntilLoadingHasFinished(); await renderable.waitForRender(); - const data = await PageObjects.visChart.getTableVisData(); - expect(data.trim().split('\n')).to.be.eql(['2015-09-20', '4,757']); + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([['2015-09-20', '4,757']]); }); it('should correctly filter for pinned filters', async () => { await filterBar.toggleFilterPinned('@timestamp'); await PageObjects.header.waitUntilLoadingHasFinished(); await renderable.waitForRender(); - const data = await PageObjects.visChart.getTableVisData(); - expect(data.trim().split('\n')).to.be.eql(['2015-09-20', '4,757']); + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([['2015-09-20', '4,757']]); }); }); }); diff --git a/test/functional/apps/visualize/_data_table_notimeindex_filters.ts b/test/functional/apps/visualize/_data_table_notimeindex_filters.ts index cdcf08d2514e5..d847d3bf4435c 100644 --- a/test/functional/apps/visualize/_data_table_notimeindex_filters.ts +++ b/test/functional/apps/visualize/_data_table_notimeindex_filters.ts @@ -77,7 +77,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardAddPanel.addVisualization(vizName1); // hover and click on cell to filter - await PageObjects.visChart.filterOnTableCell('1', '2'); + await PageObjects.visChart.filterOnTableCell(1, 2); await PageObjects.header.waitUntilLoadingHasFinished(); await renderable.waitForRender(); diff --git a/test/functional/apps/visualize/_embedding_chart.js b/test/functional/apps/visualize/_embedding_chart.ts similarity index 54% rename from test/functional/apps/visualize/_embedding_chart.js rename to test/functional/apps/visualize/_embedding_chart.ts index 773bd63d8892f..9a8bb7dde442d 100644 --- a/test/functional/apps/visualize/_embedding_chart.js +++ b/test/functional/apps/visualize/_embedding_chart.ts @@ -18,10 +18,10 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, getPageObjects }) { +export default function ({ getService, getPageObjects }: FtrProviderContext) { const filterBar = getService('filterBar'); - const log = getService('log'); const renderable = getService('renderable'); const embedding = getService('embedding'); const PageObjects = getPageObjects([ @@ -54,39 +54,18 @@ export default function ({ getService, getPageObjects }) { await embedding.openInEmbeddedMode(); await renderable.waitForRender(); - const data = await PageObjects.visChart.getTableVisData(); - log.debug(data.split('\n')); - expect(data.trim().split('\n')).to.be.eql([ - '2015-09-20 00:00', - '0B', - '5', - '2015-09-20 00:00', - '1.953KB', - '5', - '2015-09-20 00:00', - '3.906KB', - '9', - '2015-09-20 00:00', - '5.859KB', - '4', - '2015-09-20 00:00', - '7.813KB', - '14', - '2015-09-20 03:00', - '0B', - '32', - '2015-09-20 03:00', - '1.953KB', - '33', - '2015-09-20 03:00', - '3.906KB', - '45', - '2015-09-20 03:00', - '5.859KB', - '31', - '2015-09-20 03:00', - '7.813KB', - '48', + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([ + ['2015-09-20 00:00', '0B', '5'], + ['2015-09-20 00:00', '1.953KB', '5'], + ['2015-09-20 00:00', '3.906KB', '9'], + ['2015-09-20 00:00', '5.859KB', '4'], + ['2015-09-20 00:00', '7.813KB', '14'], + ['2015-09-20 03:00', '0B', '32'], + ['2015-09-20 03:00', '1.953KB', '33'], + ['2015-09-20 03:00', '3.906KB', '45'], + ['2015-09-20 03:00', '5.859KB', '31'], + ['2015-09-20 03:00', '7.813KB', '48'], ]); }); @@ -95,39 +74,18 @@ export default function ({ getService, getPageObjects }) { await PageObjects.header.waitUntilLoadingHasFinished(); await renderable.waitForRender(); - const data = await PageObjects.visChart.getTableVisData(); - log.debug(data.split('\n')); - expect(data.trim().split('\n')).to.be.eql([ - '2015-09-21 00:00', - '0B', - '7', - '2015-09-21 00:00', - '1.953KB', - '9', - '2015-09-21 00:00', - '3.906KB', - '9', - '2015-09-21 00:00', - '5.859KB', - '6', - '2015-09-21 00:00', - '7.813KB', - '10', - '2015-09-21 00:00', - '11.719KB', - '1', - '2015-09-21 03:00', - '0B', - '28', - '2015-09-21 03:00', - '1.953KB', - '39', - '2015-09-21 03:00', - '3.906KB', - '36', - '2015-09-21 03:00', - '5.859KB', - '43', + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([ + ['2015-09-21 00:00', '0B', '7'], + ['2015-09-21 00:00', '1.953KB', '9'], + ['2015-09-21 00:00', '3.906KB', '9'], + ['2015-09-21 00:00', '5.859KB', '6'], + ['2015-09-21 00:00', '7.813KB', '10'], + ['2015-09-21 00:00', '11.719KB', '1'], + ['2015-09-21 03:00', '0B', '28'], + ['2015-09-21 03:00', '1.953KB', '39'], + ['2015-09-21 03:00', '3.906KB', '36'], + ['2015-09-21 03:00', '5.859KB', '43'], ]); }); @@ -136,39 +94,18 @@ export default function ({ getService, getPageObjects }) { await PageObjects.header.waitUntilLoadingHasFinished(); await renderable.waitForRender(); - const data = await PageObjects.visChart.getTableVisData(); - log.debug(data.split('\n')); - expect(data.trim().split('\n')).to.be.eql([ - '03:00', - '0B', - '1', - '03:00', - '1.953KB', - '1', - '03:00', - '3.906KB', - '1', - '03:00', - '5.859KB', - '2', - '03:10', - '0B', - '1', - '03:10', - '5.859KB', - '1', - '03:10', - '7.813KB', - '1', - '03:15', - '0B', - '1', - '03:15', - '1.953KB', - '1', - '03:20', - '1.953KB', - '1', + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([ + ['03:00', '0B', '1'], + ['03:00', '1.953KB', '1'], + ['03:00', '3.906KB', '1'], + ['03:00', '5.859KB', '2'], + ['03:10', '0B', '1'], + ['03:10', '5.859KB', '1'], + ['03:10', '7.813KB', '1'], + ['03:15', '0B', '1'], + ['03:15', '1.953KB', '1'], + ['03:20', '1.953KB', '1'], ]); }); }); diff --git a/test/functional/apps/visualize/_gauge_chart.js b/test/functional/apps/visualize/_gauge_chart.js index 06f5913aec814..2339595997901 100644 --- a/test/functional/apps/visualize/_gauge_chart.js +++ b/test/functional/apps/visualize/_gauge_chart.js @@ -23,6 +23,7 @@ export default function ({ getService, getPageObjects }) { const log = getService('log'); const retry = getService('retry'); const inspector = getService('inspector'); + const filterBar = getService('filterBar'); const testSubjects = getService('testSubjects'); const PageObjects = getPageObjects(['visualize', 'visEditor', 'visChart', 'timePicker']); @@ -52,50 +53,7 @@ export default function ({ getService, getPageObjects }) { }); }); - it('should show Split Gauges', async function () { - log.debug('Bucket = Split Group'); - await PageObjects.visEditor.clickBucket('Split group'); - log.debug('Aggregation = Terms'); - await PageObjects.visEditor.selectAggregation('Terms'); - log.debug('Field = machine.os.raw'); - await PageObjects.visEditor.selectField('machine.os.raw'); - log.debug('Size = 4'); - await PageObjects.visEditor.setSize('4'); - await PageObjects.visEditor.clickGo(); - - await retry.try(async () => { - expect(await PageObjects.visChart.getGaugeValue()).to.eql([ - '2,904', - 'win 8', - '2,858', - 'win xp', - '2,814', - 'win 7', - '2,784', - 'ios', - ]); - }); - }); - - it('should show correct values for fields with fieldFormatters', async function () { - const expectedTexts = ['2,904', 'win 8: Count', '0B', 'win 8: Min bytes']; - - await PageObjects.visEditor.selectAggregation('Terms'); - await PageObjects.visEditor.selectField('machine.os.raw'); - await PageObjects.visEditor.setSize('1'); - await PageObjects.visEditor.clickBucket('Metric', 'metrics'); - await PageObjects.visEditor.selectAggregation('Min', 'metrics'); - await PageObjects.visEditor.selectField('bytes', 'metrics'); - await PageObjects.visEditor.clickGo(); - - await retry.try(async function tryingForTime() { - const metricValue = await PageObjects.visChart.getGaugeValue(); - expect(expectedTexts).to.eql(metricValue); - }); - }); - it('should format the metric correctly in percentage mode', async function () { - await initGaugeVis(); await PageObjects.visEditor.clickMetricEditor(); await PageObjects.visEditor.selectAggregation('Average', 'metrics'); await PageObjects.visEditor.selectField('bytes', 'metrics'); @@ -111,5 +69,61 @@ export default function ({ getService, getPageObjects }) { expect(expectedTexts).to.eql(metricValue); }); }); + + describe('Split Gauges', () => { + before(async () => { + await initGaugeVis(); + log.debug('Bucket = Split Group'); + await PageObjects.visEditor.clickBucket('Split group'); + log.debug('Aggregation = Terms'); + await PageObjects.visEditor.selectAggregation('Terms'); + log.debug('Field = machine.os.raw'); + await PageObjects.visEditor.selectField('machine.os.raw'); + log.debug('Size = 4'); + await PageObjects.visEditor.setSize('4'); + await PageObjects.visEditor.clickGo(); + }); + + it('should show Split Gauges', async () => { + await retry.try(async () => { + expect(await PageObjects.visChart.getGaugeValue()).to.eql([ + '2,904', + 'win 8', + '2,858', + 'win xp', + '2,814', + 'win 7', + '2,784', + 'ios', + ]); + }); + }); + + it('should add machine.os.raw:win 8 filter by click on the first Gauge', async () => { + await PageObjects.visChart.clickOnGaugeByLabel('win 8'); + const hasFilter = await filterBar.hasFilter('machine.os.raw', 'win 8'); + + expect(hasFilter).to.eql(true); + }); + + it('should show correct values for fields with fieldFormatters', async () => { + const expectedTexts = ['2,904', 'win 8: Count', '0B', 'win 8: Min bytes']; + + await PageObjects.visEditor.selectAggregation('Terms'); + await PageObjects.visEditor.selectField('machine.os.raw'); + await PageObjects.visEditor.setSize('1'); + await PageObjects.visEditor.clickBucket('Metric', 'metrics'); + await PageObjects.visEditor.selectAggregation('Min', 'metrics'); + await PageObjects.visEditor.selectField('bytes', 'metrics'); + await PageObjects.visEditor.clickGo(); + + await retry.try(async function tryingForTime() { + const metricValue = await PageObjects.visChart.getGaugeValue(); + expect(expectedTexts).to.eql(metricValue); + }); + }); + + afterEach(async () => await filterBar.removeAllFilters()); + }); }); } diff --git a/test/functional/apps/visualize/_histogram_request_start.js b/test/functional/apps/visualize/_histogram_request_start.ts similarity index 76% rename from test/functional/apps/visualize/_histogram_request_start.js rename to test/functional/apps/visualize/_histogram_request_start.ts index 8489cffa805da..d51a90c997e66 100644 --- a/test/functional/apps/visualize/_histogram_request_start.js +++ b/test/functional/apps/visualize/_histogram_request_start.ts @@ -18,10 +18,12 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, getPageObjects }) { +export default function ({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); const retry = getService('retry'); + const PageObjects = getPageObjects([ 'common', 'visualize', @@ -48,33 +50,32 @@ export default function ({ getService, getPageObjects }) { describe('interval parameter uses autoBounds', function () { it('should use provided value when number of generated buckets is less than histogram:maxBars', async function () { - const providedInterval = 2400000000; + const providedInterval = '2400000000'; log.debug(`Interval = ${providedInterval}`); await PageObjects.visEditor.setInterval(providedInterval, { type: 'numeric' }); await PageObjects.visEditor.clickGo(); + await retry.try(async () => { - const data = await PageObjects.visChart.getTableVisData(); - const dataArray = data.replace(/,/g, '').split('\n'); - expect(dataArray.length).to.eql(20); - const bucketStart = parseInt(dataArray[0], 10); - const bucketEnd = parseInt(dataArray[2], 10); + const data = await PageObjects.visChart.getTableVisContent(); + expect(data.length).to.eql(10); + const bucketStart = parseInt((data[0][0] as string).replace(/,/g, ''), 10); + const bucketEnd = parseInt((data[1][0] as string).replace(/,/g, ''), 10); const actualInterval = bucketEnd - bucketStart; expect(actualInterval).to.eql(providedInterval); }); }); it('should scale value to round number when number of generated buckets is greater than histogram:maxBars', async function () { - const providedInterval = 100; + const providedInterval = '100'; log.debug(`Interval = ${providedInterval}`); await PageObjects.visEditor.setInterval(providedInterval, { type: 'numeric' }); await PageObjects.visEditor.clickGo(); - await PageObjects.common.sleep(1000); //fix this + await PageObjects.common.sleep(1000); // fix this await retry.try(async () => { - const data = await PageObjects.visChart.getTableVisData(); - const dataArray = data.replace(/,/g, '').split('\n'); - expect(dataArray.length).to.eql(20); - const bucketStart = parseInt(dataArray[0], 10); - const bucketEnd = parseInt(dataArray[2], 10); + const data = await PageObjects.visChart.getTableVisContent(); + expect(data.length).to.eql(10); + const bucketStart = parseInt((data[0][0] as string).replace(/,/g, ''), 10); + const bucketEnd = parseInt((data[1][0] as string).replace(/,/g, ''), 10); const actualInterval = bucketEnd - bucketStart; expect(actualInterval).to.eql(1200000000); }); diff --git a/test/functional/apps/visualize/_linked_saved_searches.ts b/test/functional/apps/visualize/_linked_saved_searches.ts index a5a9c47d3d010..e7402de399e41 100644 --- a/test/functional/apps/visualize/_linked_saved_searches.ts +++ b/test/functional/apps/visualize/_linked_saved_searches.ts @@ -52,8 +52,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visualize.clickSavedSearch(savedSearchName); await PageObjects.timePicker.setDefaultAbsoluteRange(); await retry.waitFor('wait for count to equal 9,109', async () => { - const data = await PageObjects.visChart.getTableVisData(); - return data.trim() === '9,109'; + const data = await PageObjects.visChart.getTableVisContent(); + return data[0][0] === '9,109'; }); }); @@ -81,8 +81,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'Sep 21, 2015 @ 10:00:00.000' ); await retry.waitFor('wait for count to equal 3,950', async () => { - const data = await PageObjects.visChart.getTableVisData(); - return data.trim() === '3,950'; + const data = await PageObjects.visChart.getTableVisContent(); + return data[0][0] === '3,950'; }); }); @@ -90,16 +90,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await filterBar.addFilter('bytes', 'is between', '100', '3000'); await PageObjects.header.waitUntilLoadingHasFinished(); await retry.waitFor('wait for count to equal 707', async () => { - const data = await PageObjects.visChart.getTableVisData(); - return data.trim() === '707'; + const data = await PageObjects.visChart.getTableVisContent(); + return data[0][0] === '707'; }); }); it('should allow unlinking from a linked search', async () => { await PageObjects.visualize.clickUnlinkSavedSearch(); await retry.waitFor('wait for count to equal 707', async () => { - const data = await PageObjects.visChart.getTableVisData(); - return data.trim() === '707'; + const data = await PageObjects.visChart.getTableVisContent(); + return data[0][0] === '707'; }); // The filter on the saved search should now be in the editor expect(await filterBar.hasFilter('extension.raw', 'jpg')).to.be(true); @@ -109,8 +109,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await filterBar.toggleFilterEnabled('extension.raw'); await PageObjects.header.waitUntilLoadingHasFinished(); await retry.waitFor('wait for count to equal 1,293', async () => { - const unfilteredData = await PageObjects.visChart.getTableVisData(); - return unfilteredData.trim() === '1,293'; + const unfilteredData = await PageObjects.visChart.getTableVisContent(); + return unfilteredData[0][0] === '1,293'; }); }); @@ -118,8 +118,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visualize.saveVisualizationExpectSuccess('Unlinked before saved'); await PageObjects.header.waitUntilLoadingHasFinished(); await retry.waitFor('wait for count to equal 1,293', async () => { - const data = await PageObjects.visChart.getTableVisData(); - return data.trim() === '1,293'; + const data = await PageObjects.visChart.getTableVisContent(); + return data[0][0] === '1,293'; }); }); }); diff --git a/test/functional/apps/visualize/_tsvb_chart.ts b/test/functional/apps/visualize/_tsvb_chart.ts index 972a14842b79e..6ded88cd50b39 100644 --- a/test/functional/apps/visualize/_tsvb_chart.ts +++ b/test/functional/apps/visualize/_tsvb_chart.ts @@ -35,8 +35,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'common', ]); - // Failing: See https://github.com/elastic/kibana/issues/75127 - describe.skip('visual builder', function describeIndexTests() { + describe('visual builder', function describeIndexTests() { this.tags('includeFirefox'); beforeEach(async () => { await security.testUser.setRoles([ @@ -144,7 +143,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visualBuilder.selectIndexPatternTimeField('timestamp'); }); const newValue = await PageObjects.visualBuilder.getMetricValue(); - expect(newValue).to.eql('10'); + expect(newValue).to.eql('18'); }); }); @@ -174,9 +173,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await browser.goBack(); log.debug('Check timeseries chart and panel config is rendered'); - await PageObjects.visualBuilder.checkTimeSeriesChartIsPresent(); - await PageObjects.visualBuilder.checkTabIsSelected('timeseries'); - await PageObjects.visualBuilder.checkPanelConfigIsPresent('timeseries'); + await retry.try(async () => { + await PageObjects.visualBuilder.checkTimeSeriesChartIsPresent(); + await PageObjects.visualBuilder.checkTabIsSelected('timeseries'); + await PageObjects.visualBuilder.checkPanelConfigIsPresent('timeseries'); + }); log.debug('Go forward in browser history'); await browser.goForward(); diff --git a/test/functional/apps/visualize/legacy/_data_table.ts b/test/functional/apps/visualize/legacy/_data_table.ts new file mode 100644 index 0000000000000..cec58e2c717c4 --- /dev/null +++ b/test/functional/apps/visualize/legacy/_data_table.ts @@ -0,0 +1,331 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from 'test/functional/ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const log = getService('log'); + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects([ + 'visualize', + 'timePicker', + 'visEditor', + 'visChart', + 'legacyDataTableVis', + ]); + + describe('legacy data table visualization', function indexPatternCreation() { + before(async function () { + log.debug('navigateToApp visualize'); + await PageObjects.visualize.navigateToNewAggBasedVisualization(); + log.debug('clickDataTable'); + await PageObjects.visualize.clickDataTable(); + log.debug('clickNewSearch'); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + log.debug('Bucket = Split rows'); + await PageObjects.visEditor.clickBucket('Split rows'); + log.debug('Aggregation = Histogram'); + await PageObjects.visEditor.selectAggregation('Histogram'); + log.debug('Field = bytes'); + await PageObjects.visEditor.selectField('bytes'); + log.debug('Interval = 2000'); + await PageObjects.visEditor.setInterval('2000', { type: 'numeric' }); + await PageObjects.visEditor.clickGo(); + }); + + it('should show percentage columns', async () => { + async function expectValidTableData() { + const data = await PageObjects.legacyDataTableVis.getTableVisContent(); + expect(data).to.be.eql([ + ['≥ 0B and < 1,000B', '1,351', '64.703%'], + ['≥ 1,000B and < 1.953KB', '737', '35.297%'], + ]); + } + + // load a plain table + await PageObjects.visualize.navigateToNewAggBasedVisualization(); + await PageObjects.visualize.clickDataTable(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + await PageObjects.visEditor.clickBucket('Split rows'); + await PageObjects.visEditor.selectAggregation('Range'); + await PageObjects.visEditor.selectField('bytes'); + await PageObjects.visEditor.clickGo(); + await PageObjects.visEditor.clickOptionsTab(); + await PageObjects.visEditor.setSelectByOptionText( + 'datatableVisualizationPercentageCol', + 'Count' + ); + await PageObjects.visEditor.clickGo(); + + await expectValidTableData(); + + // check that it works after selecting a column that's deleted + await PageObjects.visEditor.clickDataTab(); + await PageObjects.visEditor.clickBucket('Metric', 'metrics'); + await PageObjects.visEditor.selectAggregation('Average', 'metrics'); + await PageObjects.visEditor.selectField('bytes', 'metrics'); + await PageObjects.visEditor.removeDimension(1); + await PageObjects.visEditor.clickGo(); + await PageObjects.visEditor.clickOptionsTab(); + + const data = await PageObjects.legacyDataTableVis.getTableVisContent(); + expect(data).to.be.eql([ + ['≥ 0B and < 1,000B', '344.094B'], + ['≥ 1,000B and < 1.953KB', '1.697KB'], + ]); + }); + + it('should show correct data for a data table with date histogram', async () => { + await PageObjects.visualize.navigateToNewAggBasedVisualization(); + await PageObjects.visualize.clickDataTable(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + await PageObjects.visEditor.clickBucket('Split rows'); + await PageObjects.visEditor.selectAggregation('Date Histogram'); + await PageObjects.visEditor.selectField('@timestamp'); + await PageObjects.visEditor.setInterval('Day'); + await PageObjects.visEditor.clickGo(); + const data = await PageObjects.legacyDataTableVis.getTableVisContent(); + expect(data).to.be.eql([ + ['2015-09-20', '4,757'], + ['2015-09-21', '4,614'], + ['2015-09-22', '4,633'], + ]); + }); + + describe('otherBucket', () => { + before(async () => { + await PageObjects.visualize.navigateToNewAggBasedVisualization(); + await PageObjects.visualize.clickDataTable(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + await PageObjects.visEditor.clickBucket('Split rows'); + await PageObjects.visEditor.selectAggregation('Terms'); + await PageObjects.visEditor.selectField('extension.raw'); + await PageObjects.visEditor.setSize(2); + await PageObjects.visEditor.clickGo(); + + await PageObjects.visEditor.toggleOtherBucket(); + await PageObjects.visEditor.toggleMissingBucket(); + await PageObjects.visEditor.clickGo(); + }); + + it('should show correct data', async () => { + const data = await PageObjects.legacyDataTableVis.getTableVisContent(); + expect(data).to.be.eql([ + ['jpg', '9,109'], + ['css', '2,159'], + ['Other', '2,736'], + ]); + }); + + it('should apply correct filter', async () => { + await PageObjects.legacyDataTableVis.filterOnTableCell(1, 3); + await PageObjects.visChart.waitForVisualizationRenderingStabilized(); + const data = await PageObjects.legacyDataTableVis.getTableVisContent(); + expect(data).to.be.eql([ + ['png', '1,373'], + ['gif', '918'], + ['Other', '445'], + ]); + }); + }); + + describe('metricsOnAllLevels', () => { + before(async () => { + await PageObjects.visualize.navigateToNewAggBasedVisualization(); + await PageObjects.visualize.clickDataTable(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + await PageObjects.visEditor.clickBucket('Split rows'); + await PageObjects.visEditor.selectAggregation('Terms'); + await PageObjects.visEditor.selectField('extension.raw'); + await PageObjects.visEditor.setSize(2); + await PageObjects.visEditor.toggleOpenEditor(2, 'false'); + await PageObjects.visEditor.clickBucket('Split rows'); + await PageObjects.visEditor.selectAggregation('Terms'); + await PageObjects.visEditor.selectField('geo.dest'); + await PageObjects.visEditor.toggleOpenEditor(3, 'false'); + await PageObjects.visEditor.clickGo(); + }); + + it('should show correct data without showMetricsAtAllLevels', async () => { + const data = await PageObjects.legacyDataTableVis.getTableVisContent(); + expect(data).to.be.eql([ + ['jpg', 'CN', '1,718'], + ['jpg', 'IN', '1,511'], + ['jpg', 'US', '770'], + ['jpg', 'ID', '314'], + ['jpg', 'PK', '244'], + ['css', 'CN', '422'], + ['css', 'IN', '346'], + ['css', 'US', '189'], + ['css', 'ID', '68'], + ['css', 'BR', '58'], + ]); + }); + + it('should show correct data without showMetricsAtAllLevels even if showPartialRows is selected', async () => { + await PageObjects.visEditor.clickOptionsTab(); + await testSubjects.setCheckbox('showPartialRows', 'check'); + await PageObjects.visEditor.clickGo(); + const data = await PageObjects.legacyDataTableVis.getTableVisContent(); + expect(data).to.be.eql([ + ['jpg', 'CN', '1,718'], + ['jpg', 'IN', '1,511'], + ['jpg', 'US', '770'], + ['jpg', 'ID', '314'], + ['jpg', 'PK', '244'], + ['css', 'CN', '422'], + ['css', 'IN', '346'], + ['css', 'US', '189'], + ['css', 'ID', '68'], + ['css', 'BR', '58'], + ]); + }); + + it('should show metrics on each level', async () => { + await PageObjects.visEditor.clickOptionsTab(); + await testSubjects.setCheckbox('showMetricsAtAllLevels', 'check'); + await PageObjects.visEditor.clickGo(); + const data = await PageObjects.legacyDataTableVis.getTableVisContent(); + expect(data).to.be.eql([ + ['jpg', '9,109', 'CN', '1,718'], + ['jpg', '9,109', 'IN', '1,511'], + ['jpg', '9,109', 'US', '770'], + ['jpg', '9,109', 'ID', '314'], + ['jpg', '9,109', 'PK', '244'], + ['css', '2,159', 'CN', '422'], + ['css', '2,159', 'IN', '346'], + ['css', '2,159', 'US', '189'], + ['css', '2,159', 'ID', '68'], + ['css', '2,159', 'BR', '58'], + ]); + }); + + it('should show metrics other than count on each level', async () => { + await PageObjects.visEditor.clickDataTab(); + await PageObjects.visEditor.clickBucket('Metric', 'metrics'); + await PageObjects.visEditor.selectAggregation('Average', 'metrics'); + await PageObjects.visEditor.selectField('bytes', 'metrics'); + await PageObjects.visEditor.clickGo(); + const data = await PageObjects.legacyDataTableVis.getTableVisContent(); + expect(data).to.be.eql([ + ['jpg', '9,109', '5.469KB', 'CN', '1,718', '5.477KB'], + ['jpg', '9,109', '5.469KB', 'IN', '1,511', '5.456KB'], + ['jpg', '9,109', '5.469KB', 'US', '770', '5.371KB'], + ['jpg', '9,109', '5.469KB', 'ID', '314', '5.424KB'], + ['jpg', '9,109', '5.469KB', 'PK', '244', '5.41KB'], + ['css', '2,159', '5.566KB', 'CN', '422', '5.712KB'], + ['css', '2,159', '5.566KB', 'IN', '346', '5.754KB'], + ['css', '2,159', '5.566KB', 'US', '189', '5.333KB'], + ['css', '2,159', '5.566KB', 'ID', '68', '4.82KB'], + ['css', '2,159', '5.566KB', 'BR', '58', '5.915KB'], + ]); + }); + }); + + describe('split tables', () => { + before(async () => { + await PageObjects.visualize.navigateToNewAggBasedVisualization(); + await PageObjects.visualize.clickDataTable(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + await PageObjects.visEditor.clickBucket('Split table'); + await PageObjects.visEditor.selectAggregation('Terms'); + await PageObjects.visEditor.selectField('extension.raw'); + await PageObjects.visEditor.setSize(2); + await PageObjects.visEditor.toggleOpenEditor(2, 'false'); + await PageObjects.visEditor.clickBucket('Split rows'); + await PageObjects.visEditor.selectAggregation('Terms'); + await PageObjects.visEditor.selectField('geo.dest'); + await PageObjects.visEditor.setSize(3, 3); + await PageObjects.visEditor.toggleOpenEditor(3, 'false'); + await PageObjects.visEditor.clickBucket('Split rows'); + await PageObjects.visEditor.selectAggregation('Terms'); + await PageObjects.visEditor.selectField('geo.src'); + await PageObjects.visEditor.setSize(3, 4); + await PageObjects.visEditor.toggleOpenEditor(4, 'false'); + await PageObjects.visEditor.clickGo(); + }); + + it('should have a splitted table', async () => { + const data = await PageObjects.legacyDataTableVis.getTableVisContent(); + expect(data).to.be.eql([ + [ + ['CN', 'CN', '330'], + ['CN', 'IN', '274'], + ['CN', 'US', '140'], + ['IN', 'CN', '286'], + ['IN', 'IN', '281'], + ['IN', 'US', '133'], + ['US', 'CN', '135'], + ['US', 'IN', '134'], + ['US', 'US', '52'], + ], + [ + ['CN', 'CN', '90'], + ['CN', 'IN', '84'], + ['CN', 'US', '27'], + ['IN', 'CN', '69'], + ['IN', 'IN', '58'], + ['IN', 'US', '34'], + ['US', 'IN', '36'], + ['US', 'CN', '29'], + ['US', 'US', '13'], + ], + ]); + }); + + it('should show metrics for split bucket when using showMetricsAtAllLevels', async () => { + await PageObjects.visEditor.clickOptionsTab(); + await testSubjects.setCheckbox('showMetricsAtAllLevels', 'check'); + await PageObjects.visEditor.clickGo(); + const data = await PageObjects.legacyDataTableVis.getTableVisContent(); + expect(data).to.be.eql([ + [ + ['CN', '1,718', 'CN', '330'], + ['CN', '1,718', 'IN', '274'], + ['CN', '1,718', 'US', '140'], + ['IN', '1,511', 'CN', '286'], + ['IN', '1,511', 'IN', '281'], + ['IN', '1,511', 'US', '133'], + ['US', '770', 'CN', '135'], + ['US', '770', 'IN', '134'], + ['US', '770', 'US', '52'], + ], + [ + ['CN', '422', 'CN', '90'], + ['CN', '422', 'IN', '84'], + ['CN', '422', 'US', '27'], + ['IN', '346', 'CN', '69'], + ['IN', '346', 'IN', '58'], + ['IN', '346', 'US', '34'], + ['US', '189', 'IN', '36'], + ['US', '189', 'CN', '29'], + ['US', '189', 'US', '13'], + ], + ]); + }); + }); + }); +} diff --git a/test/functional/apps/visualize/legacy/index.ts b/test/functional/apps/visualize/legacy/index.ts new file mode 100644 index 0000000000000..a44da75182dc7 --- /dev/null +++ b/test/functional/apps/visualize/legacy/index.ts @@ -0,0 +1,48 @@ +/* + * 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 { FtrProviderContext } from '../../../ftr_provider_context.d'; +import { UI_SETTINGS } from '../../../../../src/plugins/data/common'; + +export default function ({ getService, loadTestFile }: FtrProviderContext) { + const browser = getService('browser'); + const log = getService('log'); + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + + describe('visualize with legacy visualizations', () => { + before(async () => { + log.debug('Starting visualize legacy before method'); + await browser.setWindowSize(1280, 800); + await esArchiver.loadIfNeeded('logstash_functional'); + await esArchiver.loadIfNeeded('long_window_logstash'); + await esArchiver.load('visualize'); + await kibanaServer.uiSettings.replace({ + defaultIndex: 'logstash-*', + [UI_SETTINGS.FORMAT_BYTES_DEFAULT_PATTERN]: '0,0.[000]b', + }); + }); + + describe('legacy data table visualization', function () { + this.tags('ciGroup9'); + + loadTestFile(require.resolve('./_data_table')); + }); + }); +} diff --git a/test/functional/config.legacy.ts b/test/functional/config.legacy.ts new file mode 100644 index 0000000000000..eae099c6809f2 --- /dev/null +++ b/test/functional/config.legacy.ts @@ -0,0 +1,39 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; + +// eslint-disable-next-line import/no-default-export +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const defaultConfig = await readConfigFile(require.resolve('./config')); + + return { + ...defaultConfig.getAll(), + + testFiles: [require.resolve('./apps/visualize/legacy')], + + kbnTestServer: { + ...defaultConfig.get('kbnTestServer'), + serverArgs: [ + ...defaultConfig.get('kbnTestServer.serverArgs'), + '--vis_type_table.legacyVisEnabled=true', + ], + }, + }; +} diff --git a/test/functional/page_objects/index.ts b/test/functional/page_objects/index.ts index d3a8fb73ac3e5..3c0d43100a9fd 100644 --- a/test/functional/page_objects/index.ts +++ b/test/functional/page_objects/index.ts @@ -39,6 +39,7 @@ import { TileMapPageProvider } from './tile_map_page'; import { TagCloudPageProvider } from './tag_cloud_page'; import { VegaChartPageProvider } from './vega_chart_page'; import { SavedObjectsPageProvider } from './management/saved_objects_page'; +import { LegacyDataTableVisProvider } from './legacy/data_table_vis'; export const pageObjects = { common: CommonPageProvider, @@ -52,6 +53,7 @@ export const pageObjects = { newsfeed: NewsfeedPageProvider, settings: SettingsPageProvider, share: SharePageProvider, + legacyDataTableVis: LegacyDataTableVisProvider, login: LoginPageProvider, timelion: TimelionPageProvider, timePicker: TimePickerProvider, diff --git a/test/functional/page_objects/legacy/data_table_vis.ts b/test/functional/page_objects/legacy/data_table_vis.ts new file mode 100644 index 0000000000000..6b437c7dd640d --- /dev/null +++ b/test/functional/page_objects/legacy/data_table_vis.ts @@ -0,0 +1,96 @@ +/* + * 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 { FtrProviderContext } from 'test/functional/ftr_provider_context'; +import { WebElementWrapper } from 'test/functional/services/lib/web_element_wrapper'; + +export function LegacyDataTableVisProvider({ getService, getPageObjects }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + + class LegacyDataTableVis { + /** + * Converts the table data into nested array + * [ [cell1_in_row1, cell2_in_row1], [cell1_in_row2, cell2_in_row2] ] + * @param element table + */ + private async getDataFromElement(element: WebElementWrapper): Promise { + const $ = await element.parseDomContent(); + return $('tr') + .toArray() + .map((row) => + $(row) + .find('td') + .toArray() + .map((cell) => + $(cell) + .text() + .replace(/ /g, '') + .trim() + ) + ); + } + + public async getTableVisContent({ stripEmptyRows = true } = {}) { + return await retry.try(async () => { + const container = await testSubjects.find('tableVis'); + const allTables = await testSubjects.findAllDescendant('paginated-table-body', container); + + if (allTables.length === 0) { + return []; + } + + const allData = await Promise.all( + allTables.map(async (t) => { + let data = await this.getDataFromElement(t); + if (stripEmptyRows) { + data = data.filter( + (row) => row.length > 0 && row.some((cell) => cell.trim().length > 0) + ); + } + return data; + }) + ); + + if (allTables.length === 1) { + // If there was only one table we return only the data for that table + // This prevents an unnecessary array around that single table, which + // is the case we have in most tests. + return allData[0]; + } + + return allData; + }); + } + + public async filterOnTableCell(column: number, row: number) { + await retry.try(async () => { + const tableVis = await testSubjects.find('tableVis'); + const cell = await tableVis.findByCssSelector( + `tbody tr:nth-child(${row}) td:nth-child(${column})` + ); + await cell.moveMouseTo(); + const filterBtn = await testSubjects.findDescendant('filterForCellValue', cell); + await filterBtn.click(); + }); + } + } + + return new LegacyDataTableVis(); +} diff --git a/test/functional/page_objects/visualize_chart_page.ts b/test/functional/page_objects/visualize_chart_page.ts index 3e3f60ca17131..553c8312a59af 100644 --- a/test/functional/page_objects/visualize_chart_page.ts +++ b/test/functional/page_objects/visualize_chart_page.ts @@ -25,7 +25,7 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr const find = getService('find'); const log = getService('log'); const retry = getService('retry'); - const table = getService('table'); + const dataGrid = getService('dataGrid'); const defaultFindTimeout = config.get('timeouts.find'); const { common } = getPageObjects(['common']); @@ -283,18 +283,6 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr }); } - public async filterOnTableCell(column: string, row: string) { - await retry.try(async () => { - const tableVis = await testSubjects.find('tableVis'); - const cell = await tableVis.findByCssSelector( - `tbody tr:nth-child(${row}) td:nth-child(${column})` - ); - await cell.moveMouseTo(); - const filterBtn = await testSubjects.findDescendant('filterForCellValue', cell); - await filterBtn.click(); - }); - } - public async getMarkdownText() { const markdownContainer = await testSubjects.find('markdownBody'); return markdownContainer.getVisibleText(); @@ -306,44 +294,33 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr return element.getVisibleText(); } - public async getFieldLinkInVisTable(fieldName: string, rowIndex: number = 1) { - const tableVis = await testSubjects.find('tableVis'); - const $ = await tableVis.parseDomContent(); - const headers = $('span[ng-bind="::col.title"]') - .toArray() - .map((header: any) => $(header).text()); - const fieldColumnIndex = headers.indexOf(fieldName); - return await find.byCssSelector( - `[data-test-subj="paginated-table-body"] tr:nth-of-type(${rowIndex}) td:nth-of-type(${ - fieldColumnIndex + 1 - }) a` - ); - } + // Table visualization - /** - * If you are writing new tests, you should rather look into getTableVisContent method instead. - * @deprecated Use getTableVisContent instead. - */ - public async getTableVisData() { - return await testSubjects.getVisibleText('paginated-table-body'); + public async getTableVisNoResult() { + return await testSubjects.find('tbvChartContainer>visNoResult'); } /** * This function returns the text displayed in the Table Vis header */ public async getTableVisHeader() { - return await testSubjects.getVisibleText('paginated-table-header'); + return await testSubjects.getVisibleText('dataGridHeader'); + } + + public async getFieldLinkInVisTable(fieldName: string, rowIndex: number = 1) { + const headers = await dataGrid.getHeaders(); + const fieldColumnIndex = headers.indexOf(fieldName); + const cell = await dataGrid.getCellElement(rowIndex, fieldColumnIndex + 1); + return await cell.findByTagName('a'); } /** - * This function is the newer function to retrieve data from within a table visualization. - * It uses a better return format, than the old getTableVisData, by properly splitting - * cell values into arrays. Please use this function for newer tests. + * Function to retrieve data from within a table visualization. */ public async getTableVisContent({ stripEmptyRows = true } = {}) { return await retry.try(async () => { - const container = await testSubjects.find('tableVis'); - const allTables = await testSubjects.findAllDescendant('paginated-table-body', container); + const container = await testSubjects.find('tbvChart'); + const allTables = await testSubjects.findAllDescendant('dataGridWrapper', container); if (allTables.length === 0) { return []; @@ -351,7 +328,7 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr const allData = await Promise.all( allTables.map(async (t) => { - let data = await table.getDataFromElement(t); + let data = await dataGrid.getDataFromElement(t, 'tbvChartCellContent'); if (stripEmptyRows) { data = data.filter( (row) => row.length > 0 && row.some((cell) => cell.trim().length > 0) @@ -372,6 +349,18 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr }); } + public async filterOnTableCell(column: number, row: number) { + await retry.try(async () => { + const cell = await dataGrid.getCellElement(row, column); + await cell.moveMouseTo(); + const filterBtn = await testSubjects.findDescendant( + 'tbvChartCell__filterForCellValue', + cell + ); + await filterBtn.click(); + }); + } + public async getMetric() { const elements = await find.allByCssSelector( '[data-test-subj="visualizationLoader"] .mtrVis__container' @@ -400,6 +389,17 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr return values.filter((item) => item.length > 0); } + public async clickOnGaugeByLabel(label: string) { + const gauge = await testSubjects.find(`visGauge__meter--${label}`); + const gaugeSize = await gauge.getSize(); + const gaugeHeight = gaugeSize.height; + // To click at Gauge arc instead of the center of SVG element + // the offset for a click is calculated as half arc height without 1 pixel + const yOffset = 1 - Math.floor(gaugeHeight / 2); + + await gauge.clickMouseButton({ xOffset: 0, yOffset }); + } + public async getRightValueAxes() { const axes = await find.allByCssSelector('.visAxis__column--right g.axis'); return axes.length; diff --git a/test/functional/page_objects/visualize_editor_page.ts b/test/functional/page_objects/visualize_editor_page.ts index cdbbeb9188732..f05cd35c5cb03 100644 --- a/test/functional/page_objects/visualize_editor_page.ts +++ b/test/functional/page_objects/visualize_editor_page.ts @@ -388,7 +388,7 @@ export function VisualizeEditorPageProvider({ getService, getPageObjects }: FtrP } } - public async setSize(newValue: string, aggId: string) { + public async setSize(newValue: number, aggId?: number) { const dataTestSubj = aggId ? `visEditorAggAccordion${aggId} > sizeParamEditor` : 'sizeParamEditor'; diff --git a/test/functional/services/combo_box.ts b/test/functional/services/combo_box.ts index 57e1857989950..3dad98c8bc877 100644 --- a/test/functional/services/combo_box.ts +++ b/test/functional/services/combo_box.ts @@ -275,8 +275,10 @@ export function ComboBoxProvider({ getService, getPageObjects }: FtrProviderCont public async openOptionsList(comboBoxElement: WebElementWrapper): Promise { const isOptionsListOpen = await testSubjects.exists('~comboBoxOptionsList'); if (!isOptionsListOpen) { - const toggleBtn = await comboBoxElement.findByTestSubject('comboBoxToggleListButton'); - await toggleBtn.click(); + await retry.try(async () => { + const toggleBtn = await comboBoxElement.findByTestSubject('comboBoxInput'); + await toggleBtn.click(); + }); } } diff --git a/test/functional/services/dashboard/expectations.ts b/test/functional/services/dashboard/expectations.ts index 77a441a772d84..62e541e3baa4c 100644 --- a/test/functional/services/dashboard/expectations.ts +++ b/test/functional/services/dashboard/expectations.ts @@ -27,7 +27,7 @@ export function DashboardExpectProvider({ getService, getPageObjects }: FtrProvi const testSubjects = getService('testSubjects'); const find = getService('find'); const filterBar = getService('filterBar'); - const PageObjects = getPageObjects(['dashboard', 'visualize']); + const PageObjects = getPageObjects(['dashboard', 'visualize', 'visChart']); const findTimeout = 2500; return new (class DashboardExpect { @@ -233,14 +233,18 @@ export function DashboardExpectProvider({ getService, getPageObjects }: FtrProvi async dataTableRowCount(expectedCount: number) { log.debug(`DashboardExpect.dataTableRowCount(${expectedCount})`); await retry.try(async () => { - const dataTableRows = await find.allByCssSelector( - '[data-test-subj="paginated-table-body"] [data-cell-content]', - findTimeout - ); + const dataTableRows = await PageObjects.visChart.getTableVisContent(); expect(dataTableRows.length).to.be(expectedCount); }); } + async dataTableNoResult(expectedCount: number) { + log.debug(`DashboardExpect.dataTableNoResult`); + await retry.try(async () => { + await PageObjects.visChart.getTableVisNoResult(); + }); + } + async seriesElementCount(expectedCount: number) { log.debug(`DashboardExpect.seriesElementCount(${expectedCount})`); await retry.try(async () => { diff --git a/test/functional/services/data_grid.ts b/test/functional/services/data_grid.ts index 40157caab5756..209e30d23ca3c 100644 --- a/test/functional/services/data_grid.ts +++ b/test/functional/services/data_grid.ts @@ -18,6 +18,7 @@ */ import { FtrProviderContext } from '../ftr_provider_context'; +import { WebElementWrapper } from './lib/web_element_wrapper'; interface TabbedGridData { columns: string[]; @@ -26,6 +27,7 @@ interface TabbedGridData { export function DataGridProvider({ getService }: FtrProviderContext) { const find = getService('find'); + const testSubjects = getService('testSubjects'); class DataGrid { async getDataGridTableData(): Promise { @@ -49,6 +51,58 @@ export function DataGridProvider({ getService }: FtrProviderContext) { rows, }; } + + /** + * Converts the data grid data into nested array + * [ [cell1_in_row1, cell2_in_row1], [cell1_in_row2, cell2_in_row2] ] + * @param element table + */ + public async getDataFromElement( + element: WebElementWrapper, + cellDataTestSubj: string + ): Promise { + const $ = await element.parseDomContent(); + return $('[data-test-subj="dataGridRow"]') + .toArray() + .map((row) => + $(row) + .findTestSubjects('dataGridRowCell') + .toArray() + .map((cell) => + $(cell) + .findTestSubject(cellDataTestSubj) + .text() + .replace(/ /g, '') + .trim() + ) + ); + } + + /** + * Returns an array of data grid headers names + */ + public async getHeaders() { + const header = await testSubjects.find('dataGridWrapper > dataGridHeader'); + const $ = await header.parseDomContent(); + return $('.euiDataGridHeaderCell__content') + .toArray() + .map((cell) => $(cell).text()); + } + + /** + * Returns a grid cell element by row & column indexes. + * The row offset equals 1 since the first row of data grid is the header row. + * @param rowIndex data row index starting from 1 (1 means 1st row) + * @param columnIndex column index starting from 1 (1 means 1st column) + */ + public async getCellElement(rowIndex: number, columnIndex: number) { + return await find.byCssSelector( + `[data-test-subj="dataGridWrapper"] [data-test-subj="dataGridRow"]:nth-of-type(${ + rowIndex + 1 + }) + [data-test-subj="dataGridRowCell"]:nth-of-type(${columnIndex})` + ); + } } return new DataGrid(); diff --git a/test/functional/services/index.ts b/test/functional/services/index.ts index 2c71fd8ef8f55..64a8ac751acd9 100644 --- a/test/functional/services/index.ts +++ b/test/functional/services/index.ts @@ -46,7 +46,6 @@ import { ManagementMenuProvider } from './management'; import { QueryBarProvider } from './query_bar'; import { RemoteProvider } from './remote'; import { RenderableProvider } from './renderable'; -import { TableProvider } from './table'; import { ToastsProvider } from './toasts'; import { DataGridProvider } from './data_grid'; import { @@ -82,7 +81,6 @@ export const services = { dataGrid: DataGridProvider, embedding: EmbeddingProvider, renderable: RenderableProvider, - table: TableProvider, browser: BrowserProvider, pieChart: PieChartProvider, inspector: InspectorProvider, diff --git a/test/functional/services/table.ts b/test/functional/services/table.ts deleted file mode 100644 index 8dbed2e5d250b..0000000000000 --- a/test/functional/services/table.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { FtrProviderContext } from '../ftr_provider_context'; -import { WebElementWrapper } from './lib/web_element_wrapper'; - -export function TableProvider({ getService }: FtrProviderContext) { - const testSubjects = getService('testSubjects'); - - class Table { - /** - * Finds table and returns data in the nested array format - * [ [cell1_in_row1, cell2_in_row1], [cell1_in_row2, cell2_in_row2] ] - * @param dataTestSubj data-test-subj selector - */ - - public async getDataFromTestSubj(dataTestSubj: string): Promise { - const table = await testSubjects.find(dataTestSubj); - return await this.getDataFromElement(table); - } - - /** - * Converts the table data into nested array - * [ [cell1_in_row1, cell2_in_row1], [cell1_in_row2, cell2_in_row2] ] - * @param element table - */ - public async getDataFromElement(element: WebElementWrapper): Promise { - const $ = await element.parseDomContent(); - return $('tr') - .toArray() - .map((row) => - $(row) - .find('td') - .toArray() - .map((cell) => - $(cell) - .text() - .replace(/ /g, '') - .trim() - ) - ); - } - } - - return new Table(); -} diff --git a/x-pack/examples/alerting_example/kibana.json b/x-pack/examples/alerting_example/kibana.json index 0b2c2bdb3f6a7..0ddb3d2412f6d 100644 --- a/x-pack/examples/alerting_example/kibana.json +++ b/x-pack/examples/alerting_example/kibana.json @@ -4,6 +4,7 @@ "kibanaVersion": "kibana", "server": true, "ui": true, - "requiredPlugins": ["triggersActionsUi", "charts", "data", "alerts", "actions", "features", "developerExamples"], - "optionalPlugins": [] + "requiredPlugins": ["triggersActionsUi", "charts", "data", "alerts", "actions", "kibanaReact", "features", "developerExamples"], + "optionalPlugins": [], + "requiredBundles": ["kibanaReact"] } diff --git a/x-pack/examples/alerting_example/public/application.tsx b/x-pack/examples/alerting_example/public/application.tsx index e229c1c1e6dad..4bc46081ec6a7 100644 --- a/x-pack/examples/alerting_example/public/application.tsx +++ b/x-pack/examples/alerting_example/public/application.tsx @@ -8,16 +8,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { BrowserRouter as Router, Route, RouteComponentProps } from 'react-router-dom'; import { EuiPage } from '@elastic/eui'; -import { - AppMountParameters, - CoreStart, - IUiSettingsClient, - DocLinksStart, - ToastsSetup, - ApplicationStart, -} from '../../../../src/core/public'; -import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; -import { ChartsPluginStart } from '../../../../src/plugins/charts/public'; +import { AppMountParameters, CoreStart } from '../../../../src/core/public'; import { Page } from './components/page'; import { DocumentationPage } from './components/documentation'; @@ -25,22 +16,19 @@ import { ViewAlertPage } from './components/view_alert'; import { TriggersAndActionsUIPublicPluginStart } from '../../../plugins/triggers_actions_ui/public'; import { AlertingExamplePublicStartDeps } from './plugin'; import { ViewPeopleInSpaceAlertPage } from './components/view_astros_alert'; +import { KibanaContextProvider } from '../../../../src/plugins/kibana_react/public'; export interface AlertingExampleComponentParams { - application: CoreStart['application']; http: CoreStart['http']; basename: string; triggersActionsUi: TriggersAndActionsUIPublicPluginStart; - data: DataPublicPluginStart; - charts: ChartsPluginStart; - uiSettings: IUiSettingsClient; - docLinks: DocLinksStart; - toastNotifications: ToastsSetup; - capabilities: ApplicationStart['capabilities']; } -const AlertingExampleApp = (deps: AlertingExampleComponentParams) => { - const { basename, http } = deps; +const AlertingExampleApp = ({ + basename, + http, + triggersActionsUi, +}: AlertingExampleComponentParams) => { return ( @@ -49,7 +37,7 @@ const AlertingExampleApp = (deps: AlertingExampleComponentParams) => { exact={true} render={() => ( - + )} /> @@ -79,21 +67,19 @@ const AlertingExampleApp = (deps: AlertingExampleComponentParams) => { }; export const renderApp = ( - { application, notifications, http, uiSettings, docLinks }: CoreStart, + core: CoreStart, deps: AlertingExamplePublicStartDeps, { appBasePath, element }: AppMountParameters ) => { + const { http } = core; ReactDOM.render( - , + + + , element ); diff --git a/x-pack/examples/alerting_example/public/components/create_alert.tsx b/x-pack/examples/alerting_example/public/components/create_alert.tsx index 8b62dfbb0997b..db7667411a27e 100644 --- a/x-pack/examples/alerting_example/public/components/create_alert.tsx +++ b/x-pack/examples/alerting_example/public/components/create_alert.tsx @@ -4,25 +4,30 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, useState } from 'react'; +import React, { useMemo, useState, useCallback } from 'react'; import { EuiIcon, EuiFlexItem, EuiCard, EuiFlexGroup } from '@elastic/eui'; import { AlertingExampleComponentParams } from '../application'; import { ALERTING_EXAMPLE_APP_ID } from '../../common/constants'; -export const CreateAlert = ({ triggersActionsUi }: AlertingExampleComponentParams) => { +export const CreateAlert = ({ + triggersActionsUi, +}: Pick) => { const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState(false); + const onCloseAlertFlyout = useCallback(() => setAlertFlyoutVisibility(false), [ + setAlertFlyoutVisibility, + ]); + const AddAlertFlyout = useMemo( () => triggersActionsUi.getAddAlertFlyout({ consumer: ALERTING_EXAMPLE_APP_ID, - addFlyoutVisible: alertFlyoutVisible, - setAddFlyoutVisibility: setAlertFlyoutVisibility, + onClose: onCloseAlertFlyout, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [alertFlyoutVisible] + [onCloseAlertFlyout] ); return ( @@ -35,7 +40,7 @@ export const CreateAlert = ({ triggersActionsUi }: AlertingExampleComponentParam onClick={() => setAlertFlyoutVisibility(true)} /> - {AddAlertFlyout} + {alertFlyoutVisible && AddAlertFlyout} ); }; diff --git a/x-pack/examples/alerting_example/public/components/documentation.tsx b/x-pack/examples/alerting_example/public/components/documentation.tsx index 73896fdb8fc92..ca6e28fbc5ad8 100644 --- a/x-pack/examples/alerting_example/public/components/documentation.tsx +++ b/x-pack/examples/alerting_example/public/components/documentation.tsx @@ -21,7 +21,9 @@ import { import { CreateAlert } from './create_alert'; import { AlertingExampleComponentParams } from '../application'; -export const DocumentationPage = (deps: AlertingExampleComponentParams) => ( +export const DocumentationPage = ( + deps: Pick +) => ( diff --git a/x-pack/examples/alerting_example/server/alert_types/always_firing.ts b/x-pack/examples/alerting_example/server/alert_types/always_firing.ts index fdaf8edab3a33..4fde4183b414e 100644 --- a/x-pack/examples/alerting_example/server/alert_types/always_firing.ts +++ b/x-pack/examples/alerting_example/server/alert_types/always_firing.ts @@ -49,6 +49,7 @@ export const alertType: AlertType< { id: 'large', name: 'Large t-shirt' }, ], defaultActionGroupId: DEFAULT_ACTION_GROUP, + minimumLicenseRequired: 'basic', async executor({ services, params: { instances = DEFAULT_INSTANCES_TO_GENERATE, thresholds }, diff --git a/x-pack/examples/alerting_example/server/alert_types/astros.ts b/x-pack/examples/alerting_example/server/alert_types/astros.ts index f0f47adffa109..27a8bfc7a53a3 100644 --- a/x-pack/examples/alerting_example/server/alert_types/astros.ts +++ b/x-pack/examples/alerting_example/server/alert_types/astros.ts @@ -42,6 +42,7 @@ export const alertType: AlertType = { id: 'example.people-in-space', name: 'People In Space Right Now', actionGroups: [{ id: 'default', name: 'default' }], + minimumLicenseRequired: 'basic', defaultActionGroupId: 'default', recoveryActionGroup: { id: 'hasLandedBackOnEarth', diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index fb0293dca5ff4..12c3ab12a6998 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -553,7 +553,6 @@ The ServiceNow action uses the [V2 Table API](https://developer.servicenow.com/a | Property | Description | Type | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------- | | apiUrl | ServiceNow instance URL. | string | -| incidentConfiguration | Optional property and specific to **Cases only**. If defined, the object should contain an attribute called `mapping`. A `mapping` is an array of objects. Each mapping object should be of the form `{ source: string, target: string, actionType: string }`. `source` is the Case field. `target` is the ServiceNow field where `source` will be mapped to. `actionType` can be one of `nothing`, `overwrite` or `append`. For example the `{ source: 'title', target: 'short_description', actionType: 'overwrite' }` record, inside mapping array, means that the title of a case will be mapped to the short description of an incident in ServiceNow and will be overwrite on each update. | object _(optional)_ | ### `secrets` @@ -600,7 +599,6 @@ The Jira action uses the [V2 API](https://developer.atlassian.com/cloud/jira/pla | Property | Description | Type | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | | apiUrl | Jira instance URL. | string | -| incidentConfiguration | Optional property and specific to **Cases only**. if defined, the object should contain an attribute called `mapping`. A `mapping` is an array of objects. Each mapping object should be of the form `{ source: string, target: string, actionType: string }`. `source` is the Case field. `target` is the Jira field where `source` will be mapped to. `actionType` can be one of `nothing`, `overwrite` or `append`. For example the `{ source: 'title', target: 'summary', actionType: 'overwrite' }` record, inside mapping array, means that the title of a case will be mapped to the short description of an incident in Jira and will be overwrite on each update. | object _(optional)_ | ### `secrets` @@ -653,7 +651,6 @@ ID: `.resilient` | Property | Description | Type | | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | | apiUrl | IBM Resilient instance URL. | string | -| incidentConfiguration | Optional property and specific to **Cases only**. If defined, the object should contain an attribute called `mapping`. A `mapping` is an array of objects. Each mapping object should be of the form `{ source: string, target: string, actionType: string }`. `source` is the Case field. `target` is the Jira field where `source` will be mapped to. `actionType` can be one of `nothing`, `overwrite` or `append`. For example the `{ source: 'title', target: 'summary', actionType: 'overwrite' }` record, inside mapping array, means that the title of a case will be mapped to the short description of an incident in IBM Resilient and will be overwrite on each update. | object | ### `secrets` diff --git a/x-pack/plugins/actions/server/action_type_registry.ts b/x-pack/plugins/actions/server/action_type_registry.ts index 15ff7e558025e..7176d3ad3a1a7 100644 --- a/x-pack/plugins/actions/server/action_type_registry.ts +++ b/x-pack/plugins/actions/server/action_type_registry.ts @@ -177,7 +177,7 @@ export class ActionTypeRegistry { minimumLicenseRequired: actionType.minimumLicenseRequired, enabled: this.isActionTypeEnabled(actionTypeId), enabledInConfig: this.actionsConfigUtils.isActionTypeEnabled(actionTypeId), - enabledInLicense: this.licenseState.isLicenseValidForActionType(actionType).isValid === true, + enabledInLicense: !!this.licenseState.isLicenseValidForActionType(actionType).isValid, })); } } diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/case/schema.ts deleted file mode 100644 index 5a23eb89339e6..0000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/case/schema.ts +++ /dev/null @@ -1,43 +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 { schema } from '@kbn/config-schema'; - -export const MappingActionType = schema.oneOf([ - schema.literal('nothing'), - schema.literal('overwrite'), - schema.literal('append'), -]); - -export const MapRecordSchema = schema.object({ - source: schema.string(), - target: schema.string(), - actionType: MappingActionType, -}); - -export const IncidentConfigurationSchema = schema.object({ - mapping: schema.arrayOf(MapRecordSchema), -}); - -export const UserSchema = schema.object({ - fullName: schema.nullable(schema.string()), - username: schema.nullable(schema.string()), -}); - -export const EntityInformation = { - createdAt: schema.nullable(schema.string()), - createdBy: schema.nullable(UserSchema), - updatedAt: schema.nullable(schema.string()), - updatedBy: schema.nullable(UserSchema), -}; - -export const EntityInformationSchema = schema.object(EntityInformation); - -export const CommentSchema = schema.object({ - commentId: schema.string(), - comment: schema.string(), - ...EntityInformation, -}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/transformers.test.ts b/x-pack/plugins/actions/server/builtin_action_types/case/transformers.test.ts deleted file mode 100644 index 75dcab65ee9f2..0000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/case/transformers.test.ts +++ /dev/null @@ -1,131 +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 { transformers } from './transformers'; - -const { informationCreated, informationUpdated, informationAdded, append } = transformers; - -describe('informationCreated', () => { - test('transforms correctly', () => { - const res = informationCreated({ - value: 'a value', - date: '2020-04-15T08:19:27.400Z', - user: 'elastic', - }); - expect(res).toEqual({ value: 'a value (created at 2020-04-15T08:19:27.400Z by elastic)' }); - }); - - test('transforms correctly without optional fields', () => { - const res = informationCreated({ - value: 'a value', - }); - expect(res).toEqual({ value: 'a value (created at by )' }); - }); - - test('returns correctly rest fields', () => { - const res = informationCreated({ - value: 'a value', - date: '2020-04-15T08:19:27.400Z', - user: 'elastic', - previousValue: 'previous value', - }); - expect(res).toEqual({ - value: 'a value (created at 2020-04-15T08:19:27.400Z by elastic)', - previousValue: 'previous value', - }); - }); -}); - -describe('informationUpdated', () => { - test('transforms correctly', () => { - const res = informationUpdated({ - value: 'a value', - date: '2020-04-15T08:19:27.400Z', - user: 'elastic', - }); - expect(res).toEqual({ value: 'a value (updated at 2020-04-15T08:19:27.400Z by elastic)' }); - }); - - test('transforms correctly without optional fields', () => { - const res = informationUpdated({ - value: 'a value', - }); - expect(res).toEqual({ value: 'a value (updated at by )' }); - }); - - test('returns correctly rest fields', () => { - const res = informationUpdated({ - value: 'a value', - date: '2020-04-15T08:19:27.400Z', - user: 'elastic', - previousValue: 'previous value', - }); - expect(res).toEqual({ - value: 'a value (updated at 2020-04-15T08:19:27.400Z by elastic)', - previousValue: 'previous value', - }); - }); -}); - -describe('informationAdded', () => { - test('transforms correctly', () => { - const res = informationAdded({ - value: 'a value', - date: '2020-04-15T08:19:27.400Z', - user: 'elastic', - }); - expect(res).toEqual({ value: 'a value (added at 2020-04-15T08:19:27.400Z by elastic)' }); - }); - - test('transforms correctly without optional fields', () => { - const res = informationAdded({ - value: 'a value', - }); - expect(res).toEqual({ value: 'a value (added at by )' }); - }); - - test('returns correctly rest fields', () => { - const res = informationAdded({ - value: 'a value', - date: '2020-04-15T08:19:27.400Z', - user: 'elastic', - previousValue: 'previous value', - }); - expect(res).toEqual({ - value: 'a value (added at 2020-04-15T08:19:27.400Z by elastic)', - previousValue: 'previous value', - }); - }); -}); - -describe('append', () => { - test('transforms correctly', () => { - const res = append({ - value: 'a value', - previousValue: 'previous value', - }); - expect(res).toEqual({ value: 'previous value \r\na value' }); - }); - - test('transforms correctly without optional fields', () => { - const res = append({ - value: 'a value', - }); - expect(res).toEqual({ value: 'a value' }); - }); - - test('returns correctly rest fields', () => { - const res = append({ - value: 'a value', - user: 'elastic', - previousValue: 'previous value', - }); - expect(res).toEqual({ - value: 'previous value \r\na value', - user: 'elastic', - }); - }); -}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/transformers.ts b/x-pack/plugins/actions/server/builtin_action_types/case/transformers.ts deleted file mode 100644 index 3dca1fd703430..0000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/case/transformers.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { TransformerArgs } from './types'; -import * as i18n from './translations'; - -export type Transformer = (args: TransformerArgs) => TransformerArgs; - -export const transformers: Record = { - informationCreated: ({ value, date, user, ...rest }: TransformerArgs): TransformerArgs => ({ - value: `${value} ${i18n.FIELD_INFORMATION('create', date, user)}`, - ...rest, - }), - informationUpdated: ({ value, date, user, ...rest }: TransformerArgs): TransformerArgs => ({ - value: `${value} ${i18n.FIELD_INFORMATION('update', date, user)}`, - ...rest, - }), - informationAdded: ({ value, date, user, ...rest }: TransformerArgs): TransformerArgs => ({ - value: `${value} ${i18n.FIELD_INFORMATION('add', date, user)}`, - ...rest, - }), - append: ({ value, previousValue, ...rest }: TransformerArgs): TransformerArgs => ({ - value: previousValue ? `${previousValue} \r\n${value}` : `${value}`, - ...rest, - }), -}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/case/translations.ts deleted file mode 100644 index 4842728b0e4e7..0000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/case/translations.ts +++ /dev/null @@ -1,55 +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 { i18n } from '@kbn/i18n'; - -export const API_URL_REQUIRED = i18n.translate('xpack.actions.builtin.case.connectorApiNullError', { - defaultMessage: 'connector [apiUrl] is required', -}); - -export const FIELD_INFORMATION = ( - mode: string, - date: string | undefined, - user: string | undefined -) => { - switch (mode) { - case 'create': - return i18n.translate('xpack.actions.builtin.case.common.externalIncidentCreated', { - values: { date, user }, - defaultMessage: '(created at {date} by {user})', - }); - case 'update': - return i18n.translate('xpack.actions.builtin.case.common.externalIncidentUpdated', { - values: { date, user }, - defaultMessage: '(updated at {date} by {user})', - }); - case 'add': - return i18n.translate('xpack.actions.builtin.case.common.externalIncidentAdded', { - values: { date, user }, - defaultMessage: '(added at {date} by {user})', - }); - default: - return i18n.translate('xpack.actions.builtin.case.common.externalIncidentDefault', { - values: { date, user }, - defaultMessage: '(created at {date} by {user})', - }); - } -}; - -export const MAPPING_EMPTY = i18n.translate( - 'xpack.actions.builtin.case.configuration.emptyMapping', - { - defaultMessage: '[casesConfiguration.mapping]: expected non-empty but got empty', - } -); - -export const WHITE_LISTED_ERROR = (message: string) => - i18n.translate('xpack.actions.builtin.case.configuration.apiWhitelistError', { - defaultMessage: 'error configuring connector action: {message}', - values: { - message, - }, - }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/types.ts b/x-pack/plugins/actions/server/builtin_action_types/case/types.ts deleted file mode 100644 index 73d8297c638df..0000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/case/types.ts +++ /dev/null @@ -1,54 +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 { TypeOf } from '@kbn/config-schema'; -import { - IncidentConfigurationSchema, - MapRecordSchema, - CommentSchema, - EntityInformationSchema, -} from './schema'; - -export type IncidentConfiguration = TypeOf; -export type MapRecord = TypeOf; -export type Comment = TypeOf; -export type EntityInformation = TypeOf; - -export interface ExternalServiceCommentResponse { - commentId: string; - pushedDate: string; - externalCommentId?: string; -} - -export interface PipedField { - key: string; - value: string; - actionType: string; - pipes: string[]; -} - -export interface TransformFieldsArgs { - params: P; - fields: PipedField[]; - currentIncident?: S; -} - -export interface TransformerArgs { - value: string; - date?: string; - user?: string; - previousValue?: string; -} - -export interface AnyParams { - [index: string]: string | number | object | undefined | null; -} - -export interface PrepareFieldsForTransformArgs { - externalCase: Record; - mapping: Map; - defaultPipes?: string[]; -} diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/case/utils.test.ts deleted file mode 100644 index 600e18eb5daff..0000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/case/utils.test.ts +++ /dev/null @@ -1,494 +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. - */ - -/* eslint-disable @typescript-eslint/no-explicit-any */ - -import { - normalizeMapping, - buildMap, - mapParams, - prepareFieldsForTransformation, - transformFields, - transformComments, -} from './utils'; - -import { SUPPORTED_SOURCE_FIELDS } from './constants'; -import { Comment, MapRecord } from './types'; - -interface Entity { - createdAt: string | null; - createdBy: { fullName: string; username: string } | null; - updatedAt: string | null; - updatedBy: { fullName: string; username: string } | null; -} - -interface PushToServiceApiParams extends Entity { - savedObjectId: string; - title: string; - description: string | null; - externalId: string | null; - externalObject: Record; - comments: Comment[]; -} - -const mapping: MapRecord[] = [ - { source: 'title', target: 'short_description', actionType: 'overwrite' }, - { source: 'description', target: 'description', actionType: 'append' }, - { source: 'comments', target: 'comments', actionType: 'append' }, -]; - -const finalMapping: Map = new Map(); - -finalMapping.set('title', { - target: 'short_description', - actionType: 'overwrite', -}); - -finalMapping.set('description', { - target: 'description', - actionType: 'append', -}); - -finalMapping.set('comments', { - target: 'comments', - actionType: 'append', -}); - -finalMapping.set('short_description', { - target: 'title', - actionType: 'overwrite', -}); - -const maliciousMapping: MapRecord[] = [ - { source: '__proto__', target: 'short_description', actionType: 'nothing' }, - { source: 'description', target: '__proto__', actionType: 'nothing' }, - { source: 'comments', target: 'comments', actionType: 'nothing' }, - { source: 'unsupportedSource', target: 'comments', actionType: 'nothing' }, -]; - -const fullParams: PushToServiceApiParams = { - savedObjectId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', - 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, - externalObject: { - short_description: 'a title', - description: 'a description', - }, - comments: [ - { - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', - comment: 'first comment', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: null, - updatedBy: null, - }, - { - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', - comment: 'second comment', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: null, - updatedBy: null, - }, - ], -}; - -describe('normalizeMapping', () => { - test('remove malicious fields', () => { - const sanitizedMapping = normalizeMapping(SUPPORTED_SOURCE_FIELDS, maliciousMapping); - expect( - sanitizedMapping.every((m) => m.source !== '__proto__' && m.target !== '__proto__') - ).toBe(true); - }); - - test('remove unsuppported source fields', () => { - const normalizedMapping = normalizeMapping(SUPPORTED_SOURCE_FIELDS, maliciousMapping); - expect(normalizedMapping).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - source: 'unsupportedSource', - target: 'comments', - actionType: 'nothing', - }), - ]) - ); - }); -}); - -describe('buildMap', () => { - test('builds sanitized Map', () => { - const finalMap = buildMap(maliciousMapping); - expect(finalMap.get('__proto__')).not.toBeDefined(); - }); - - test('builds Map correct', () => { - const final = buildMap(mapping); - expect(final).toEqual(finalMapping); - }); -}); - -describe('mapParams', () => { - test('maps params correctly', () => { - const params = { - savedObjectId: '123', - incidentId: '456', - title: 'Incident title', - description: 'Incident description', - }; - - const fields = mapParams(params, finalMapping); - - expect(fields).toEqual({ - short_description: 'Incident title', - description: 'Incident description', - }); - }); - - test('do not add fields not in mapping', () => { - const params = { - savedObjectId: '123', - incidentId: '456', - title: 'Incident title', - description: 'Incident description', - }; - const fields = mapParams(params, finalMapping); - - const { title, description, ...unexpectedFields } = params; - - expect(fields).not.toEqual(expect.objectContaining(unexpectedFields)); - }); -}); - -describe('prepareFieldsForTransformation', () => { - test('prepare fields with defaults', () => { - const res = prepareFieldsForTransformation({ - externalCase: fullParams.externalObject, - mapping: finalMapping, - }); - expect(res).toEqual([ - { - key: 'short_description', - value: 'a title', - actionType: 'overwrite', - pipes: ['informationCreated'], - }, - { - key: 'description', - value: 'a description', - actionType: 'append', - pipes: ['informationCreated', 'append'], - }, - ]); - }); - - test('prepare fields with default pipes', () => { - const res = prepareFieldsForTransformation({ - externalCase: fullParams.externalObject, - mapping: finalMapping, - defaultPipes: ['myTestPipe'], - }); - expect(res).toEqual([ - { - key: 'short_description', - value: 'a title', - actionType: 'overwrite', - pipes: ['myTestPipe'], - }, - { - key: 'description', - value: 'a description', - actionType: 'append', - pipes: ['myTestPipe', 'append'], - }, - ]); - }); -}); - -describe('transformFields', () => { - test('transform fields for creation correctly', () => { - const fields = prepareFieldsForTransformation({ - externalCase: fullParams.externalObject, - mapping: finalMapping, - }); - - const res = transformFields< - PushToServiceApiParams, - {}, - { short_description: string; description: string } - >({ - params: fullParams, - fields, - }); - - expect(res).toEqual({ - short_description: 'a title (created at 2020-03-13T08:34:53.450Z by Elastic User)', - description: 'a description (created at 2020-03-13T08:34:53.450Z by Elastic User)', - }); - }); - - test('transform fields for update correctly', () => { - const fields = prepareFieldsForTransformation({ - externalCase: fullParams.externalObject, - mapping: finalMapping, - defaultPipes: ['informationUpdated'], - }); - - const res = transformFields< - PushToServiceApiParams, - {}, - { short_description: string; description: string } - >({ - params: { - ...fullParams, - updatedAt: '2020-03-15T08:34:53.450Z', - updatedBy: { - username: 'anotherUser', - fullName: 'Another User', - }, - }, - fields, - currentIncident: { - short_description: 'first title (created at 2020-03-13T08:34:53.450Z by Elastic User)', - description: 'first description (created at 2020-03-13T08:34:53.450Z by Elastic User)', - }, - }); - expect(res).toEqual({ - short_description: 'a title (updated at 2020-03-15T08:34:53.450Z by Another User)', - description: - 'first description (created at 2020-03-13T08:34:53.450Z by Elastic User) \r\na description (updated at 2020-03-15T08:34:53.450Z by Another User)', - }); - }); - - test('add newline character to description', () => { - const fields = prepareFieldsForTransformation({ - externalCase: fullParams.externalObject, - mapping: finalMapping, - defaultPipes: ['informationUpdated'], - }); - - const res = transformFields< - PushToServiceApiParams, - {}, - { short_description: string; description: string } - >({ - params: fullParams, - fields, - currentIncident: { - short_description: 'first title', - description: 'first description', - }, - }); - expect(res.description?.includes('\r\n')).toBe(true); - }); - - test('append username if fullname is undefined when create', () => { - const fields = prepareFieldsForTransformation({ - externalCase: fullParams.externalObject, - mapping: finalMapping, - }); - - const res = transformFields< - PushToServiceApiParams, - {}, - { short_description: string; description: string } - >({ - params: { - ...fullParams, - createdBy: { fullName: '', username: 'elastic' }, - }, - fields, - }); - - expect(res).toEqual({ - short_description: 'a title (created at 2020-03-13T08:34:53.450Z by elastic)', - description: 'a description (created at 2020-03-13T08:34:53.450Z by elastic)', - }); - }); - - test('append username if fullname is undefined when update', () => { - const fields = prepareFieldsForTransformation({ - externalCase: fullParams.externalObject, - mapping: finalMapping, - defaultPipes: ['informationUpdated'], - }); - - const res = transformFields< - PushToServiceApiParams, - {}, - { short_description: string; description: string } - >({ - params: { - ...fullParams, - updatedAt: '2020-03-15T08:34:53.450Z', - updatedBy: { username: 'anotherUser', fullName: '' }, - }, - fields, - }); - - expect(res).toEqual({ - short_description: 'a title (updated at 2020-03-15T08:34:53.450Z by anotherUser)', - description: 'a description (updated at 2020-03-15T08:34:53.450Z by anotherUser)', - }); - }); -}); - -describe('transformComments', () => { - test('transform creation comments', () => { - const comments: Comment[] = [ - { - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', - comment: 'first comment', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: null, - updatedBy: null, - }, - ]; - const res = transformComments(comments, ['informationCreated']); - expect(res).toEqual([ - { - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', - comment: 'first comment (created at 2020-03-13T08:34:53.450Z by Elastic User)', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: null, - updatedBy: null, - }, - ]); - }); - - test('transform update comments', () => { - const comments: Comment[] = [ - { - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', - comment: 'first comment', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: '2020-03-15T08:34:53.450Z', - updatedBy: { - fullName: 'Another User', - username: 'anotherUser', - }, - }, - ]; - const res = transformComments(comments, ['informationUpdated']); - expect(res).toEqual([ - { - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', - comment: 'first comment (updated at 2020-03-15T08:34:53.450Z by Another User)', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: '2020-03-15T08:34:53.450Z', - updatedBy: { - fullName: 'Another User', - username: 'anotherUser', - }, - }, - ]); - }); - - test('transform added comments', () => { - const comments: Comment[] = [ - { - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', - comment: 'first comment', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: null, - updatedBy: null, - }, - ]; - const res = transformComments(comments, ['informationAdded']); - expect(res).toEqual([ - { - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', - comment: 'first comment (added at 2020-03-13T08:34:53.450Z by Elastic User)', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: null, - updatedBy: null, - }, - ]); - }); - - test('transform comments without fullname', () => { - const comments: Comment[] = [ - { - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', - comment: 'first comment', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: '', username: 'elastic' }, - updatedAt: null, - updatedBy: null, - }, - ]; - const res = transformComments(comments, ['informationAdded']); - expect(res).toEqual([ - { - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', - comment: 'first comment (added at 2020-03-13T08:34:53.450Z by elastic)', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: '', username: 'elastic' }, - updatedAt: null, - updatedBy: null, - }, - ]); - }); - - test('adds update user correctly', () => { - const comments: Comment[] = [ - { - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', - comment: 'first comment', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic', username: 'elastic' }, - updatedAt: '2020-04-13T08:34:53.450Z', - updatedBy: { fullName: 'Elastic2', username: 'elastic' }, - }, - ]; - const res = transformComments(comments, ['informationAdded']); - expect(res).toEqual([ - { - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', - comment: 'first comment (added at 2020-04-13T08:34:53.450Z by Elastic2)', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic', username: 'elastic' }, - updatedAt: '2020-04-13T08:34:53.450Z', - updatedBy: { fullName: 'Elastic2', username: 'elastic' }, - }, - ]); - }); - - test('adds update user with empty fullname correctly', () => { - const comments: Comment[] = [ - { - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', - comment: 'first comment', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic', username: 'elastic' }, - updatedAt: '2020-04-13T08:34:53.450Z', - updatedBy: { fullName: '', username: 'elastic2' }, - }, - ]; - const res = transformComments(comments, ['informationAdded']); - expect(res).toEqual([ - { - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', - comment: 'first comment (added at 2020-04-13T08:34:53.450Z by elastic2)', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic', username: 'elastic' }, - updatedAt: '2020-04-13T08:34:53.450Z', - updatedBy: { fullName: '', username: 'elastic2' }, - }, - ]); - }); -}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/utils.ts b/x-pack/plugins/actions/server/builtin_action_types/case/utils.ts deleted file mode 100644 index 3d51f5e826279..0000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/case/utils.ts +++ /dev/null @@ -1,111 +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 { flow, get } from 'lodash'; - -import { - MapRecord, - TransformFieldsArgs, - Comment, - EntityInformation, - PipedField, - AnyParams, - PrepareFieldsForTransformArgs, -} from './types'; - -import { transformers } from './transformers'; - -import { SUPPORTED_SOURCE_FIELDS } from './constants'; - -export const normalizeMapping = (supportedFields: string[], mapping: MapRecord[]): MapRecord[] => { - // Prevent prototype pollution and remove unsupported fields - return mapping.filter( - (m) => - m.source !== '__proto__' && m.target !== '__proto__' && supportedFields.includes(m.source) - ); -}; - -export const buildMap = (mapping: MapRecord[]): Map => { - return normalizeMapping(SUPPORTED_SOURCE_FIELDS, mapping).reduce((fieldsMap, field) => { - const { source, target, actionType } = field; - fieldsMap.set(source, { target, actionType }); - fieldsMap.set(target, { target: source, actionType }); - return fieldsMap; - }, new Map()); -}; - -export const mapParams = (params: T, mapping: Map): AnyParams => { - return Object.keys(params).reduce((prev: AnyParams, curr: string): AnyParams => { - const field = mapping.get(curr); - if (field) { - prev[field.target] = get(params, curr); - } - return prev; - }, {}); -}; - -export const prepareFieldsForTransformation = ({ - externalCase, - mapping, - defaultPipes = ['informationCreated'], -}: PrepareFieldsForTransformArgs): PipedField[] => { - return Object.keys(externalCase) - .filter((p) => mapping.get(p)?.actionType != null && mapping.get(p)?.actionType !== 'nothing') - .map((p) => { - const actionType = mapping.get(p)?.actionType ?? 'nothing'; - return { - key: p, - value: externalCase[p], - actionType, - pipes: actionType === 'append' ? [...defaultPipes, 'append'] : defaultPipes, - }; - }); -}; - -export const transformFields = < - P extends EntityInformation, - S extends Record, - R extends {} ->({ - params, - fields, - currentIncident, -}: TransformFieldsArgs): R => { - return fields.reduce((prev, cur) => { - const transform = flow(...cur.pipes.map((p) => transformers[p])); - return { - ...prev, - [cur.key]: transform({ - value: cur.value, - date: params.updatedAt ?? params.createdAt, - user: getEntity(params), - previousValue: currentIncident ? currentIncident[cur.key] : '', - }).value, - }; - }, {} as R); -}; - -export const transformComments = (comments: Comment[], pipes: string[]): Comment[] => { - return comments.map((c) => ({ - ...c, - comment: flow(...pipes.map((p) => transformers[p]))({ - value: c.comment, - date: c.updatedAt ?? c.createdAt, - user: getEntity(c), - }).value, - })); -}; - -export const getEntity = (entity: EntityInformation): string => - (entity.updatedBy != null - ? entity.updatedBy.fullName - ? entity.updatedBy.fullName - : entity.updatedBy.username - : entity.createdBy != null - ? entity.createdBy.fullName - ? entity.createdBy.fullName - : entity.createdBy.username - : '') ?? ''; diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts index 132510ea0ce84..a9c2430c4f395 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts @@ -396,4 +396,37 @@ describe('execute()', () => { } `); }); + + test('renders parameter templates as expected', async () => { + expect(actionType.renderParameterTemplates).toBeTruthy(); + const paramsWithTemplates = { + to: [], + cc: ['{{rogue}}'], + bcc: ['jim', '{{rogue}}', 'bob'], + subject: '{{rogue}}', + message: '{{rogue}}', + }; + const variables = { + rogue: '*bold*', + }; + const params = actionType.renderParameterTemplates!(paramsWithTemplates, variables); + // Yes, this is tested in the snapshot below, but it's double-escaped there, + // so easier to see here that the escaping is correct. + expect(params.message).toBe('\\*bold\\*'); + expect(params).toMatchInlineSnapshot(` + Object { + "bcc": Array [ + "jim", + "*bold*", + "bob", + ], + "cc": Array [ + "*bold*", + ], + "message": "\\\\*bold\\\\*", + "subject": "*bold*", + "to": Array [], + } + `); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.ts b/x-pack/plugins/actions/server/builtin_action_types/email.ts index be2664887d943..06f18916d7ee5 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.ts @@ -14,6 +14,7 @@ import { portSchema } from './lib/schemas'; import { Logger } from '../../../../../src/core/server'; import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types'; import { ActionsConfigurationUtilities } from '../actions_config'; +import { renderMustacheString, renderMustacheObject } from '../lib/mustache_renderer'; export type EmailActionType = ActionType< ActionTypeConfigType, @@ -140,10 +141,23 @@ export function getActionType(params: GetActionTypeParams): EmailActionType { secrets: SecretsSchema, params: ParamsSchema, }, + renderParameterTemplates, executor: curry(executor)({ logger }), }; } +function renderParameterTemplates( + params: ActionParamsType, + variables: Record +): ActionParamsType { + return { + // most of the params need no escaping + ...renderMustacheObject(params, variables), + // message however, needs to escaped as markdown + message: renderMustacheString(params.message, variables, 'markdown'), + }; +} + // action executor async function executor( diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/api.test.ts index 5a7617ada1bf0..99021c1fc552b 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/api.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/api.test.ts @@ -5,7 +5,7 @@ */ import { Logger } from '../../../../../../src/core/server'; -import { externalServiceMock, mapping, apiParams } from './mocks'; +import { externalServiceMock, apiParams } from './mocks'; import { ExternalService } from './types'; import { api } from './api'; let mockedLogger: jest.Mocked; @@ -22,7 +22,6 @@ describe('api', () => { const params = { ...apiParams, externalId: null }; const res = await api.pushToService({ externalService, - mapping, params, logger: mockedLogger, }); @@ -49,7 +48,6 @@ describe('api', () => { const params = { ...apiParams, externalId: null, comments: [] }; const res = await api.pushToService({ externalService, - mapping, params, logger: mockedLogger, }); @@ -63,8 +61,8 @@ describe('api', () => { }); test('it calls createIncident correctly', async () => { - const params = { ...apiParams, externalId: null }; - await api.pushToService({ externalService, mapping, params, logger: mockedLogger }); + const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } }; + await api.pushToService({ externalService, params, logger: mockedLogger }); expect(externalService.createIncident).toHaveBeenCalledWith({ incident: { @@ -72,16 +70,16 @@ describe('api', () => { priority: 'High', issueType: '10006', parent: null, - description: 'Incident description (created at 2020-04-27T10:59:46.202Z by Elastic User)', - summary: 'Incident title (created at 2020-04-27T10:59:46.202Z by Elastic User)', + description: 'Incident description', + summary: 'Incident title', }, }); expect(externalService.updateIncident).not.toHaveBeenCalled(); }); test('it calls createIncident correctly without mapping', async () => { - const params = { ...apiParams, externalId: null }; - await api.pushToService({ externalService, mapping: null, params, logger: mockedLogger }); + const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } }; + await api.pushToService({ externalService, params, logger: mockedLogger }); expect(externalService.createIncident).toHaveBeenCalledWith({ incident: { @@ -97,24 +95,14 @@ describe('api', () => { }); test('it calls createComment correctly', async () => { - const params = { ...apiParams, externalId: null }; - await api.pushToService({ externalService, mapping, params, logger: mockedLogger }); + const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } }; + await api.pushToService({ externalService, params, logger: mockedLogger }); expect(externalService.createComment).toHaveBeenCalledTimes(2); expect(externalService.createComment).toHaveBeenNthCalledWith(1, { incidentId: 'incident-1', comment: { commentId: 'case-comment-1', - comment: 'A comment (added at 2020-04-27T10:59:46.202Z by Elastic User)', - createdAt: '2020-04-27T10:59:46.202Z', - createdBy: { - fullName: 'Elastic User', - username: 'elastic', - }, - updatedAt: '2020-04-27T10:59:46.202Z', - updatedBy: { - fullName: 'Elastic User', - username: 'elastic', - }, + comment: 'A comment', }, }); @@ -122,40 +110,20 @@ describe('api', () => { incidentId: 'incident-1', comment: { commentId: 'case-comment-2', - comment: 'Another comment (added at 2020-04-27T10:59:46.202Z by Elastic User)', - createdAt: '2020-04-27T10:59:46.202Z', - createdBy: { - fullName: 'Elastic User', - username: 'elastic', - }, - updatedAt: '2020-04-27T10:59:46.202Z', - updatedBy: { - fullName: 'Elastic User', - username: 'elastic', - }, + comment: 'Another comment', }, }); }); test('it calls createComment correctly without mapping', async () => { - const params = { ...apiParams, externalId: null }; - await api.pushToService({ externalService, mapping: null, params, logger: mockedLogger }); + const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } }; + await api.pushToService({ externalService, params, logger: mockedLogger }); expect(externalService.createComment).toHaveBeenCalledTimes(2); expect(externalService.createComment).toHaveBeenNthCalledWith(1, { incidentId: 'incident-1', comment: { commentId: 'case-comment-1', comment: 'A comment', - createdAt: '2020-04-27T10:59:46.202Z', - createdBy: { - fullName: 'Elastic User', - username: 'elastic', - }, - updatedAt: '2020-04-27T10:59:46.202Z', - updatedBy: { - fullName: 'Elastic User', - username: 'elastic', - }, }, }); @@ -164,16 +132,6 @@ describe('api', () => { comment: { commentId: 'case-comment-2', comment: 'Another comment', - createdAt: '2020-04-27T10:59:46.202Z', - createdBy: { - fullName: 'Elastic User', - username: 'elastic', - }, - updatedAt: '2020-04-27T10:59:46.202Z', - updatedBy: { - fullName: 'Elastic User', - username: 'elastic', - }, }, }); }); @@ -183,7 +141,6 @@ describe('api', () => { test('it updates an incident', async () => { const res = await api.pushToService({ externalService, - mapping, params: apiParams, logger: mockedLogger, }); @@ -210,7 +167,6 @@ describe('api', () => { const params = { ...apiParams, comments: [] }; const res = await api.pushToService({ externalService, - mapping, params, logger: mockedLogger, }); @@ -225,7 +181,7 @@ describe('api', () => { test('it calls updateIncident correctly', async () => { const params = { ...apiParams }; - await api.pushToService({ externalService, mapping, params, logger: mockedLogger }); + await api.pushToService({ externalService, params, logger: mockedLogger }); expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', @@ -234,8 +190,8 @@ describe('api', () => { priority: 'High', issueType: '10006', parent: null, - description: 'Incident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', - summary: 'Incident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + description: 'Incident description', + summary: 'Incident title', }, }); expect(externalService.createIncident).not.toHaveBeenCalled(); @@ -243,7 +199,7 @@ describe('api', () => { test('it calls updateIncident correctly without mapping', async () => { const params = { ...apiParams }; - await api.pushToService({ externalService, mapping: null, params, logger: mockedLogger }); + await api.pushToService({ externalService, params, logger: mockedLogger }); expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', @@ -261,23 +217,13 @@ describe('api', () => { test('it calls createComment correctly', async () => { const params = { ...apiParams }; - await api.pushToService({ externalService, mapping, params, logger: mockedLogger }); + await api.pushToService({ externalService, params, logger: mockedLogger }); expect(externalService.createComment).toHaveBeenCalledTimes(2); expect(externalService.createComment).toHaveBeenNthCalledWith(1, { incidentId: 'incident-1', comment: { commentId: 'case-comment-1', - comment: 'A comment (added at 2020-04-27T10:59:46.202Z by Elastic User)', - createdAt: '2020-04-27T10:59:46.202Z', - createdBy: { - fullName: 'Elastic User', - username: 'elastic', - }, - updatedAt: '2020-04-27T10:59:46.202Z', - updatedBy: { - fullName: 'Elastic User', - username: 'elastic', - }, + comment: 'A comment', }, }); @@ -285,40 +231,20 @@ describe('api', () => { incidentId: 'incident-1', comment: { commentId: 'case-comment-2', - comment: 'Another comment (added at 2020-04-27T10:59:46.202Z by Elastic User)', - createdAt: '2020-04-27T10:59:46.202Z', - createdBy: { - fullName: 'Elastic User', - username: 'elastic', - }, - updatedAt: '2020-04-27T10:59:46.202Z', - updatedBy: { - fullName: 'Elastic User', - username: 'elastic', - }, + comment: 'Another comment', }, }); }); test('it calls createComment correctly without mapping', async () => { const params = { ...apiParams }; - await api.pushToService({ externalService, mapping: null, params, logger: mockedLogger }); + await api.pushToService({ externalService, params, logger: mockedLogger }); expect(externalService.createComment).toHaveBeenCalledTimes(2); expect(externalService.createComment).toHaveBeenNthCalledWith(1, { incidentId: 'incident-1', comment: { commentId: 'case-comment-1', comment: 'A comment', - createdAt: '2020-04-27T10:59:46.202Z', - createdBy: { - fullName: 'Elastic User', - username: 'elastic', - }, - updatedAt: '2020-04-27T10:59:46.202Z', - updatedBy: { - fullName: 'Elastic User', - username: 'elastic', - }, }, }); @@ -327,16 +253,6 @@ describe('api', () => { comment: { commentId: 'case-comment-2', comment: 'Another comment', - createdAt: '2020-04-27T10:59:46.202Z', - createdBy: { - fullName: 'Elastic User', - username: 'elastic', - }, - updatedAt: '2020-04-27T10:59:46.202Z', - updatedBy: { - fullName: 'Elastic User', - username: 'elastic', - }, }, }); }); @@ -411,396 +327,4 @@ describe('api', () => { }); }); }); - - describe('mapping variations', () => { - test('overwrite & append', async () => { - mapping.set('title', { - target: 'summary', - actionType: 'overwrite', - }); - - mapping.set('description', { - target: 'description', - actionType: 'append', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - mapping.set('summary', { - target: 'title', - actionType: 'overwrite', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - logger: mockedLogger, - }); - expect(externalService.updateIncident).toHaveBeenCalledWith({ - incidentId: 'incident-3', - incident: { - labels: ['kibana', 'elastic'], - priority: 'High', - issueType: '10006', - parent: null, - summary: 'Incident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', - description: - 'description from jira \r\nIncident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', - }, - }); - }); - - test('nothing & append', async () => { - mapping.set('title', { - target: 'summary', - actionType: 'nothing', - }); - - mapping.set('description', { - target: 'description', - actionType: 'append', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - mapping.set('summary', { - target: 'title', - actionType: 'nothing', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - logger: mockedLogger, - }); - expect(externalService.updateIncident).toHaveBeenCalledWith({ - incidentId: 'incident-3', - incident: { - labels: ['kibana', 'elastic'], - priority: 'High', - issueType: '10006', - parent: null, - description: - 'description from jira \r\nIncident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', - }, - }); - }); - - test('append & append', async () => { - mapping.set('title', { - target: 'summary', - actionType: 'append', - }); - - mapping.set('description', { - target: 'description', - actionType: 'append', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - mapping.set('summary', { - target: 'title', - actionType: 'append', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - logger: mockedLogger, - }); - expect(externalService.updateIncident).toHaveBeenCalledWith({ - incidentId: 'incident-3', - incident: { - labels: ['kibana', 'elastic'], - priority: 'High', - issueType: '10006', - parent: null, - summary: - 'title from jira \r\nIncident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', - description: - 'description from jira \r\nIncident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', - }, - }); - }); - - test('nothing & nothing', async () => { - mapping.set('title', { - target: 'summary', - actionType: 'nothing', - }); - - mapping.set('description', { - target: 'description', - actionType: 'nothing', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - mapping.set('summary', { - target: 'title', - actionType: 'nothing', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - logger: mockedLogger, - }); - expect(externalService.updateIncident).toHaveBeenCalledWith({ - incidentId: 'incident-3', - incident: { - labels: ['kibana', 'elastic'], - priority: 'High', - issueType: '10006', - parent: null, - }, - }); - }); - - test('overwrite & nothing', async () => { - mapping.set('title', { - target: 'summary', - actionType: 'overwrite', - }); - - mapping.set('description', { - target: 'description', - actionType: 'nothing', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - mapping.set('summary', { - target: 'title', - actionType: 'overwrite', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - logger: mockedLogger, - }); - expect(externalService.updateIncident).toHaveBeenCalledWith({ - incidentId: 'incident-3', - incident: { - labels: ['kibana', 'elastic'], - priority: 'High', - issueType: '10006', - parent: null, - summary: 'Incident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', - }, - }); - }); - - test('overwrite & overwrite', async () => { - mapping.set('title', { - target: 'summary', - actionType: 'overwrite', - }); - - mapping.set('description', { - target: 'description', - actionType: 'overwrite', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - mapping.set('summary', { - target: 'title', - actionType: 'overwrite', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - logger: mockedLogger, - }); - expect(externalService.updateIncident).toHaveBeenCalledWith({ - incidentId: 'incident-3', - incident: { - labels: ['kibana', 'elastic'], - priority: 'High', - issueType: '10006', - parent: null, - summary: 'Incident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', - description: 'Incident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', - }, - }); - }); - - test('nothing & overwrite', async () => { - mapping.set('title', { - target: 'summary', - actionType: 'nothing', - }); - - mapping.set('description', { - target: 'description', - actionType: 'overwrite', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - mapping.set('summary', { - target: 'title', - actionType: 'nothing', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - logger: mockedLogger, - }); - expect(externalService.updateIncident).toHaveBeenCalledWith({ - incidentId: 'incident-3', - incident: { - labels: ['kibana', 'elastic'], - priority: 'High', - issueType: '10006', - parent: null, - description: 'Incident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', - }, - }); - }); - - test('append & overwrite', async () => { - mapping.set('title', { - target: 'summary', - actionType: 'append', - }); - - mapping.set('description', { - target: 'description', - actionType: 'overwrite', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - mapping.set('summary', { - target: 'title', - actionType: 'append', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - logger: mockedLogger, - }); - expect(externalService.updateIncident).toHaveBeenCalledWith({ - incidentId: 'incident-3', - incident: { - labels: ['kibana', 'elastic'], - priority: 'High', - issueType: '10006', - parent: null, - summary: - 'title from jira \r\nIncident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', - description: 'Incident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', - }, - }); - }); - - test('append & nothing', async () => { - mapping.set('title', { - target: 'summary', - actionType: 'append', - }); - - mapping.set('description', { - target: 'description', - actionType: 'nothing', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - mapping.set('summary', { - target: 'title', - actionType: 'append', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - logger: mockedLogger, - }); - expect(externalService.updateIncident).toHaveBeenCalledWith({ - incidentId: 'incident-3', - incident: { - labels: ['kibana', 'elastic'], - priority: 'High', - issueType: '10006', - parent: null, - summary: - 'title from jira \r\nIncident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', - }, - }); - }); - - test('comment nothing', async () => { - mapping.set('title', { - target: 'summary', - actionType: 'overwrite', - }); - - mapping.set('description', { - target: 'description', - actionType: 'nothing', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'nothing', - }); - - mapping.set('summary', { - target: 'title', - actionType: 'overwrite', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - logger: mockedLogger, - }); - expect(externalService.createComment).not.toHaveBeenCalled(); - }); - }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/api.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/api.ts index feeb69b1d1a0e..cd0d410bd8dfa 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/api.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/api.ts @@ -5,7 +5,6 @@ */ import { - ExternalServiceParams, PushToServiceApiHandlerArgs, HandshakeApiHandlerArgs, GetIncidentApiHandlerArgs, @@ -14,26 +13,17 @@ import { GetFieldsByIssueTypeHandlerArgs, GetIssueTypesHandlerArgs, GetIssuesHandlerArgs, - PushToServiceApiParams, PushToServiceResponse, GetIssueHandlerArgs, GetCommonFieldsHandlerArgs, } from './types'; -// TODO: to remove, need to support Case -import { prepareFieldsForTransformation, transformFields, transformComments } from '../case/utils'; +const handshakeHandler = async ({ externalService, params }: HandshakeApiHandlerArgs) => {}; -const handshakeHandler = async ({ - externalService, - mapping, - params, -}: HandshakeApiHandlerArgs) => {}; - -const getIncidentHandler = async ({ - externalService, - mapping, - params, -}: GetIncidentApiHandlerArgs) => {}; +const getIncidentHandler = async ({ externalService, params }: GetIncidentApiHandlerArgs) => { + const res = await externalService.getIncident(params.externalId); + return res; +}; const getIssueTypesHandler = async ({ externalService }: GetIssueTypesHandlerArgs) => { const res = await externalService.getIssueTypes(); @@ -68,58 +58,12 @@ const getIssueHandler = async ({ externalService, params }: GetIssueHandlerArgs) const pushToServiceHandler = async ({ externalService, - mapping, params, - logger, }: PushToServiceApiHandlerArgs): Promise => { - const { externalId, comments } = params; - const updateIncident = externalId ? true : false; - const defaultPipes = updateIncident ? ['informationUpdated'] : ['informationCreated']; - let currentIncident: ExternalServiceParams | undefined; + const { comments } = params; let res: PushToServiceResponse; - - if (externalId) { - try { - currentIncident = await externalService.getIncident(externalId); - } catch (ex) { - logger.debug( - `Retrieving Incident by id ${externalId} from Jira failed with exception: ${ex}` - ); - } - } - - let incident: Incident; - // TODO: should be removed later but currently keep it for the Case implementation support - if (mapping) { - const fields = prepareFieldsForTransformation({ - externalCase: params.externalObject, - mapping, - defaultPipes, - }); - - const transformedFields = transformFields< - PushToServiceApiParams, - ExternalServiceParams, - Incident - >({ - params, - fields, - currentIncident, - }); - - const { priority, labels, issueType, parent } = params; - incident = { - summary: transformedFields.summary, - description: transformedFields.description, - priority, - labels, - issueType, - parent, - }; - } else { - const { title, description, priority, labels, issueType, parent } = params; - incident = { summary: title, description, priority, labels, issueType, parent }; - } + const { externalId, ...rest } = params.incident; + const incident: Incident = rest; if (externalId != null) { res = await externalService.updateIncident({ @@ -128,23 +72,13 @@ const pushToServiceHandler = async ({ }); } else { res = await externalService.createIncident({ - incident: { - ...incident, - }, + incident, }); } if (comments && Array.isArray(comments) && comments.length > 0) { - if (mapping && mapping.get('comments')?.actionType === 'nothing') { - return res; - } - - const commentsTransformed = mapping - ? transformComments(comments, ['informationAdded']) - : comments; - res.comments = []; - for (const currentComment of commentsTransformed) { + for (const currentComment of comments) { const comment = await externalService.createComment({ incidentId: res.id, comment: currentComment, diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts index c70c0810926f4..4518fa0f119d5 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts @@ -27,13 +27,11 @@ import { ExecutorSubActionGetIssueTypesParams, ExecutorSubActionGetIssuesParams, ExecutorSubActionGetIssueParams, + ExecutorSubActionGetIncidentParams, } from './types'; import * as i18n from './translations'; import { Logger } from '../../../../../../src/core/server'; -// TODO: to remove, need to support Case -import { buildMap, mapParams } from '../case/utils'; - interface GetActionTypeParams { logger: Logger; configurationUtilities: ActionsConfigurationUtilities; @@ -41,6 +39,7 @@ interface GetActionTypeParams { const supportedSubActions: string[] = [ 'getFields', + 'getIncident', 'pushToService', 'issueTypes', 'fieldsByIssueType', @@ -109,21 +108,22 @@ async function executor( throw new Error(errorMessage); } + if (subAction === 'getIncident') { + const getIncidentParams = subActionParams as ExecutorSubActionGetIncidentParams; + const res = await api.getIncident({ + externalService, + params: getIncidentParams, + }); + if (res != null) { + data = res; + } + } if (subAction === 'pushToService') { const pushToServiceParams = subActionParams as ExecutorSubActionPushParams; - const { comments, externalId, ...restParams } = pushToServiceParams; - const incidentConfiguration = config.incidentConfiguration; - const mapping = incidentConfiguration ? buildMap(incidentConfiguration.mapping) : null; - const externalObject = - config.incidentConfiguration && mapping - ? mapParams(restParams as ExecutorSubActionPushParams, mapping) - : {}; - data = await api.pushToService({ externalService, - mapping, - params: { ...pushToServiceParams, externalObject }, + params: pushToServiceParams, logger, }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/mocks.ts index 87a0f156a0c2a..cc37dd475f42c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/mocks.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/mocks.ts @@ -6,8 +6,6 @@ import { ExternalService, PushToServiceApiParams, ExecutorSubActionPushParams } from './types'; -import { MapRecord } from '../case/types'; - const createMock = (): jest.Mocked => { const service = { getIncident: jest.fn().mockImplementation(() => @@ -111,64 +109,31 @@ const createMock = (): jest.Mocked => { const externalServiceMock = { create: createMock, }; -const mapping: Map> = new Map(); - -mapping.set('title', { - target: 'summary', - actionType: 'overwrite', -}); - -mapping.set('description', { - target: 'description', - actionType: 'overwrite', -}); - -mapping.set('comments', { - target: 'comments', - actionType: 'append', -}); - -mapping.set('summary', { - target: 'title', - actionType: 'overwrite', -}); const executorParams: ExecutorSubActionPushParams = { - savedObjectId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', - externalId: 'incident-3', - createdAt: '2020-04-27T10:59:46.202Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: '2020-04-27T10:59:46.202Z', - updatedBy: { fullName: 'Elastic User', username: 'elastic' }, - title: 'Incident title', - description: 'Incident description', - labels: ['kibana', 'elastic'], - priority: 'High', - issueType: '10006', - parent: null, + incident: { + externalId: 'incident-3', + summary: 'Incident title', + description: 'Incident description', + labels: ['kibana', 'elastic'], + priority: 'High', + issueType: '10006', + parent: null, + }, comments: [ { commentId: 'case-comment-1', comment: 'A comment', - createdAt: '2020-04-27T10:59:46.202Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: '2020-04-27T10:59:46.202Z', - updatedBy: { fullName: 'Elastic User', username: 'elastic' }, }, { commentId: 'case-comment-2', comment: 'Another comment', - createdAt: '2020-04-27T10:59:46.202Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: '2020-04-27T10:59:46.202Z', - updatedBy: { fullName: 'Elastic User', username: 'elastic' }, }, ], }; const apiParams: PushToServiceApiParams = { ...executorParams, - externalObject: { summary: 'Incident title', description: 'Incident description' }, }; -export { externalServiceMock, mapping, executorParams, apiParams }; +export { externalServiceMock, executorParams, apiParams }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts index 70b60ada9c386..1885e64bbe329 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts @@ -5,14 +5,10 @@ */ import { schema } from '@kbn/config-schema'; -import { CommentSchema, EntityInformation, IncidentConfigurationSchema } from '../case/schema'; export const ExternalIncidentServiceConfiguration = { apiUrl: schema.string(), projectKey: schema.string(), - // TODO: to remove - set it optional for the current stage to support Case Jira implementation - incidentConfiguration: schema.nullable(IncidentConfigurationSchema), - isCaseOwned: schema.nullable(schema.boolean()), }; export const ExternalIncidentServiceConfigurationSchema = schema.object( @@ -37,17 +33,23 @@ export const ExecutorSubActionSchema = schema.oneOf([ ]); export const ExecutorSubActionPushParamsSchema = schema.object({ - savedObjectId: schema.nullable(schema.string()), - title: schema.string(), - description: schema.nullable(schema.string()), - externalId: schema.nullable(schema.string()), - issueType: schema.nullable(schema.string()), - priority: schema.nullable(schema.string()), - labels: schema.nullable(schema.arrayOf(schema.string())), - parent: schema.nullable(schema.string()), - // TODO: modify later to string[] - need for support Case schema - comments: schema.nullable(schema.arrayOf(CommentSchema)), - ...EntityInformation, + incident: schema.object({ + summary: schema.string(), + description: schema.nullable(schema.string()), + externalId: schema.nullable(schema.string()), + issueType: schema.nullable(schema.string()), + priority: schema.nullable(schema.string()), + labels: schema.nullable(schema.arrayOf(schema.string())), + parent: schema.nullable(schema.string()), + }), + comments: schema.nullable( + schema.arrayOf( + schema.object({ + comment: schema.string(), + commentId: schema.string(), + }) + ) + ), }); export const ExecutorSubActionGetIncidentParamsSchema = schema.object({ diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts index c7d0153daec24..30144416491dd 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts @@ -479,10 +479,6 @@ describe('Jira service', () => { comment: { comment: 'comment', commentId: 'comment-1', - createdBy: null, - createdAt: null, - updatedAt: null, - updatedBy: null, }, }); @@ -507,10 +503,6 @@ describe('Jira service', () => { comment: { comment: 'comment', commentId: 'comment-1', - createdBy: null, - createdAt: null, - updatedAt: null, - updatedBy: null, }, }); @@ -536,10 +528,6 @@ describe('Jira service', () => { comment: { comment: 'comment', commentId: 'comment-1', - createdBy: null, - createdAt: null, - updatedAt: null, - updatedBy: null, }, }) ).rejects.toThrow( diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts index 742e68eccbb23..f507893365c8a 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts @@ -133,21 +133,24 @@ export const createExternalService = ( [key: string]: { allowedValues?: Array<{}>; defaultValue?: {}; + name: string; required: boolean; schema: FieldSchema; }; }) => - Object.keys(fields ?? {}).reduce((fieldsAcc, fieldKey) => { - return { + Object.keys(fields ?? {}).reduce( + (fieldsAcc, fieldKey) => ({ ...fieldsAcc, [fieldKey]: { required: fields[fieldKey]?.required, allowedValues: fields[fieldKey]?.allowedValues ?? [], defaultValue: fields[fieldKey]?.defaultValue ?? {}, schema: fields[fieldKey]?.schema, + name: fields[fieldKey]?.name, }, - }; - }, {}); + }), + {} + ); const normalizeSearchResults = ( issues: Array<{ id: string; key: string; fields: { summary: string } }> @@ -386,7 +389,6 @@ export const createExternalService = ( }); const fields = res.data.projects[0]?.issuetypes[0]?.fields || {}; - return normalizeFields(fields); } else { const res = await request({ diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/translations.ts index 0e71de813eb5d..196799df7599a 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/translations.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/translations.ts @@ -17,11 +17,3 @@ export const ALLOWED_HOSTS_ERROR = (message: string) => message, }, }); - -// TODO: remove when Case mappings will be removed -export const MAPPING_EMPTY = i18n.translate( - 'xpack.actions.builtin.jira.configuration.emptyMapping', - { - defaultMessage: '[incidentConfiguration.mapping]: expected non-empty but got empty', - } -); diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts index e142637010a98..6c72ce3cde499 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts @@ -21,8 +21,6 @@ import { ExecutorSubActionGetIssueParamsSchema, } from './schema'; import { ActionsConfigurationUtilities } from '../../actions_config'; -import { IncidentConfigurationSchema } from '../case/schema'; -import { Comment } from '../case/types'; import { Logger } from '../../../../../../src/core/server'; export type JiraPublicConfigurationType = TypeOf; @@ -33,8 +31,6 @@ export type JiraSecretConfigurationType = TypeOf< export type ExecutorParams = TypeOf; export type ExecutorSubActionPushParams = TypeOf; -export type IncidentConfiguration = TypeOf; - export interface ExternalServiceCredentials { config: Record; secrets: Record; @@ -52,18 +48,9 @@ export interface ExternalServiceIncidentResponse { pushedDate: string; } -export interface ExternalServiceCommentResponse { - commentId: string; - pushedDate: string; - externalCommentId?: string; -} - export type ExternalServiceParams = Record; -export type Incident = Pick< - ExecutorSubActionPushParams, - 'description' | 'priority' | 'labels' | 'issueType' | 'parent' -> & { summary: string }; +export type Incident = Omit; export interface CreateIncidentParams { incident: Incident; @@ -76,7 +63,7 @@ export interface UpdateIncidentParams { export interface CreateCommentParams { incidentId: string; - comment: Comment; + comment: SimpleComment; } export interface FieldsSchema { @@ -84,18 +71,6 @@ export interface FieldsSchema { [key: string]: string; } -export interface ExternalServiceFields { - clauseNames: string[]; - custom: boolean; - id: string; - key: string; - name: string; - navigatable: boolean; - orderable: boolean; - schema: FieldsSchema; - searchable: boolean; -} - export type GetIssueTypesResponse = Array<{ id: string; name: string }>; export interface FieldSchema { @@ -104,7 +79,13 @@ export interface FieldSchema { } export type GetFieldsByIssueTypeResponse = Record< string, - { allowedValues: Array<{}>; defaultValue: {}; required: boolean; schema: FieldSchema } + { + allowedValues: Array<{}>; + defaultValue: {}; + required: boolean; + schema: FieldSchema; + name: string; + } >; export type GetCommonFieldsResponse = GetFieldsByIssueTypeResponse; @@ -128,9 +109,7 @@ export interface ExternalService { updateIncident: (params: UpdateIncidentParams) => Promise; } -export interface PushToServiceApiParams extends ExecutorSubActionPushParams { - externalObject: Record; -} +export type PushToServiceApiParams = ExecutorSubActionPushParams; export type ExecutorSubActionGetIncidentParams = TypeOf< typeof ExecutorSubActionGetIncidentParamsSchema @@ -160,7 +139,6 @@ export type ExecutorSubActionGetIssueParams = TypeOf | null; } export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs { @@ -207,7 +185,7 @@ export interface GetIssueHandlerArgs { export interface ExternalServiceApi { getFields: (args: GetCommonFieldsHandlerArgs) => Promise; - getIncident: (args: GetIncidentApiHandlerArgs) => Promise; + getIncident: (args: GetIncidentApiHandlerArgs) => Promise; handshake: (args: HandshakeApiHandlerArgs) => Promise; issueTypes: (args: GetIssueTypesHandlerArgs) => Promise; pushToService: (args: PushToServiceApiHandlerArgs) => Promise; @@ -223,7 +201,8 @@ export type JiraExecutorResultData = | GetIssueTypesResponse | GetFieldsByIssueTypeResponse | GetIssuesResponse - | GetIssueResponse; + | GetIssueResponse + | ExternalServiceParams; export interface Fields { [key: string]: string | string[] | { name: string } | { key: string } | { id: string }; @@ -232,3 +211,12 @@ export interface ResponseError { errorMessages: string[] | null | undefined; errors: { [k: string]: string } | null | undefined; } +export interface SimpleComment { + comment: string; + commentId: string; +} +export interface ExternalServiceCommentResponse { + commentId: string; + pushedDate: string; + externalCommentId?: string; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/validators.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/validators.ts index 58a3e27247fae..2e4d3e56c4102 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/validators.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/validators.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isEmpty } from 'lodash'; import { ActionsConfigurationUtilities } from '../../actions_config'; import { JiraPublicConfigurationType, @@ -18,13 +17,6 @@ export const validateCommonConfig = ( configurationUtilities: ActionsConfigurationUtilities, configObject: JiraPublicConfigurationType ) => { - if ( - configObject.incidentConfiguration !== null && - isEmpty(configObject.incidentConfiguration.mapping) - ) { - return i18n.MAPPING_EMPTY; - } - try { configurationUtilities.ensureUriAllowed(configObject.apiUrl); } catch (allowedListError) { diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/api.test.ts index 0892a2789bbc0..5c018fe748c6c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/api.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/api.test.ts @@ -6,7 +6,7 @@ import { Logger } from '../../../../../../src/core/server'; import { api } from './api'; -import { externalServiceMock, mapping, apiParams } from './mocks'; +import { externalServiceMock, apiParams } from './mocks'; import { ExternalService } from './types'; let mockedLogger: jest.Mocked; @@ -28,7 +28,6 @@ describe('api', () => { const params = { ...apiParams, externalId: null }; const res = await api.pushToService({ externalService, - mapping, params, logger: mockedLogger, }); @@ -55,7 +54,6 @@ describe('api', () => { const params = { ...apiParams, externalId: null, comments: [] }; const res = await api.pushToService({ externalService, - mapping, params, logger: mockedLogger, }); @@ -69,16 +67,15 @@ describe('api', () => { }); test('it calls createIncident correctly', async () => { - const params = { ...apiParams, externalId: null }; - await api.pushToService({ externalService, mapping, params, logger: mockedLogger }); + const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } }; + await api.pushToService({ externalService, params, logger: mockedLogger }); expect(externalService.createIncident).toHaveBeenCalledWith({ incident: { incidentTypes: [1001], severityCode: 6, - description: - 'Incident description (created at 2020-06-03T15:09:13.606Z by Elastic User)', - name: 'Incident title (created at 2020-06-03T15:09:13.606Z by Elastic User)', + description: 'Incident description', + name: 'Incident title', }, }); expect(externalService.updateIncident).not.toHaveBeenCalled(); @@ -86,23 +83,13 @@ describe('api', () => { test('it calls createComment correctly', async () => { const params = { ...apiParams, externalId: null }; - await api.pushToService({ externalService, mapping, params, logger: mockedLogger }); + await api.pushToService({ externalService, params, logger: mockedLogger }); expect(externalService.createComment).toHaveBeenCalledTimes(2); expect(externalService.createComment).toHaveBeenNthCalledWith(1, { incidentId: '1', comment: { commentId: 'case-comment-1', - comment: 'A comment (added at 2020-06-03T15:09:13.606Z by Elastic User)', - createdAt: '2020-06-03T15:09:13.606Z', - createdBy: { - fullName: 'Elastic User', - username: 'elastic', - }, - updatedAt: '2020-06-03T15:09:13.606Z', - updatedBy: { - fullName: 'Elastic User', - username: 'elastic', - }, + comment: 'A comment', }, }); @@ -110,17 +97,7 @@ describe('api', () => { incidentId: '1', comment: { commentId: 'case-comment-2', - comment: 'Another comment (added at 2020-06-03T15:09:13.606Z by Elastic User)', - createdAt: '2020-06-03T15:09:13.606Z', - createdBy: { - fullName: 'Elastic User', - username: 'elastic', - }, - updatedAt: '2020-06-03T15:09:13.606Z', - updatedBy: { - fullName: 'Elastic User', - username: 'elastic', - }, + comment: 'Another comment', }, }); }); @@ -130,7 +107,6 @@ describe('api', () => { test('it updates an incident', async () => { const res = await api.pushToService({ externalService, - mapping, params: apiParams, logger: mockedLogger, }); @@ -157,7 +133,6 @@ describe('api', () => { const params = { ...apiParams, comments: [] }; const res = await api.pushToService({ externalService, - mapping, params, logger: mockedLogger, }); @@ -172,16 +147,15 @@ describe('api', () => { test('it calls updateIncident correctly', async () => { const params = { ...apiParams }; - await api.pushToService({ externalService, mapping, params, logger: mockedLogger }); + await api.pushToService({ externalService, params, logger: mockedLogger }); expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { incidentTypes: [1001], severityCode: 6, - description: - 'Incident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', - name: 'Incident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + description: 'Incident description', + name: 'Incident title', }, }); expect(externalService.createIncident).not.toHaveBeenCalled(); @@ -189,23 +163,13 @@ describe('api', () => { test('it calls createComment correctly', async () => { const params = { ...apiParams }; - await api.pushToService({ externalService, mapping, params, logger: mockedLogger }); + await api.pushToService({ externalService, params, logger: mockedLogger }); expect(externalService.createComment).toHaveBeenCalledTimes(2); expect(externalService.createComment).toHaveBeenNthCalledWith(1, { incidentId: '1', comment: { commentId: 'case-comment-1', - comment: 'A comment (added at 2020-06-03T15:09:13.606Z by Elastic User)', - createdAt: '2020-06-03T15:09:13.606Z', - createdBy: { - fullName: 'Elastic User', - username: 'elastic', - }, - updatedAt: '2020-06-03T15:09:13.606Z', - updatedBy: { - fullName: 'Elastic User', - username: 'elastic', - }, + comment: 'A comment', }, }); @@ -213,17 +177,7 @@ describe('api', () => { incidentId: '1', comment: { commentId: 'case-comment-2', - comment: 'Another comment (added at 2020-06-03T15:09:13.606Z by Elastic User)', - createdAt: '2020-06-03T15:09:13.606Z', - createdBy: { - fullName: 'Elastic User', - username: 'elastic', - }, - updatedAt: '2020-06-03T15:09:13.606Z', - updatedBy: { - fullName: 'Elastic User', - username: 'elastic', - }, + comment: 'Another comment', }, }); }); @@ -236,14 +190,8 @@ describe('api', () => { params: {}, }); expect(res).toEqual([ - { - id: 17, - name: 'Communication error (fax; email)', - }, - { - id: 1001, - name: 'Custom type', - }, + { id: 17, name: 'Communication error (fax; email)' }, + { id: 1001, name: 'Custom type' }, ]); }); }); @@ -255,397 +203,11 @@ describe('api', () => { params: { id: '10006' }, }); expect(res).toEqual([ - { - id: 4, - name: 'Low', - }, - { - id: 5, - name: 'Medium', - }, - { - id: 6, - name: 'High', - }, + { id: 4, name: 'Low' }, + { id: 5, name: 'Medium' }, + { id: 6, name: 'High' }, ]); }); }); - - describe('mapping variations', () => { - test('overwrite & append', async () => { - mapping.set('title', { - target: 'name', - actionType: 'overwrite', - }); - - mapping.set('description', { - target: 'description', - actionType: 'append', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - mapping.set('name', { - target: 'title', - actionType: 'overwrite', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - logger: mockedLogger, - }); - expect(externalService.updateIncident).toHaveBeenCalledWith({ - incidentId: 'incident-3', - incident: { - incidentTypes: [1001], - severityCode: 6, - name: 'Incident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', - description: - 'description from ibm resilient \r\nIncident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', - }, - }); - }); - - test('nothing & append', async () => { - mapping.set('title', { - target: 'name', - actionType: 'nothing', - }); - - mapping.set('description', { - target: 'description', - actionType: 'append', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - mapping.set('name', { - target: 'title', - actionType: 'nothing', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - logger: mockedLogger, - }); - expect(externalService.updateIncident).toHaveBeenCalledWith({ - incidentId: 'incident-3', - incident: { - incidentTypes: [1001], - severityCode: 6, - description: - 'description from ibm resilient \r\nIncident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', - }, - }); - }); - - test('append & append', async () => { - mapping.set('title', { - target: 'name', - actionType: 'append', - }); - - mapping.set('description', { - target: 'description', - actionType: 'append', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - mapping.set('name', { - target: 'title', - actionType: 'append', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - logger: mockedLogger, - }); - expect(externalService.updateIncident).toHaveBeenCalledWith({ - incidentId: 'incident-3', - incident: { - incidentTypes: [1001], - severityCode: 6, - name: - 'title from ibm resilient \r\nIncident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', - description: - 'description from ibm resilient \r\nIncident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', - }, - }); - }); - - test('nothing & nothing', async () => { - mapping.set('title', { - target: 'name', - actionType: 'nothing', - }); - - mapping.set('description', { - target: 'description', - actionType: 'nothing', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - mapping.set('name', { - target: 'title', - actionType: 'nothing', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - logger: mockedLogger, - }); - expect(externalService.updateIncident).toHaveBeenCalledWith({ - incidentId: 'incident-3', - incident: { - incidentTypes: [1001], - severityCode: 6, - }, - }); - }); - - test('overwrite & nothing', async () => { - mapping.set('title', { - target: 'name', - actionType: 'overwrite', - }); - - mapping.set('description', { - target: 'description', - actionType: 'nothing', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - mapping.set('name', { - target: 'title', - actionType: 'overwrite', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - logger: mockedLogger, - }); - expect(externalService.updateIncident).toHaveBeenCalledWith({ - incidentId: 'incident-3', - incident: { - incidentTypes: [1001], - severityCode: 6, - name: 'Incident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', - }, - }); - }); - - test('overwrite & overwrite', async () => { - mapping.set('title', { - target: 'name', - actionType: 'overwrite', - }); - - mapping.set('description', { - target: 'description', - actionType: 'overwrite', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - mapping.set('name', { - target: 'title', - actionType: 'overwrite', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - logger: mockedLogger, - }); - expect(externalService.updateIncident).toHaveBeenCalledWith({ - incidentId: 'incident-3', - incident: { - incidentTypes: [1001], - severityCode: 6, - name: 'Incident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', - description: - 'Incident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', - }, - }); - }); - - test('nothing & overwrite', async () => { - mapping.set('title', { - target: 'name', - actionType: 'nothing', - }); - - mapping.set('description', { - target: 'description', - actionType: 'overwrite', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - mapping.set('name', { - target: 'title', - actionType: 'nothing', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - logger: mockedLogger, - }); - expect(externalService.updateIncident).toHaveBeenCalledWith({ - incidentId: 'incident-3', - incident: { - incidentTypes: [1001], - severityCode: 6, - description: - 'Incident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', - }, - }); - }); - - test('append & overwrite', async () => { - mapping.set('title', { - target: 'name', - actionType: 'append', - }); - - mapping.set('description', { - target: 'description', - actionType: 'overwrite', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - mapping.set('name', { - target: 'title', - actionType: 'append', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - logger: mockedLogger, - }); - expect(externalService.updateIncident).toHaveBeenCalledWith({ - incidentId: 'incident-3', - incident: { - incidentTypes: [1001], - severityCode: 6, - name: - 'title from ibm resilient \r\nIncident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', - description: - 'Incident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', - }, - }); - }); - - test('append & nothing', async () => { - mapping.set('title', { - target: 'name', - actionType: 'append', - }); - - mapping.set('description', { - target: 'description', - actionType: 'nothing', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - mapping.set('name', { - target: 'title', - actionType: 'append', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - logger: mockedLogger, - }); - expect(externalService.updateIncident).toHaveBeenCalledWith({ - incidentId: 'incident-3', - incident: { - incidentTypes: [1001], - severityCode: 6, - name: - 'title from ibm resilient \r\nIncident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', - }, - }); - }); - - test('comment nothing', async () => { - mapping.set('title', { - target: 'name', - actionType: 'overwrite', - }); - - mapping.set('description', { - target: 'description', - actionType: 'nothing', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'nothing', - }); - - mapping.set('name', { - target: 'title', - actionType: 'overwrite', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - logger: mockedLogger, - }); - expect(externalService.createComment).not.toHaveBeenCalled(); - }); - }); }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/api.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/api.ts index 29f2594d2b6f8..b308df1444c93 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/api.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/api.ts @@ -5,7 +5,6 @@ */ import { - ExternalServiceParams, PushToServiceApiHandlerArgs, HandshakeApiHandlerArgs, GetIncidentApiHandlerArgs, @@ -13,25 +12,13 @@ import { Incident, GetIncidentTypesHandlerArgs, GetSeverityHandlerArgs, - PushToServiceApiParams, PushToServiceResponse, GetCommonFieldsHandlerArgs, } from './types'; -// TODO: to remove, need to support Case -import { transformFields, prepareFieldsForTransformation, transformComments } from '../case/utils'; +const handshakeHandler = async ({ externalService, params }: HandshakeApiHandlerArgs) => {}; -const handshakeHandler = async ({ - externalService, - mapping, - params, -}: HandshakeApiHandlerArgs) => {}; - -const getIncidentHandler = async ({ - externalService, - mapping, - params, -}: GetIncidentApiHandlerArgs) => {}; +const getIncidentHandler = async ({ externalService, params }: GetIncidentApiHandlerArgs) => {}; const getFieldsHandler = async ({ externalService }: GetCommonFieldsHandlerArgs) => { const res = await externalService.getFields(); @@ -49,56 +36,12 @@ const getSeverityHandler = async ({ externalService }: GetSeverityHandlerArgs) = const pushToServiceHandler = async ({ externalService, - mapping, params, - logger, }: PushToServiceApiHandlerArgs): Promise => { - const { externalId, comments } = params; - const updateIncident = externalId ? true : false; - const defaultPipes = updateIncident ? ['informationUpdated'] : ['informationCreated']; - let currentIncident: ExternalServiceParams | undefined; + const { comments } = params; let res: PushToServiceResponse; - - if (externalId) { - try { - currentIncident = await externalService.getIncident(externalId); - } catch (ex) { - logger.debug( - `Retrieving Incident by id ${externalId} from IBM Resilient was failed with exception: ${ex}` - ); - } - } - - let incident: Incident; - // TODO: should be removed later but currently keep it for the Case implementation support - if (mapping) { - const fields = prepareFieldsForTransformation({ - externalCase: params.externalObject, - mapping, - defaultPipes, - }); - - const transformedFields = transformFields< - PushToServiceApiParams, - ExternalServiceParams, - Incident - >({ - params, - fields, - currentIncident, - }); - - const { incidentTypes, severityCode } = params; - incident = { - name: transformedFields.name, - description: transformedFields.description, - incidentTypes, - severityCode, - }; - } else { - const { title, description, incidentTypes, severityCode } = params; - incident = { name: title, description, incidentTypes, severityCode }; - } + const { externalId, ...rest } = params.incident; + const incident: Incident = rest; if (externalId != null) { res = await externalService.updateIncident({ @@ -107,22 +50,13 @@ const pushToServiceHandler = async ({ }); } else { res = await externalService.createIncident({ - incident: { - ...incident, - }, + incident, }); } if (comments && Array.isArray(comments) && comments.length > 0) { - if (mapping && mapping.get('comments')?.actionType === 'nothing') { - return res; - } - const commentsTransformed = mapping - ? transformComments(comments, ['informationAdded']) - : comments; - res.comments = []; - for (const currentComment of commentsTransformed) { + for (const currentComment of comments) { const comment = await externalService.createComment({ incidentId: res.id, comment: currentComment, diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts index 6203dda4120f5..7ce9369289554 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts @@ -30,9 +30,6 @@ import { import * as i18n from './translations'; import { Logger } from '../../../../../../src/core/server'; -// TODO: to remove, need to support Case -import { buildMap, mapParams } from '../case/utils'; - interface GetActionTypeParams { logger: Logger; configurationUtilities: ActionsConfigurationUtilities; @@ -104,19 +101,9 @@ async function executor( if (subAction === 'pushToService') { const pushToServiceParams = subActionParams as ExecutorSubActionPushParams; - const { comments, externalId, ...restParams } = pushToServiceParams; - const mapping = config.incidentConfiguration - ? buildMap(config.incidentConfiguration.mapping) - : null; - const externalObject = - config.incidentConfiguration && mapping - ? mapParams(restParams as ExecutorSubActionPushParams, mapping) - : {}; - data = await api.pushToService({ externalService, - mapping, - params: { ...pushToServiceParams, externalObject }, + params: pushToServiceParams, logger, }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/mocks.ts index 2b2a22a66b709..e1447e7718fb7 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/mocks.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/mocks.ts @@ -6,8 +6,6 @@ import { ExternalService, PushToServiceApiParams, ExecutorSubActionPushParams } from './types'; -import { MapRecord } from '../case/types'; - export const resilientFields = [ { id: 17, @@ -348,62 +346,28 @@ const externalServiceMock = { create: createMock, }; -const mapping: Map> = new Map(); - -mapping.set('title', { - target: 'name', - actionType: 'overwrite', -}); - -mapping.set('description', { - target: 'description', - actionType: 'overwrite', -}); - -mapping.set('comments', { - target: 'comments', - actionType: 'append', -}); - -mapping.set('name', { - target: 'title', - actionType: 'overwrite', -}); - const executorParams: ExecutorSubActionPushParams = { - savedObjectId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', - externalId: 'incident-3', - createdAt: '2020-06-03T15:09:13.606Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: '2020-06-03T15:09:13.606Z', - updatedBy: { fullName: 'Elastic User', username: 'elastic' }, - title: 'Incident title', - description: 'Incident description', - incidentTypes: [1001], - severityCode: 6, + incident: { + externalId: 'incident-3', + name: 'Incident title', + description: 'Incident description', + incidentTypes: [1001], + severityCode: 6, + }, comments: [ { commentId: 'case-comment-1', comment: 'A comment', - createdAt: '2020-06-03T15:09:13.606Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: '2020-06-03T15:09:13.606Z', - updatedBy: { fullName: 'Elastic User', username: 'elastic' }, }, { commentId: 'case-comment-2', comment: 'Another comment', - createdAt: '2020-06-03T15:09:13.606Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: '2020-06-03T15:09:13.606Z', - updatedBy: { fullName: 'Elastic User', username: 'elastic' }, }, ], }; const apiParams: PushToServiceApiParams = { ...executorParams, - externalObject: { name: 'Incident title', description: 'Incident description' }, }; const incidentTypes = [ @@ -457,4 +421,4 @@ const severity = [ }, ]; -export { externalServiceMock, mapping, executorParams, apiParams, incidentTypes, severity }; +export { externalServiceMock, executorParams, apiParams, incidentTypes, severity }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts index c7ceba94140fb..06bfeade2c7d2 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts @@ -5,14 +5,10 @@ */ import { schema } from '@kbn/config-schema'; -import { CommentSchema, EntityInformation, IncidentConfigurationSchema } from '../case/schema'; export const ExternalIncidentServiceConfiguration = { apiUrl: schema.string(), orgId: schema.string(), - // TODO: to remove - set it optional for the current stage to support Case implementation - incidentConfiguration: schema.nullable(IncidentConfigurationSchema), - isCaseOwned: schema.nullable(schema.boolean()), }; export const ExternalIncidentServiceConfigurationSchema = schema.object( @@ -37,15 +33,21 @@ export const ExecutorSubActionSchema = schema.oneOf([ ]); export const ExecutorSubActionPushParamsSchema = schema.object({ - savedObjectId: schema.nullable(schema.string()), - title: schema.string(), - description: schema.nullable(schema.string()), - externalId: schema.nullable(schema.string()), - incidentTypes: schema.nullable(schema.arrayOf(schema.number())), - severityCode: schema.nullable(schema.number()), - // TODO: remove later - need for support Case push multiple comments - comments: schema.nullable(schema.arrayOf(CommentSchema)), - ...EntityInformation, + incident: schema.object({ + name: schema.string(), + description: schema.nullable(schema.string()), + externalId: schema.nullable(schema.string()), + incidentTypes: schema.nullable(schema.arrayOf(schema.number())), + severityCode: schema.nullable(schema.number()), + }), + comments: schema.nullable( + schema.arrayOf( + schema.object({ + comment: schema.string(), + commentId: schema.string(), + }) + ) + ), }); export const ExecutorSubActionGetIncidentParamsSchema = schema.object({ diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts index 9362b0d4d2bad..97d8b64fb6535 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts @@ -450,10 +450,6 @@ describe('IBM Resilient service', () => { comment: { comment: 'comment', commentId: 'comment-1', - createdBy: null, - createdAt: null, - updatedAt: null, - updatedBy: null, }, }); @@ -477,10 +473,6 @@ describe('IBM Resilient service', () => { comment: { comment: 'comment', commentId: 'comment-1', - createdBy: null, - createdAt: null, - updatedAt: null, - updatedBy: null, }, }); @@ -510,10 +502,6 @@ describe('IBM Resilient service', () => { comment: { comment: 'comment', commentId: 'comment-1', - createdBy: null, - createdAt: null, - updatedAt: null, - updatedBy: null, }, }) ).rejects.toThrow( diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/translations.ts index 8c6ce9902da81..72bfc8001532e 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/translations.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/translations.ts @@ -17,11 +17,3 @@ export const ALLOWED_HOSTS_ERROR = (message: string) => message, }, }); - -// TODO: remove when Case mappings will be removed -export const MAPPING_EMPTY = i18n.translate( - 'xpack.actions.builtin.servicenow.configuration.emptyMapping', - { - defaultMessage: '[incidentConfiguration.mapping]: expected non-empty but got empty', - } -); diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/types.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/types.ts index a70420b30a092..ad6ae4b1d8386 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/types.ts @@ -22,9 +22,6 @@ import { import { ActionsConfigurationUtilities } from '../../actions_config'; import { Logger } from '../../../../../../src/core/server'; -import { IncidentConfigurationSchema } from '../case/schema'; -import { Comment } from '../case/types'; - export type ResilientPublicConfigurationType = TypeOf< typeof ExternalIncidentServiceConfigurationSchema >; @@ -39,8 +36,6 @@ export type ExecutorSubActionCommonFieldsParams = TypeOf< export type ExecutorParams = TypeOf; export type ExecutorSubActionPushParams = TypeOf; -export type IncidentConfiguration = TypeOf; - export interface ExternalServiceCredentials { config: Record; secrets: Record; @@ -58,28 +53,17 @@ export interface ExternalServiceIncidentResponse { pushedDate: string; } -export interface ExternalServiceCommentResponse { - commentId: string; - pushedDate: string; - externalCommentId?: string; -} - export type ExternalServiceParams = Record; export interface ExternalServiceFields { - id: string; input_type: string; name: string; read_only: boolean; required?: string; + text: string; } export type GetCommonFieldsResponse = ExternalServiceFields[]; -export type Incident = Pick< - ExecutorSubActionPushParams, - 'description' | 'incidentTypes' | 'severityCode' -> & { - name: string; -}; +export type Incident = Omit; export interface CreateIncidentParams { incident: Incident; @@ -92,7 +76,7 @@ export interface UpdateIncidentParams { export interface CreateCommentParams { incidentId: string; - comment: Comment; + comment: SimpleComment; } export type GetIncidentTypesResponse = Array<{ id: string; name: string }>; @@ -108,10 +92,7 @@ export interface ExternalService { updateIncident: (params: UpdateIncidentParams) => Promise; } -export interface PushToServiceApiParams extends ExecutorSubActionPushParams { - externalObject: Record; -} - +export type PushToServiceApiParams = ExecutorSubActionPushParams; export type ExecutorSubActionGetIncidentTypesParams = TypeOf< typeof ExecutorSubActionGetIncidentTypesParamsSchema >; @@ -122,7 +103,6 @@ export type ExecutorSubActionGetSeverityParams = TypeOf< export interface ExternalServiceApiHandlerArgs { externalService: ExternalService; - mapping: Map | null; } export type ExecutorSubActionGetIncidentParams = TypeOf< @@ -222,3 +202,12 @@ export interface CreateIncidentData { incident_type_ids?: Array<{ id: number }>; severity_code?: { id: number }; } +export interface SimpleComment { + comment: string; + commentId: string; +} +export interface ExternalServiceCommentResponse { + commentId: string; + pushedDate: string; + externalCommentId?: string; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/validators.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/validators.ts index a50e868cdda3d..2acd558e260aa 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/validators.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/validators.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isEmpty } from 'lodash'; import { ActionsConfigurationUtilities } from '../../actions_config'; import { ResilientPublicConfigurationType, @@ -18,13 +17,6 @@ export const validateCommonConfig = ( configurationUtilities: ActionsConfigurationUtilities, configObject: ResilientPublicConfigurationType ) => { - if ( - configObject.incidentConfiguration !== null && - isEmpty(configObject.incidentConfiguration.mapping) - ) { - return i18n.MAPPING_EMPTY; - } - try { configurationUtilities.ensureUriAllowed(configObject.apiUrl); } catch (allowedListError) { diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts index 4683b661e21da..772cd16cc4d51 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts @@ -5,7 +5,7 @@ */ import { Logger } from '../../../../../../src/core/server'; -import { externalServiceMock, mapping, apiParams, serviceNowCommonFields } from './mocks'; +import { externalServiceMock, apiParams, serviceNowCommonFields } from './mocks'; import { ExternalService } from './types'; import { api } from './api'; let mockedLogger: jest.Mocked; @@ -19,10 +19,9 @@ describe('api', () => { describe('create incident', () => { test('it creates an incident', async () => { - const params = { ...apiParams, externalId: null }; + const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } }; const res = await api.pushToService({ externalService, - mapping, params, secrets: {}, logger: mockedLogger, @@ -47,10 +46,13 @@ describe('api', () => { }); test('it creates an incident without comments', async () => { - const params = { ...apiParams, externalId: null, comments: [] }; + const params = { + ...apiParams, + incident: { ...apiParams.incident, externalId: null }, + comments: [], + }; const res = await api.pushToService({ externalService, - mapping, params, secrets: {}, logger: mockedLogger, @@ -65,10 +67,12 @@ describe('api', () => { }); test('it calls createIncident correctly', async () => { - const params = { ...apiParams, externalId: null, comments: [] }; + const params = { + incident: { ...apiParams.incident, externalId: null }, + comments: [], + }; await api.pushToService({ externalService, - mapping, params, secrets: { username: 'elastic', password: 'elastic' }, logger: mockedLogger, @@ -80,18 +84,17 @@ describe('api', () => { urgency: '2', impact: '3', caller_id: 'elastic', - description: 'Incident description (created at 2020-03-13T08:34:53.450Z by Elastic User)', - short_description: 'Incident title (created at 2020-03-13T08:34:53.450Z by Elastic User)', + description: 'Incident description', + short_description: 'Incident title', }, }); expect(externalService.updateIncident).not.toHaveBeenCalled(); }); test('it calls updateIncident correctly when creating an incident and having comments', async () => { - const params = { ...apiParams, externalId: null }; + const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } }; await api.pushToService({ externalService, - mapping, params, secrets: {}, logger: mockedLogger, @@ -102,9 +105,9 @@ describe('api', () => { severity: '1', urgency: '2', impact: '3', - comments: 'A comment (added at 2020-03-13T08:34:53.450Z by Elastic User)', - description: 'Incident description (created at 2020-03-13T08:34:53.450Z by Elastic User)', - short_description: 'Incident title (created at 2020-03-13T08:34:53.450Z by Elastic User)', + comments: 'A comment', + description: 'Incident description', + short_description: 'Incident title', }, incidentId: 'incident-1', }); @@ -114,9 +117,9 @@ describe('api', () => { severity: '1', urgency: '2', impact: '3', - comments: 'Another comment (added at 2020-03-13T08:34:53.450Z by Elastic User)', - description: 'Incident description (created at 2020-03-13T08:34:53.450Z by Elastic User)', - short_description: 'Incident title (created at 2020-03-13T08:34:53.450Z by Elastic User)', + comments: 'Another comment', + description: 'Incident description', + short_description: 'Incident title', }, incidentId: 'incident-1', }); @@ -127,7 +130,6 @@ describe('api', () => { test('it updates an incident', async () => { const res = await api.pushToService({ externalService, - mapping, params: apiParams, secrets: {}, logger: mockedLogger, @@ -155,7 +157,6 @@ describe('api', () => { const params = { ...apiParams, comments: [] }; const res = await api.pushToService({ externalService, - mapping, params, secrets: {}, logger: mockedLogger, @@ -173,7 +174,6 @@ describe('api', () => { const params = { ...apiParams }; await api.pushToService({ externalService, - mapping, params, secrets: {}, logger: mockedLogger, @@ -185,8 +185,8 @@ describe('api', () => { severity: '1', urgency: '2', impact: '3', - description: 'Incident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - short_description: 'Incident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + description: 'Incident description', + short_description: 'Incident title', }, }); expect(externalService.createIncident).not.toHaveBeenCalled(); @@ -196,7 +196,6 @@ describe('api', () => { const params = { ...apiParams }; await api.pushToService({ externalService, - mapping, params, secrets: {}, logger: mockedLogger, @@ -207,8 +206,8 @@ describe('api', () => { severity: '1', urgency: '2', impact: '3', - description: 'Incident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - short_description: 'Incident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + description: 'Incident description', + short_description: 'Incident title', }, incidentId: 'incident-3', }); @@ -218,409 +217,15 @@ describe('api', () => { severity: '1', urgency: '2', impact: '3', - comments: 'A comment (added at 2020-03-13T08:34:53.450Z by Elastic User)', - description: 'Incident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - short_description: 'Incident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + comments: 'A comment', + description: 'Incident description', + short_description: 'Incident title', }, incidentId: 'incident-2', }); }); }); - describe('mapping variations', () => { - test('overwrite & append', async () => { - mapping.set('title', { - target: 'short_description', - actionType: 'overwrite', - }); - - mapping.set('description', { - target: 'description', - actionType: 'append', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - mapping.set('short_description', { - target: 'title', - actionType: 'overwrite', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - secrets: {}, - logger: mockedLogger, - }); - expect(externalService.updateIncident).toHaveBeenCalledWith({ - incidentId: 'incident-3', - incident: { - severity: '1', - urgency: '2', - impact: '3', - short_description: 'Incident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - description: - 'description from servicenow \r\nIncident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - }, - }); - }); - - test('nothing & append', async () => { - mapping.set('title', { - target: 'short_description', - actionType: 'nothing', - }); - - mapping.set('description', { - target: 'description', - actionType: 'append', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - mapping.set('short_description', { - target: 'title', - actionType: 'nothing', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - secrets: {}, - logger: mockedLogger, - }); - expect(externalService.updateIncident).toHaveBeenCalledWith({ - incidentId: 'incident-3', - incident: { - severity: '1', - urgency: '2', - impact: '3', - description: - 'description from servicenow \r\nIncident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - }, - }); - }); - - test('append & append', async () => { - mapping.set('title', { - target: 'short_description', - actionType: 'append', - }); - - mapping.set('description', { - target: 'description', - actionType: 'append', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - mapping.set('short_description', { - target: 'title', - actionType: 'append', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - secrets: {}, - logger: mockedLogger, - }); - expect(externalService.updateIncident).toHaveBeenCalledWith({ - incidentId: 'incident-3', - incident: { - severity: '1', - urgency: '2', - impact: '3', - short_description: - 'title from servicenow \r\nIncident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - description: - 'description from servicenow \r\nIncident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - }, - }); - }); - - test('nothing & nothing', async () => { - mapping.set('title', { - target: 'short_description', - actionType: 'nothing', - }); - - mapping.set('description', { - target: 'description', - actionType: 'nothing', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - mapping.set('short_description', { - target: 'title', - actionType: 'nothing', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - secrets: {}, - logger: mockedLogger, - }); - - expect(externalService.updateIncident).toHaveBeenCalledWith({ - incidentId: 'incident-3', - incident: { - severity: '1', - urgency: '2', - impact: '3', - }, - }); - }); - - test('overwrite & nothing', async () => { - mapping.set('title', { - target: 'short_description', - actionType: 'overwrite', - }); - - mapping.set('description', { - target: 'description', - actionType: 'nothing', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - mapping.set('short_description', { - target: 'title', - actionType: 'overwrite', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - secrets: {}, - logger: mockedLogger, - }); - expect(externalService.updateIncident).toHaveBeenCalledWith({ - incidentId: 'incident-3', - incident: { - severity: '1', - urgency: '2', - impact: '3', - short_description: 'Incident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - }, - }); - }); - - test('overwrite & overwrite', async () => { - mapping.set('title', { - target: 'short_description', - actionType: 'overwrite', - }); - - mapping.set('description', { - target: 'description', - actionType: 'overwrite', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - mapping.set('short_description', { - target: 'title', - actionType: 'overwrite', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - secrets: {}, - logger: mockedLogger, - }); - expect(externalService.updateIncident).toHaveBeenCalledWith({ - incidentId: 'incident-3', - incident: { - severity: '1', - urgency: '2', - impact: '3', - short_description: 'Incident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - description: 'Incident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - }, - }); - }); - - test('nothing & overwrite', async () => { - mapping.set('title', { - target: 'short_description', - actionType: 'nothing', - }); - - mapping.set('description', { - target: 'description', - actionType: 'overwrite', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - mapping.set('short_description', { - target: 'title', - actionType: 'nothing', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - secrets: {}, - logger: mockedLogger, - }); - expect(externalService.updateIncident).toHaveBeenCalledWith({ - incidentId: 'incident-3', - incident: { - severity: '1', - urgency: '2', - impact: '3', - description: 'Incident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - }, - }); - }); - - test('append & overwrite', async () => { - mapping.set('title', { - target: 'short_description', - actionType: 'append', - }); - - mapping.set('description', { - target: 'description', - actionType: 'overwrite', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - mapping.set('short_description', { - target: 'title', - actionType: 'append', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - secrets: {}, - logger: mockedLogger, - }); - expect(externalService.updateIncident).toHaveBeenCalledWith({ - incidentId: 'incident-3', - incident: { - severity: '1', - urgency: '2', - impact: '3', - short_description: - 'title from servicenow \r\nIncident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - description: 'Incident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - }, - }); - }); - - test('append & nothing', async () => { - mapping.set('title', { - target: 'short_description', - actionType: 'append', - }); - - mapping.set('description', { - target: 'description', - actionType: 'nothing', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - mapping.set('short_description', { - target: 'title', - actionType: 'append', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - secrets: {}, - logger: mockedLogger, - }); - expect(externalService.updateIncident).toHaveBeenCalledWith({ - incidentId: 'incident-3', - incident: { - severity: '1', - urgency: '2', - impact: '3', - short_description: - 'title from servicenow \r\nIncident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - }, - }); - }); - - test('comment nothing', async () => { - mapping.set('title', { - target: 'short_description', - actionType: 'overwrite', - }); - - mapping.set('description', { - target: 'description', - actionType: 'nothing', - }); - - mapping.set('comments', { - target: 'comments', - actionType: 'nothing', - }); - - mapping.set('short_description', { - target: 'title', - actionType: 'overwrite', - }); - - await api.pushToService({ - externalService, - mapping, - params: apiParams, - secrets: {}, - logger: mockedLogger, - }); - expect(externalService.updateIncident).toHaveBeenCalledTimes(1); - }); - }); - describe('getFields', () => { test('it returns the fields correctly', async () => { const res = await api.getFields({ diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts index fbd8fdd635d70..9981a8431a736 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts @@ -4,86 +4,30 @@ * you may not use this file except in compliance with the Elastic License. */ import { - ExternalServiceParams, - PushToServiceApiHandlerArgs, - HandshakeApiHandlerArgs, - GetIncidentApiHandlerArgs, ExternalServiceApi, - PushToServiceApiParams, - PushToServiceResponse, - Incident, GetCommonFieldsHandlerArgs, GetCommonFieldsResponse, + GetIncidentApiHandlerArgs, + HandshakeApiHandlerArgs, + Incident, + PushToServiceApiHandlerArgs, + PushToServiceResponse, } from './types'; -// TODO: to remove, need to support Case -import { transformFields, transformComments, prepareFieldsForTransformation } from '../case/utils'; - -const handshakeHandler = async ({ - externalService, - mapping, - params, -}: HandshakeApiHandlerArgs) => {}; -const getIncidentHandler = async ({ - externalService, - mapping, - params, -}: GetIncidentApiHandlerArgs) => {}; +const handshakeHandler = async ({ externalService, params }: HandshakeApiHandlerArgs) => {}; +const getIncidentHandler = async ({ externalService, params }: GetIncidentApiHandlerArgs) => {}; const pushToServiceHandler = async ({ externalService, - mapping, params, secrets, - logger, }: PushToServiceApiHandlerArgs): Promise => { - const { externalId, comments } = params; - const updateIncident = externalId ? true : false; - const defaultPipes = updateIncident ? ['informationUpdated'] : ['informationCreated']; - let currentIncident: ExternalServiceParams | undefined; + const { comments } = params; let res: PushToServiceResponse; + const { externalId, ...rest } = params.incident; + const incident: Incident = rest; - if (externalId) { - try { - currentIncident = await externalService.getIncident(externalId); - } catch (ex) { - logger.debug( - `Retrieving Incident by id ${externalId} from ServiceNow was failed with exception: ${ex}` - ); - } - } - - let incident = {}; - // TODO: should be removed later but currently keep it for the Case implementation support - if (mapping && Array.isArray(params.comments)) { - const fields = prepareFieldsForTransformation({ - externalCase: params.externalObject, - mapping, - defaultPipes, - }); - - const transformedFields = transformFields< - PushToServiceApiParams, - ExternalServiceParams, - Incident - >({ - params, - fields, - currentIncident, - }); - - incident = { - severity: params.severity, - urgency: params.urgency, - impact: params.impact, - short_description: transformedFields.short_description, - description: transformedFields.description, - }; - } else { - incident = { ...params, short_description: params.title, comments: params.comment }; - } - - if (updateIncident) { + if (externalId != null) { res = await externalService.updateIncident({ incidentId: externalId, incident, @@ -97,24 +41,15 @@ const pushToServiceHandler = async ({ }); } - // TODO: should temporary keep comments for a Case usage - if ( - comments && - Array.isArray(comments) && - comments.length > 0 && - mapping && - mapping.get('comments')?.actionType !== 'nothing' - ) { + if (comments && Array.isArray(comments) && comments.length > 0) { res.comments = []; - const commentsTransformed = transformComments(comments, ['informationAdded']); - const fieldsKey = mapping.get('comments')?.target ?? 'comments'; - for (const currentComment of commentsTransformed) { + for (const currentComment of comments) { await externalService.updateIncident({ incidentId: res.id, incident: { ...incident, - [fieldsKey]: currentComment.comment, + comments: currentComment.comment, }, }); res.comments = [ diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts index d1182b0d3b2fa..3fa8b25b86e8b 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts @@ -29,9 +29,6 @@ import { ServiceNowExecutorResultData, } from './types'; -// TODO: to remove, need to support Case -import { buildMap, mapParams } from '../case/utils'; - interface GetActionTypeParams { logger: Logger; configurationUtilities: ActionsConfigurationUtilities; @@ -101,17 +98,9 @@ async function executor( if (subAction === 'pushToService') { const pushToServiceParams = subActionParams as ExecutorSubActionPushParams; - - const { comments, externalId, ...restParams } = pushToServiceParams; - const incidentConfiguration = config.incidentConfiguration; - const mapping = incidentConfiguration ? buildMap(incidentConfiguration.mapping) : null; - const externalObject = - config.incidentConfiguration && mapping ? mapParams(restParams, mapping) : {}; - data = await api.pushToService({ externalService, - mapping, - params: { ...pushToServiceParams, externalObject }, + params: pushToServiceParams, secrets, logger, }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts index 2351be36a50c4..9d9b1e164e7dd 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts @@ -5,7 +5,6 @@ */ import { ExternalService, PushToServiceApiParams, ExecutorSubActionPushParams } from './types'; -import { MapRecord } from '../case/types'; export const serviceNowCommonFields = [ { @@ -69,64 +68,29 @@ const externalServiceMock = { create: createMock, }; -const mapping: Map> = new Map(); - -mapping.set('title', { - target: 'short_description', - actionType: 'overwrite', -}); - -mapping.set('description', { - target: 'description', - actionType: 'overwrite', -}); - -mapping.set('comments', { - target: 'comments', - actionType: 'append', -}); - -mapping.set('short_description', { - target: 'title', - actionType: 'overwrite', -}); - const executorParams: ExecutorSubActionPushParams = { - savedObjectId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', - externalId: 'incident-3', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: '2020-03-13T08:34:53.450Z', - updatedBy: { fullName: 'Elastic User', username: 'elastic' }, - title: 'Incident title', - description: 'Incident description', - comment: 'test-alert comment', - severity: '1', - urgency: '2', - impact: '3', + incident: { + externalId: 'incident-3', + short_description: 'Incident title', + description: 'Incident description', + severity: '1', + urgency: '2', + impact: '3', + }, comments: [ { commentId: 'case-comment-1', comment: 'A comment', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: '2020-03-13T08:34:53.450Z', - updatedBy: { fullName: 'Elastic User', username: 'elastic' }, }, { commentId: 'case-comment-2', comment: 'Another comment', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: '2020-03-13T08:34:53.450Z', - updatedBy: { fullName: 'Elastic User', username: 'elastic' }, }, ], }; const apiParams: PushToServiceApiParams = { ...executorParams, - externalObject: { short_description: 'Incident title', description: 'Incident description' }, }; -export { externalServiceMock, mapping, executorParams, apiParams }; +export { externalServiceMock, executorParams, apiParams }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts index 77c48aab1f309..1c05fa93f2362 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts @@ -5,13 +5,9 @@ */ import { schema } from '@kbn/config-schema'; -import { CommentSchema, EntityInformation, IncidentConfigurationSchema } from '../case/schema'; export const ExternalIncidentServiceConfiguration = { apiUrl: schema.string(), - // TODO: to remove - set it optional for the current stage to support Case ServiceNow implementation - incidentConfiguration: schema.nullable(IncidentConfigurationSchema), - isCaseOwned: schema.maybe(schema.boolean()), }; export const ExternalIncidentServiceConfigurationSchema = schema.object( @@ -35,17 +31,22 @@ export const ExecutorSubActionSchema = schema.oneOf([ ]); export const ExecutorSubActionPushParamsSchema = schema.object({ - savedObjectId: schema.nullable(schema.string()), - title: schema.string(), - description: schema.nullable(schema.string()), - comment: schema.nullable(schema.string()), - externalId: schema.nullable(schema.string()), - severity: schema.nullable(schema.string()), - urgency: schema.nullable(schema.string()), - impact: schema.nullable(schema.string()), - // TODO: remove later - need for support Case push multiple comments - comments: schema.maybe(schema.arrayOf(CommentSchema)), - ...EntityInformation, + incident: schema.object({ + short_description: schema.string(), + description: schema.nullable(schema.string()), + externalId: schema.nullable(schema.string()), + severity: schema.nullable(schema.string()), + urgency: schema.nullable(schema.string()), + impact: schema.nullable(schema.string()), + }), + comments: schema.nullable( + schema.arrayOf( + schema.object({ + comment: schema.string(), + commentId: schema.string(), + }) + ) + ), }); export const ExecutorSubActionGetIncidentParamsSchema = schema.object({ diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts index 3e4873270ad7a..1a6412f9ceb5b 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts @@ -249,7 +249,7 @@ describe('ServiceNow service', () => { axios, logger, url: - 'https://dev102283.service-now.com/api/now/v2/table/sys_dictionary?sysparm_query=name=task^internal_type=string&active=true&read_only=false&sysparm_fields=max_length,element,column_label', + 'https://dev102283.service-now.com/api/now/v2/table/sys_dictionary?sysparm_query=name=task^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', }); }); test('it returns common fields correctly', async () => { @@ -265,7 +265,7 @@ describe('ServiceNow service', () => { throw new Error('An error has occurred'); }); await expect(service.getFields()).rejects.toThrow( - 'Unable to get common fields. Error: An error has occurred' + '[Action][ServiceNow]: Unable to get fields. Error: An error has occurred' ); }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts index 29614a4b951e1..96faf6d338b90 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import axios from 'axios'; +import axios, { AxiosResponse } from 'axios'; import { ExternalServiceCredentials, ExternalService, ExternalServiceParams } from './types'; @@ -35,7 +35,7 @@ export const createExternalService = ( const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url; const incidentUrl = `${urlWithoutTrailingSlash}/${INCIDENT_URL}`; - const fieldsUrl = `${urlWithoutTrailingSlash}/${SYS_DICTIONARY}?sysparm_query=name=task^internal_type=string&active=true&read_only=false&sysparm_fields=max_length,element,column_label`; + const fieldsUrl = `${urlWithoutTrailingSlash}/${SYS_DICTIONARY}?sysparm_query=name=task^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory`; const axiosInstance = axios.create({ auth: { username, password }, }); @@ -44,6 +44,14 @@ export const createExternalService = ( return `${urlWithoutTrailingSlash}/${VIEW_INCIDENT_URL}${id}`; }; + const checkInstance = (res: AxiosResponse) => { + if (res.status === 200 && res.data.result == null) { + throw new Error( + `There is an issue with your Service Now Instance. Please check ${res.request.connection.servername}` + ); + } + }; + const getIncident = async (id: string) => { try { const res = await request({ @@ -52,7 +60,7 @@ export const createExternalService = ( logger, proxySettings, }); - + checkInstance(res); return { ...res.data.result }; } catch (error) { throw new Error( @@ -70,7 +78,7 @@ export const createExternalService = ( proxySettings, params, }); - + checkInstance(res); return res.data.result.length > 0 ? { ...res.data.result } : undefined; } catch (error) { throw new Error( @@ -89,7 +97,7 @@ export const createExternalService = ( method: 'post', data: { ...(incident as Record) }, }); - + checkInstance(res); return { title: res.data.result.number, id: res.data.result.sys_id, @@ -112,7 +120,7 @@ export const createExternalService = ( data: { ...(incident as Record) }, proxySettings, }); - + checkInstance(res); return { title: res.data.result.number, id: res.data.result.sys_id, @@ -137,12 +145,10 @@ export const createExternalService = ( logger, proxySettings, }); - + checkInstance(res); return res.data.result.length > 0 ? res.data.result : []; } catch (error) { - throw new Error( - getErrorMessage(i18n.NAME, `Unable to get common fields. Error: ${error.message}`) - ); + throw new Error(getErrorMessage(i18n.NAME, `Unable to get fields. Error: ${error.message}`)); } }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts index 7cc97a241c4bc..287fe8cacda79 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts @@ -17,11 +17,3 @@ export const ALLOWED_HOSTS_ERROR = (message: string) => message, }, }); - -// TODO: remove when Case mappings will be removed -export const MAPPING_EMPTY = i18n.translate( - 'xpack.actions.builtin.servicenow.configuration.emptyMapping', - { - defaultMessage: '[incidentConfiguration.mapping]: expected non-empty but got empty', - } -); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts index 0ee03f883ec05..9868f5d1bea06 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -17,8 +17,6 @@ import { ExternalIncidentServiceSecretConfigurationSchema, } from './schema'; import { ActionsConfigurationUtilities } from '../../actions_config'; -import { ExternalServiceCommentResponse } from '../case/types'; -import { IncidentConfigurationSchema } from '../case/schema'; import { Logger } from '../../../../../../src/core/server'; export type ServiceNowPublicConfigurationType = TypeOf< @@ -41,8 +39,6 @@ export interface CreateCommentRequest { export type ExecutorParams = TypeOf; export type ExecutorSubActionPushParams = TypeOf; -export type IncidentConfiguration = TypeOf; - export interface ExternalServiceCredentials { config: Record; secrets: Record; @@ -73,13 +69,10 @@ export interface ExternalService { findIncidents: (params?: Record) => Promise; } -export interface PushToServiceApiParams extends ExecutorSubActionPushParams { - externalObject: Record; -} +export type PushToServiceApiParams = ExecutorSubActionPushParams; export interface ExternalServiceApiHandlerArgs { externalService: ExternalService; - mapping: Map | null; } export type ExecutorSubActionGetIncidentParams = TypeOf< @@ -90,12 +83,7 @@ export type ExecutorSubActionHandshakeParams = TypeOf< typeof ExecutorSubActionHandshakeParamsSchema >; -export type Incident = Pick< - ExecutorSubActionPushParams, - 'description' | 'severity' | 'urgency' | 'impact' -> & { - short_description: string; -}; +export type Incident = Omit; export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs { params: PushToServiceApiParams; @@ -112,11 +100,7 @@ export interface HandshakeApiHandlerArgs extends ExternalServiceApiHandlerArgs { } export interface ExternalServiceFields { column_label: string; - name: string; - internal_type: { - link: string; - value: string; - }; + mandatory: string; max_length: string; element: string; } @@ -132,3 +116,9 @@ export interface ExternalServiceApi { pushToService: (args: PushToServiceApiHandlerArgs) => Promise; getIncident: (args: GetIncidentApiHandlerArgs) => Promise; } + +export interface ExternalServiceCommentResponse { + commentId: string; + pushedDate: string; + externalCommentId?: string; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.ts index 87bbfd9c7ea95..07c6d83a13042 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isEmpty } from 'lodash'; import { ActionsConfigurationUtilities } from '../../actions_config'; import { ServiceNowPublicConfigurationType, @@ -18,13 +17,6 @@ export const validateCommonConfig = ( configurationUtilities: ActionsConfigurationUtilities, configObject: ServiceNowPublicConfigurationType ) => { - if ( - configObject.incidentConfiguration !== null && - isEmpty(configObject.incidentConfiguration.mapping) - ) { - return i18n.MAPPING_EMPTY; - } - try { configurationUtilities.ensureUriAllowed(configObject.apiUrl); } catch (allowedListError) { diff --git a/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts b/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts index d98a41ed1f355..cc2c0eda76f52 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts @@ -213,4 +213,16 @@ describe('execute()', () => { 'IncomingWebhook was called with proxyUrl https://someproxyhost' ); }); + + test('renders parameter templates as expected', async () => { + expect(actionType.renderParameterTemplates).toBeTruthy(); + const paramsWithTemplates = { + message: '{{rogue}}', + }; + const variables = { + rogue: '*bold*', + }; + const params = actionType.renderParameterTemplates!(paramsWithTemplates, variables); + expect(params.message).toBe('`*bold*`'); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/slack.ts b/x-pack/plugins/actions/server/builtin_action_types/slack.ts index 628a13e19f7a9..a9155c329c175 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/slack.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/slack.ts @@ -15,6 +15,7 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { map, getOrElse } from 'fp-ts/lib/Option'; import { Logger } from '../../../../../src/core/server'; import { getRetryAfterIntervalFromHeaders } from './lib/http_rersponse_retry_header'; +import { renderMustacheString } from '../lib/mustache_renderer'; import { ActionType, @@ -73,10 +74,20 @@ export function getActionType({ }), params: ParamsSchema, }, + renderParameterTemplates, executor, }; } +function renderParameterTemplates( + params: ActionParamsType, + variables: Record +): ActionParamsType { + return { + message: renderMustacheString(params.message, variables, 'slack'), + }; +} + function valdiateActionTypeConfig( configurationUtilities: ActionsConfigurationUtilities, secretsObject: ActionTypeSecretsType diff --git a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts index 74feb8ee57d48..dbbd2a029caa9 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts @@ -373,4 +373,28 @@ describe('execute()', () => { } `); }); + + test('renders parameter templates as expected', async () => { + const rogue = `double-quote:"; line-break->\n`; + + expect(actionType.renderParameterTemplates).toBeTruthy(); + const paramsWithTemplates = { + body: '{"x": "{{rogue}}"}', + }; + const variables = { + rogue, + }; + const params = actionType.renderParameterTemplates!(paramsWithTemplates, variables); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let paramsObject: any; + try { + paramsObject = JSON.parse(`${params.body}`); + } catch (err) { + expect(err).toBe(null); // kinda weird, but test should fail if it can't parse + } + + expect(paramsObject.x).toBe(rogue); + expect(params.body).toBe(`{"x": "double-quote:\\"; line-break->\\n"}`); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/webhook.ts b/x-pack/plugins/actions/server/builtin_action_types/webhook.ts index dc9de86d3d951..3d872d6e7e311 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/webhook.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.ts @@ -16,6 +16,7 @@ import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from import { ActionsConfigurationUtilities } from '../actions_config'; import { Logger } from '../../../../../src/core/server'; import { request } from './lib/axios_utils'; +import { renderMustacheString } from '../lib/mustache_renderer'; // config definition export enum WebhookMethods { @@ -91,10 +92,21 @@ export function getActionType({ secrets: SecretsSchema, params: ParamsSchema, }, + renderParameterTemplates, executor: curry(executor)({ logger }), }; } +function renderParameterTemplates( + params: ActionParamsType, + variables: Record +): ActionParamsType { + if (!params.body) return params; + return { + body: renderMustacheString(params.body, variables, 'json'), + }; +} + function validateActionTypeConfig( configurationUtilities: ActionsConfigurationUtilities, configObject: ActionTypeConfigType diff --git a/x-pack/plugins/actions/server/lib/mustache_renderer.test.ts b/x-pack/plugins/actions/server/lib/mustache_renderer.test.ts new file mode 100644 index 0000000000000..e34aa85af7368 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/mustache_renderer.test.ts @@ -0,0 +1,183 @@ +/* + * 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 { renderMustacheString, renderMustacheObject, Escape } from './mustache_renderer'; + +const variables = { + a: 1, + b: '2', + c: false, + d: null, + e: undefined, + f: { + g: 3, + h: null, + }, + i: [42, 43, 44], + lt: '<', + gt: '>', + amp: '&', + nl: '\n', + dq: '"', + bt: '`', + bs: '\\', + st: '*', + ul: '_', + st_lt: '*<', +}; + +describe('mustache_renderer', () => { + describe('renderMustacheString()', () => { + for (const escapeVal of ['none', 'slack', 'markdown', 'json']) { + const escape = escapeVal as Escape; + + it(`handles basic templating that does not need escaping for ${escape}`, () => { + expect(renderMustacheString('', variables, escape)).toBe(''); + expect(renderMustacheString('{{a}}', variables, escape)).toBe('1'); + expect(renderMustacheString('{{b}}', variables, escape)).toBe('2'); + expect(renderMustacheString('{{c}}', variables, escape)).toBe('false'); + expect(renderMustacheString('{{d}}', variables, escape)).toBe(''); + expect(renderMustacheString('{{e}}', variables, escape)).toBe(''); + if (escape === 'markdown') { + expect(renderMustacheString('{{f}}', variables, escape)).toBe('\\[object Object\\]'); + } else { + expect(renderMustacheString('{{f}}', variables, escape)).toBe('[object Object]'); + } + expect(renderMustacheString('{{f.g}}', variables, escape)).toBe('3'); + expect(renderMustacheString('{{f.h}}', variables, escape)).toBe(''); + expect(renderMustacheString('{{i}}', variables, escape)).toBe('42,43,44'); + }); + } + + it('handles escape:none with commonly escaped strings', () => { + expect(renderMustacheString('{{lt}}', variables, 'none')).toBe(variables.lt); + expect(renderMustacheString('{{gt}}', variables, 'none')).toBe(variables.gt); + expect(renderMustacheString('{{amp}}', variables, 'none')).toBe(variables.amp); + expect(renderMustacheString('{{nl}}', variables, 'none')).toBe(variables.nl); + expect(renderMustacheString('{{dq}}', variables, 'none')).toBe(variables.dq); + expect(renderMustacheString('{{bt}}', variables, 'none')).toBe(variables.bt); + expect(renderMustacheString('{{bs}}', variables, 'none')).toBe(variables.bs); + expect(renderMustacheString('{{st}}', variables, 'none')).toBe(variables.st); + expect(renderMustacheString('{{ul}}', variables, 'none')).toBe(variables.ul); + }); + + it('handles escape:markdown with commonly escaped strings', () => { + expect(renderMustacheString('{{lt}}', variables, 'markdown')).toBe(variables.lt); + expect(renderMustacheString('{{gt}}', variables, 'markdown')).toBe(variables.gt); + expect(renderMustacheString('{{amp}}', variables, 'markdown')).toBe(variables.amp); + expect(renderMustacheString('{{nl}}', variables, 'markdown')).toBe(variables.nl); + expect(renderMustacheString('{{dq}}', variables, 'markdown')).toBe(variables.dq); + expect(renderMustacheString('{{bt}}', variables, 'markdown')).toBe('\\' + variables.bt); + expect(renderMustacheString('{{bs}}', variables, 'markdown')).toBe('\\' + variables.bs); + expect(renderMustacheString('{{st}}', variables, 'markdown')).toBe('\\' + variables.st); + expect(renderMustacheString('{{ul}}', variables, 'markdown')).toBe('\\' + variables.ul); + }); + + it('handles triple escapes', () => { + expect(renderMustacheString('{{{bt}}}', variables, 'markdown')).toBe(variables.bt); + expect(renderMustacheString('{{{bs}}}', variables, 'markdown')).toBe(variables.bs); + expect(renderMustacheString('{{{st}}}', variables, 'markdown')).toBe(variables.st); + expect(renderMustacheString('{{{ul}}}', variables, 'markdown')).toBe(variables.ul); + }); + + it('handles escape:slack with commonly escaped strings', () => { + expect(renderMustacheString('{{lt}}', variables, 'slack')).toBe('<'); + expect(renderMustacheString('{{gt}}', variables, 'slack')).toBe('>'); + expect(renderMustacheString('{{amp}}', variables, 'slack')).toBe('&'); + expect(renderMustacheString('{{nl}}', variables, 'slack')).toBe(variables.nl); + expect(renderMustacheString('{{dq}}', variables, 'slack')).toBe(variables.dq); + expect(renderMustacheString('{{bt}}', variables, 'slack')).toBe(`'`); + expect(renderMustacheString('{{bs}}', variables, 'slack')).toBe(variables.bs); + expect(renderMustacheString('{{st}}', variables, 'slack')).toBe('`*`'); + expect(renderMustacheString('{{ul}}', variables, 'slack')).toBe('`_`'); + // html escapes not needed when using backtic escaping + expect(renderMustacheString('{{st_lt}}', variables, 'slack')).toBe('`*<`'); + }); + + it('handles escape:json with commonly escaped strings', () => { + expect(renderMustacheString('{{lt}}', variables, 'json')).toBe(variables.lt); + expect(renderMustacheString('{{gt}}', variables, 'json')).toBe(variables.gt); + expect(renderMustacheString('{{amp}}', variables, 'json')).toBe(variables.amp); + expect(renderMustacheString('{{nl}}', variables, 'json')).toBe('\\n'); + expect(renderMustacheString('{{dq}}', variables, 'json')).toBe('\\"'); + expect(renderMustacheString('{{bt}}', variables, 'json')).toBe(variables.bt); + expect(renderMustacheString('{{bs}}', variables, 'json')).toBe('\\\\'); + expect(renderMustacheString('{{st}}', variables, 'json')).toBe(variables.st); + expect(renderMustacheString('{{ul}}', variables, 'json')).toBe(variables.ul); + }); + + it('handles errors', () => { + expect(renderMustacheString('{{a}', variables, 'none')).toMatchInlineSnapshot( + `"error rendering mustache template \\"{{a}\\": Unclosed tag at 4"` + ); + }); + }); + + const object = { + literal: 0, + literals: { + a: 1, + b: '2', + c: true, + d: null, + e: undefined, + eval: '{{lt}}{{b}}{{gt}}', + }, + list: ['{{a}}', '{{bt}}{{st}}{{bt}}'], + object: { + a: ['{{a}}', '{{bt}}{{st}}{{bt}}'], + }, + }; + + describe('renderMustacheObject()', () => { + it('handles deep objects', () => { + expect(renderMustacheObject(object, variables)).toMatchInlineSnapshot(` + Object { + "list": Array [ + "1", + "\`*\`", + ], + "literal": 0, + "literals": Object { + "a": 1, + "b": "2", + "c": true, + "d": null, + "e": undefined, + "eval": "<2>", + }, + "object": Object { + "a": Array [ + "1", + "\`*\`", + ], + }, + } + `); + }); + + it('handles primitive objects', () => { + expect(renderMustacheObject(undefined, variables)).toMatchInlineSnapshot(`undefined`); + expect(renderMustacheObject(null, variables)).toMatchInlineSnapshot(`null`); + expect(renderMustacheObject(0, variables)).toMatchInlineSnapshot(`0`); + expect(renderMustacheObject(true, variables)).toMatchInlineSnapshot(`true`); + expect(renderMustacheObject('{{a}}', variables)).toMatchInlineSnapshot(`"1"`); + expect(renderMustacheObject(['{{a}}'], variables)).toMatchInlineSnapshot(` + Array [ + "1", + ] + `); + }); + + it('handles errors', () => { + expect(renderMustacheObject({ a: '{{a}' }, variables)).toMatchInlineSnapshot(` + Object { + "a": "error rendering mustache template \\"{{a}\\": Unclosed tag at 4", + } + `); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/lib/mustache_renderer.ts b/x-pack/plugins/actions/server/lib/mustache_renderer.ts new file mode 100644 index 0000000000000..ae17e12921726 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/mustache_renderer.ts @@ -0,0 +1,107 @@ +/* + * 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 Mustache from 'mustache'; +import { isString, cloneDeepWith } from 'lodash'; + +export type Escape = 'markdown' | 'slack' | 'json' | 'none'; +type Variables = Record; + +// return a rendered mustache template given the specified variables and escape +export function renderMustacheString(string: string, variables: Variables, escape: Escape): string { + const previousMustacheEscape = Mustache.escape; + Mustache.escape = getEscape(escape); + + try { + return Mustache.render(`${string}`, variables); + } catch (err) { + // log error; the mustache code does not currently leak variables + return `error rendering mustache template "${string}": ${err.message}`; + } finally { + Mustache.escape = previousMustacheEscape; + } +} + +// return a cloned object with all strings rendered as mustache templates +export function renderMustacheObject(params: Params, variables: Variables): Params { + const result = cloneDeepWith(params, (value: unknown) => { + if (!isString(value)) return; + + // since we're rendering a JS object, no escaping needed + return renderMustacheString(value, variables, 'none'); + }); + + // The return type signature for `cloneDeep()` ends up taking the return + // type signature for the customizer, but rather than pollute the customizer + // with casts, seemed better to just do it in one place, here. + return (result as unknown) as Params; +} + +function getEscape(escape: Escape): (value: unknown) => string { + if (escape === 'markdown') return escapeMarkdown; + if (escape === 'slack') return escapeSlack; + if (escape === 'json') return escapeJSON; + return escapeNone; +} + +function escapeNone(value: unknown): string { + if (value == null) return ''; + return `${value}`; +} + +// replace with JSON stringified version, removing leading and trailing double quote +function escapeJSON(value: unknown): string { + if (value == null) return ''; + + const quoted = JSON.stringify(`${value}`); + // quoted will always be a string with double quotes, but we don't want the double quotes + return quoted.substr(1, quoted.length - 2); +} + +// see: https://api.slack.com/reference/surfaces/formatting +// but in practice, a bit more needs to be escaped, in drastic ways +function escapeSlack(value: unknown): string { + if (value == null) return ''; + + const valueString = `${value}`; + // if the value contains * or _, escape the whole thing with back tics + if (valueString.includes('_') || valueString.includes('*')) { + // replace unescapable back tics with single quote + return '`' + valueString.replace(/`/g, `'`) + '`'; + } + + // otherwise, do "standard" escaping + return ( + valueString + .replace(/&/g, '&') + .replace(//g, '>') + // this isn't really standard escaping, but escaping back tics is problematic + .replace(/`/g, `'`) + ); +} + +// see: https://www.markdownguide.org/basic-syntax/#characters-you-can-escape +function escapeMarkdown(value: unknown): string { + if (value == null) return ''; + + return `${value}` + .replace(/\\/g, '\\\\') + .replace(/`/g, '\\`') + .replace(/\*/g, '\\*') + .replace(/_/g, '\\_') + .replace(/{/g, '\\{') + .replace(/}/g, '\\}') + .replace(/\[/g, '\\[') + .replace(/\]/g, '\\]') + .replace(/\(/g, '\\(') + .replace(/\)/g, '\\)') + .replace(/#/g, '\\#') + .replace(/\+/g, '\\+') + .replace(/-/g, '\\-') + .replace(/\./g, '\\.') + .replace(/!/g, '\\!'); +} diff --git a/x-pack/plugins/actions/server/mocks.ts b/x-pack/plugins/actions/server/mocks.ts index ad1c51d06d0c0..a766b5aa1776b 100644 --- a/x-pack/plugins/actions/server/mocks.ts +++ b/x-pack/plugins/actions/server/mocks.ts @@ -5,14 +5,13 @@ */ import { actionsClientMock } from './actions_client.mock'; -import { PluginSetupContract, PluginStartContract } from './plugin'; +import { PluginSetupContract, PluginStartContract, renderActionParameterTemplates } from './plugin'; import { Services } from './types'; import { elasticsearchServiceMock, savedObjectsClientMock, } from '../../../../src/core/server/mocks'; import { actionsAuthorizationMock } from './authorization/actions_authorization.mock'; - export { actionsAuthorizationMock }; export { actionsClientMock }; @@ -32,10 +31,20 @@ const createStartMock = () => { .fn() .mockReturnValue(actionsAuthorizationMock.create()), preconfiguredActions: [], + renderActionParameterTemplates: jest.fn(), }; return mock; }; +// this is a default renderer that escapes nothing +export function renderActionParameterTemplatesDefault( + actionTypeId: string, + params: Record, + variables: Record +) { + return renderActionParameterTemplates(undefined, actionTypeId, params, variables); +} + const createServicesMock = () => { const mock: jest.Mocked< Services & { diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 6e37d4bd7a92a..4d52b1c8b3492 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -75,6 +75,7 @@ import { AuthorizationMode, } from './authorization/get_authorization_mode_by_source'; import { ensureSufficientLicense } from './lib/ensure_sufficient_license'; +import { renderMustacheObject } from './lib/mustache_renderer'; const EVENT_LOG_PROVIDER = 'actions'; export const EVENT_LOG_ACTIONS = { @@ -103,6 +104,11 @@ export interface PluginStartContract { getActionsClientWithRequest(request: KibanaRequest): Promise>; getActionsAuthorizationWithRequest(request: KibanaRequest): PublicMethodsOf; preconfiguredActions: PreConfiguredAction[]; + renderActionParameterTemplates( + actionTypeId: string, + params: Params, + variables: Record + ): Params; } export interface ActionsPluginsSetup { @@ -389,6 +395,8 @@ export class ActionsPlugin implements Plugin, Plugi }, getActionsClientWithRequest: secureGetActionsClientWithRequest, preconfiguredActions, + renderActionParameterTemplates: (...args) => + renderActionParameterTemplates(actionTypeRegistry, ...args), }; } @@ -484,3 +492,17 @@ export class ActionsPlugin implements Plugin, Plugi } } } + +export function renderActionParameterTemplates( + actionTypeRegistry: ActionTypeRegistry | undefined, + actionTypeId: string, + params: Params, + variables: Record +): Params { + const actionType = actionTypeRegistry?.get(actionTypeId); + if (actionType?.renderParameterTemplates) { + return actionType.renderParameterTemplates(params, variables) as Params; + } else { + return renderMustacheObject(params, variables); + } +} diff --git a/x-pack/plugins/actions/server/saved_objects/migrations.test.ts b/x-pack/plugins/actions/server/saved_objects/migrations.test.ts index f1bd1ba2aeb60..fc0d9a45282ce 100644 --- a/x-pack/plugins/actions/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/actions/server/saved_objects/migrations.test.ts @@ -93,6 +93,21 @@ describe('7.11.0', () => { }, }); }); + test('remove cases mapping object', () => { + const migration711 = getMigrations(encryptedSavedObjectsSetup)['7.11.0']; + const action = getMockData({ + config: { incidentConfiguration: { mapping: [] }, isCaseOwned: true, another: 'value' }, + }); + expect(migration711(action, context)).toEqual({ + ...action, + attributes: { + ...action.attributes, + config: { + another: 'value', + }, + }, + }); + }); }); function getMockDataForWebhook( diff --git a/x-pack/plugins/actions/server/saved_objects/migrations.ts b/x-pack/plugins/actions/server/saved_objects/migrations.ts index 1e2290b14ec1b..1045047d97186 100644 --- a/x-pack/plugins/actions/server/saved_objects/migrations.ts +++ b/x-pack/plugins/actions/server/saved_objects/migrations.ts @@ -19,24 +19,23 @@ type ActionMigration = ( export function getMigrations( encryptedSavedObjects: EncryptedSavedObjectsPluginSetup ): SavedObjectMigrationMap { - const migrationActions = encryptedSavedObjects.createMigration( + const migrationActionsTen = encryptedSavedObjects.createMigration( (doc): doc is SavedObjectUnsanitizedDoc => !!doc.attributes.config?.casesConfiguration || doc.attributes.actionTypeId === '.email', pipeMigrations(renameCasesConfigurationObject, addHasAuthConfigurationObject) ); - const migrationWebhookConnectorHasAuth = encryptedSavedObjects.createMigration< - RawAction, - RawAction - >( + const migrationActionsEleven = encryptedSavedObjects.createMigration( (doc): doc is SavedObjectUnsanitizedDoc => + !!doc.attributes.config?.isCaseOwned || + !!doc.attributes.config?.incidentConfiguration || doc.attributes.actionTypeId === '.webhook', - pipeMigrations(addHasAuthConfigurationObject) + pipeMigrations(removeCasesFieldMappings, addHasAuthConfigurationObject) ); return { - '7.10.0': executeMigrationWithErrorHandling(migrationActions, '7.10.0'), - '7.11.0': executeMigrationWithErrorHandling(migrationWebhookConnectorHasAuth, '7.11.0'), + '7.10.0': executeMigrationWithErrorHandling(migrationActionsTen, '7.10.0'), + '7.11.0': executeMigrationWithErrorHandling(migrationActionsEleven, '7.11.0'), }; } @@ -77,6 +76,26 @@ function renameCasesConfigurationObject( }; } +function removeCasesFieldMappings( + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc { + if ( + !doc.attributes.config?.hasOwnProperty('isCaseOwned') && + !doc.attributes.config?.hasOwnProperty('incidentConfiguration') + ) { + return doc; + } + const { incidentConfiguration, isCaseOwned, ...restConfiguration } = doc.attributes.config; + + return { + ...doc, + attributes: { + ...doc.attributes, + config: restConfiguration, + }, + }; +} + const addHasAuthConfigurationObject = ( doc: SavedObjectUnsanitizedDoc ): SavedObjectUnsanitizedDoc => { diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts index 79895195d90f3..b311a602212c7 100644 --- a/x-pack/plugins/actions/server/types.ts +++ b/x-pack/plugins/actions/server/types.ts @@ -18,6 +18,9 @@ import { } from '../../../../src/core/server'; import { ActionTypeExecutorResult } from '../common'; export { ActionTypeExecutorResult } from '../common'; +export { GetFieldsByIssueTypeResponse as JiraGetFieldsResponse } from './builtin_action_types/jira/types'; +export { GetCommonFieldsResponse as ServiceNowGetFieldsResponse } from './builtin_action_types/servicenow/types'; +export { GetCommonFieldsResponse as ResilientGetFieldsResponse } from './builtin_action_types/resilient/types'; export type WithoutQueryAndParams = Pick>; export type GetServicesFunction = (request: KibanaRequest) => Services; @@ -112,6 +115,7 @@ export interface ActionType< config?: ValidatorType; secrets?: ValidatorType; }; + renderParameterTemplates?(params: Params, variables: Record): Params; executor: ExecutorType; } diff --git a/x-pack/plugins/actions/server/usage/actions_telemetry.ts b/x-pack/plugins/actions/server/usage/actions_telemetry.ts index 2815b5fbe6a5e..e3ff2552fed9c 100644 --- a/x-pack/plugins/actions/server/usage/actions_telemetry.ts +++ b/x-pack/plugins/actions/server/usage/actions_telemetry.ts @@ -35,7 +35,6 @@ export async function getTotalCount(callCluster: LegacyAPICaller, kibanaIndex: s const searchResult = await callCluster('search', { index: kibanaIndex, - rest_total_hits_as_int: true, body: { query: { bool: { @@ -104,7 +103,6 @@ export async function getInUseTotalCount(callCluster: LegacyAPICaller, kibanaInd const actionResults = await callCluster('search', { index: kibanaIndex, - rest_total_hits_as_int: true, body: { query: { bool: { diff --git a/x-pack/plugins/alerts/README.md b/x-pack/plugins/alerts/README.md index 5bd47c287b891..39dc23c7bbb73 100644 --- a/x-pack/plugins/alerts/README.md +++ b/x-pack/plugins/alerts/README.md @@ -172,6 +172,7 @@ server.newPlatform.setup.plugins.alerts.registerType({ { name: 'cpuUsage', description: 'CPU usage' }, ], }, + minimumLicenseRequired: 'basic', async executor({ alertId, startedAt, @@ -239,6 +240,7 @@ server.newPlatform.setup.plugins.alerts.registerType({ }, ], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', actionVariables: { context: [ { name: 'server', description: 'the server' }, diff --git a/x-pack/plugins/alerts/common/alert.ts b/x-pack/plugins/alerts/common/alert.ts index e0e73e978f775..d74f66898eff6 100644 --- a/x-pack/plugins/alerts/common/alert.ts +++ b/x-pack/plugins/alerts/common/alert.ts @@ -26,6 +26,7 @@ export enum AlertExecutionStatusErrorReasons { Decrypt = 'decrypt', Execute = 'execute', Unknown = 'unknown', + License = 'license', } export interface AlertExecutionStatus { diff --git a/x-pack/plugins/alerts/common/alert_type.ts b/x-pack/plugins/alerts/common/alert_type.ts index a06c6d2fd5af2..4ab3ddc7ca810 100644 --- a/x-pack/plugins/alerts/common/alert_type.ts +++ b/x-pack/plugins/alerts/common/alert_type.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { LicenseType } from '../../licensing/common/types'; + export interface AlertType { id: string; name: string; @@ -12,6 +14,7 @@ export interface AlertType { actionVariables: string[]; defaultActionGroupId: ActionGroup['id']; producer: string; + minimumLicenseRequired: LicenseType; } export interface ActionGroup { diff --git a/x-pack/plugins/alerts/public/alert_api.test.ts b/x-pack/plugins/alerts/public/alert_api.test.ts index 03c55dfdf5b28..bfa7065473dc6 100644 --- a/x-pack/plugins/alerts/public/alert_api.test.ts +++ b/x-pack/plugins/alerts/public/alert_api.test.ts @@ -22,6 +22,7 @@ describe('loadAlertTypes', () => { actionVariables: ['var1'], actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, producer: 'alerts', }, @@ -46,6 +47,7 @@ describe('loadAlertType', () => { actionVariables: ['var1'], actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, producer: 'alerts', }; @@ -67,6 +69,7 @@ describe('loadAlertType', () => { actionVariables: [], actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, producer: 'alerts', }; @@ -83,6 +86,7 @@ describe('loadAlertType', () => { actionVariables: [], actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, producer: 'alerts', }, diff --git a/x-pack/plugins/alerts/public/alert_navigation_registry/alert_navigation_registry.test.ts b/x-pack/plugins/alerts/public/alert_navigation_registry/alert_navigation_registry.test.ts index bf005e07f959e..9b40964f71949 100644 --- a/x-pack/plugins/alerts/public/alert_navigation_registry/alert_navigation_registry.test.ts +++ b/x-pack/plugins/alerts/public/alert_navigation_registry/alert_navigation_registry.test.ts @@ -18,6 +18,7 @@ const mockAlertType = (id: string): AlertType => ({ actionVariables: [], defaultActionGroupId: 'default', producer: 'alerts', + minimumLicenseRequired: 'basic', }); describe('AlertNavigationRegistry', () => { diff --git a/x-pack/plugins/alerts/server/alert_type_registry.mock.ts b/x-pack/plugins/alerts/server/alert_type_registry.mock.ts index 39d15eba014c9..f41023c189229 100644 --- a/x-pack/plugins/alerts/server/alert_type_registry.mock.ts +++ b/x-pack/plugins/alerts/server/alert_type_registry.mock.ts @@ -14,6 +14,7 @@ const createAlertTypeRegistryMock = () => { register: jest.fn(), get: jest.fn(), list: jest.fn(), + ensureAlertTypeEnabled: jest.fn(), }; return mocked; }; diff --git a/x-pack/plugins/alerts/server/alert_type_registry.test.ts b/x-pack/plugins/alerts/server/alert_type_registry.test.ts index e4811daa3611b..58b2cb74f2353 100644 --- a/x-pack/plugins/alerts/server/alert_type_registry.test.ts +++ b/x-pack/plugins/alerts/server/alert_type_registry.test.ts @@ -5,17 +5,27 @@ */ import { TaskRunnerFactory } from './task_runner'; -import { AlertTypeRegistry } from './alert_type_registry'; +import { AlertTypeRegistry, ConstructorOptions } from './alert_type_registry'; import { AlertType } from './types'; import { taskManagerMock } from '../../task_manager/server/mocks'; +import { ILicenseState } from './lib/license_state'; +import { licenseStateMock } from './lib/license_state.mock'; +import { licensingMock } from '../../licensing/server/mocks'; +let mockedLicenseState: jest.Mocked; +let alertTypeRegistryParams: ConstructorOptions; const taskManager = taskManagerMock.createSetup(); -const alertTypeRegistryParams = { - taskManager, - taskRunnerFactory: new TaskRunnerFactory(), -}; -beforeEach(() => jest.resetAllMocks()); +beforeEach(() => { + jest.resetAllMocks(); + mockedLicenseState = licenseStateMock.create(); + alertTypeRegistryParams = { + taskManager, + taskRunnerFactory: new TaskRunnerFactory(), + licenseState: mockedLicenseState, + licensing: licensingMock.createSetup(), + }; +}); describe('has()', () => { test('returns false for unregistered alert types', () => { @@ -35,6 +45,7 @@ describe('has()', () => { }, ], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', executor: jest.fn(), producer: 'alerts', }); @@ -44,7 +55,7 @@ describe('has()', () => { describe('register()', () => { test('throws if AlertType Id contains invalid characters', () => { - const alertType = { + const alertType: AlertType = { id: 'test', name: 'Test', actionGroups: [ @@ -54,6 +65,7 @@ describe('register()', () => { }, ], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', executor: jest.fn(), producer: 'alerts', }; @@ -75,7 +87,7 @@ describe('register()', () => { }); test('throws if AlertType Id isnt a string', () => { - const alertType = { + const alertType: AlertType = { id: (123 as unknown) as string, name: 'Test', actionGroups: [ @@ -85,6 +97,7 @@ describe('register()', () => { }, ], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', executor: jest.fn(), producer: 'alerts', }; @@ -96,7 +109,7 @@ describe('register()', () => { }); test('throws if AlertType action groups contains reserved group id', () => { - const alertType = { + const alertType: AlertType = { id: 'test', name: 'Test', actionGroups: [ @@ -110,6 +123,7 @@ describe('register()', () => { }, ], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', executor: jest.fn(), producer: 'alerts', }; @@ -123,7 +137,7 @@ describe('register()', () => { }); test('allows an AlertType to specify a custom recovery group', () => { - const alertType = { + const alertType: AlertType = { id: 'test', name: 'Test', actionGroups: [ @@ -139,6 +153,7 @@ describe('register()', () => { }, executor: jest.fn(), producer: 'alerts', + minimumLicenseRequired: 'basic', }; const registry = new AlertTypeRegistry(alertTypeRegistryParams); registry.register(alertType); @@ -157,7 +172,7 @@ describe('register()', () => { }); test('throws if the custom recovery group is contained in the AlertType action groups', () => { - const alertType = { + const alertType: AlertType = { id: 'test', name: 'Test', actionGroups: [ @@ -175,6 +190,7 @@ describe('register()', () => { name: 'Back To Awesome', }, defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', executor: jest.fn(), producer: 'alerts', }; @@ -188,7 +204,7 @@ describe('register()', () => { }); test('registers the executor with the task manager', () => { - const alertType = { + const alertType: AlertType = { id: 'test', name: 'Test', actionGroups: [ @@ -198,6 +214,7 @@ describe('register()', () => { }, ], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', executor: jest.fn(), producer: 'alerts', }; @@ -227,6 +244,7 @@ describe('register()', () => { }, ], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', executor: jest.fn(), producer: 'alerts', }; @@ -248,6 +266,7 @@ describe('register()', () => { }, ], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', executor: jest.fn(), producer: 'alerts', }); @@ -262,6 +281,7 @@ describe('register()', () => { }, ], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', executor: jest.fn(), producer: 'alerts', }) @@ -282,6 +302,7 @@ describe('get()', () => { }, ], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', executor: jest.fn(), producer: 'alerts', }); @@ -306,6 +327,7 @@ describe('get()', () => { "defaultActionGroupId": "default", "executor": [MockFunction], "id": "test", + "minimumLicenseRequired": "basic", "name": "Test", "producer": "alerts", "recoveryActionGroup": Object { @@ -343,6 +365,7 @@ describe('list()', () => { }, ], defaultActionGroupId: 'testActionGroup', + minimumLicenseRequired: 'basic', executor: jest.fn(), producer: 'alerts', }); @@ -366,7 +389,9 @@ describe('list()', () => { "state": Array [], }, "defaultActionGroupId": "testActionGroup", + "enabledInLicense": false, "id": "test", + "minimumLicenseRequired": "basic", "name": "Test", "producer": "alerts", "recoveryActionGroup": Object { @@ -413,12 +438,50 @@ describe('list()', () => { }); }); +describe('ensureAlertTypeEnabled', () => { + let alertTypeRegistry: AlertTypeRegistry; + + beforeEach(() => { + alertTypeRegistry = new AlertTypeRegistry(alertTypeRegistryParams); + alertTypeRegistry.register({ + id: 'test', + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + defaultActionGroupId: 'default', + executor: jest.fn(), + producer: 'alerts', + minimumLicenseRequired: 'basic', + recoveryActionGroup: { id: 'recovered', name: 'Recovered' }, + }); + }); + + test('should call ensureLicenseForAlertType on the license state', async () => { + alertTypeRegistry.ensureAlertTypeEnabled('test'); + expect(mockedLicenseState.ensureLicenseForAlertType).toHaveBeenCalled(); + }); + + test('should throw when ensureLicenseForAlertType throws', async () => { + mockedLicenseState.ensureLicenseForAlertType.mockImplementation(() => { + throw new Error('Fail'); + }); + expect(() => + alertTypeRegistry.ensureAlertTypeEnabled('test') + ).toThrowErrorMatchingInlineSnapshot(`"Fail"`); + }); +}); + function alertTypeWithVariables(id: string, context: string, state: string): AlertType { - const baseAlert = { + const baseAlert: AlertType = { id, name: `${id}-name`, actionGroups: [], defaultActionGroupId: id, + minimumLicenseRequired: 'basic', async executor() {}, producer: 'alerts', }; diff --git a/x-pack/plugins/alerts/server/alert_type_registry.ts b/x-pack/plugins/alerts/server/alert_type_registry.ts index a3e80fbd6c11a..d436d1987c027 100644 --- a/x-pack/plugins/alerts/server/alert_type_registry.ts +++ b/x-pack/plugins/alerts/server/alert_type_registry.ts @@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; import typeDetect from 'type-detect'; import { intersection } from 'lodash'; +import { LicensingPluginSetup } from '../../licensing/server'; import { RunContext, TaskManagerSetupContract } from '../../task_manager/server'; import { TaskRunnerFactory } from './task_runner'; import { @@ -19,10 +20,14 @@ import { AlertInstanceContext, } from './types'; import { RecoveredActionGroup, getBuiltinActionGroups } from '../common'; +import { ILicenseState } from './lib/license_state'; +import { getAlertTypeFeatureUsageName } from './lib/get_alert_type_feature_usage_name'; -interface ConstructorOptions { +export interface ConstructorOptions { taskManager: TaskManagerSetupContract; taskRunnerFactory: TaskRunnerFactory; + licenseState: ILicenseState; + licensing: LicensingPluginSetup; } export interface RegistryAlertType @@ -34,8 +39,10 @@ export interface RegistryAlertType | 'defaultActionGroupId' | 'actionVariables' | 'producer' + | 'minimumLicenseRequired' > { id: string; + enabledInLicense: boolean; } /** @@ -70,16 +77,24 @@ export class AlertTypeRegistry { private readonly taskManager: TaskManagerSetupContract; private readonly alertTypes: Map = new Map(); private readonly taskRunnerFactory: TaskRunnerFactory; + private readonly licenseState: ILicenseState; + private readonly licensing: LicensingPluginSetup; - constructor({ taskManager, taskRunnerFactory }: ConstructorOptions) { + constructor({ taskManager, taskRunnerFactory, licenseState, licensing }: ConstructorOptions) { this.taskManager = taskManager; this.taskRunnerFactory = taskRunnerFactory; + this.licenseState = licenseState; + this.licensing = licensing; } public has(id: string) { return this.alertTypes.has(id); } + public ensureAlertTypeEnabled(id: string) { + this.licenseState.ensureLicenseForAlertType(this.get(id)); + } + public register< Params extends AlertTypeParams = AlertTypeParams, State extends AlertTypeState = AlertTypeState, @@ -108,6 +123,13 @@ export class AlertTypeRegistry { this.taskRunnerFactory.create(normalizedAlertType, context), }, }); + // No need to notify usage on basic alert types + if (alertType.minimumLicenseRequired !== 'basic') { + this.licensing.featureUsage.register( + getAlertTypeFeatureUsageName(alertType.name), + alertType.minimumLicenseRequired + ); + } } public get< @@ -146,6 +168,7 @@ export class AlertTypeRegistry { defaultActionGroupId, actionVariables, producer, + minimumLicenseRequired, }, ]: [string, NormalizedAlertType]) => ({ id, @@ -155,6 +178,12 @@ export class AlertTypeRegistry { defaultActionGroupId, actionVariables, producer, + minimumLicenseRequired, + enabledInLicense: !!this.licenseState.getLicenseCheckForAlertType( + id, + name, + minimumLicenseRequired + ).isValid, }) ) ); diff --git a/x-pack/plugins/alerts/server/alerts_client/alerts_client.ts b/x-pack/plugins/alerts/server/alerts_client/alerts_client.ts index b1696696b3044..095823952722b 100644 --- a/x-pack/plugins/alerts/server/alerts_client/alerts_client.ts +++ b/x-pack/plugins/alerts/server/alerts_client/alerts_client.ts @@ -242,6 +242,8 @@ export class AlertsClient { throw error; } + this.alertTypeRegistry.ensureAlertTypeEnabled(data.alertTypeId); + // Throws an error if alert type isn't registered const alertType = this.alertTypeRegistry.get(data.alertTypeId); @@ -653,6 +655,8 @@ export class AlertsClient { }) ); + this.alertTypeRegistry.ensureAlertTypeEnabled(alertSavedObject.attributes.alertTypeId); + const updateResult = await this.updateAlert({ id, data }, alertSavedObject); await Promise.all([ @@ -830,6 +834,8 @@ export class AlertsClient { }) ); + this.alertTypeRegistry.ensureAlertTypeEnabled(attributes.alertTypeId); + try { await this.unsecuredSavedObjectsClient.update('alert', id, updateAttributes, { version }); } catch (e) { @@ -913,6 +919,8 @@ export class AlertsClient { }) ); + this.alertTypeRegistry.ensureAlertTypeEnabled(attributes.alertTypeId); + if (attributes.enabled === false) { const username = await this.getUserName(); const updateAttributes = this.updateMeta({ @@ -1012,6 +1020,8 @@ export class AlertsClient { }) ); + this.alertTypeRegistry.ensureAlertTypeEnabled(attributes.alertTypeId); + if (attributes.enabled === true) { await this.unsecuredSavedObjectsClient.update( 'alert', @@ -1086,6 +1096,8 @@ export class AlertsClient { }) ); + this.alertTypeRegistry.ensureAlertTypeEnabled(attributes.alertTypeId); + const updateAttributes = this.updateMeta({ muteAll: true, mutedInstanceIds: [], @@ -1145,6 +1157,8 @@ export class AlertsClient { }) ); + this.alertTypeRegistry.ensureAlertTypeEnabled(attributes.alertTypeId); + const updateAttributes = this.updateMeta({ muteAll: false, mutedInstanceIds: [], @@ -1204,6 +1218,8 @@ export class AlertsClient { }) ); + this.alertTypeRegistry.ensureAlertTypeEnabled(attributes.alertTypeId); + const mutedInstanceIds = attributes.mutedInstanceIds || []; if (!attributes.muteAll && !mutedInstanceIds.includes(alertInstanceId)) { mutedInstanceIds.push(alertInstanceId); @@ -1268,6 +1284,8 @@ export class AlertsClient { }) ); + this.alertTypeRegistry.ensureAlertTypeEnabled(attributes.alertTypeId); + const mutedInstanceIds = attributes.mutedInstanceIds || []; if (!attributes.muteAll && mutedInstanceIds.includes(alertInstanceId)) { await this.unsecuredSavedObjectsClient.update( diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/aggregate.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/aggregate.test.ts index b21e3dcdf563d..81b095c013e71 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/aggregate.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/aggregate.test.ts @@ -15,6 +15,7 @@ import { ActionsAuthorization } from '../../../../actions/server'; import { getBeforeSetup, setGlobalDate } from './lib'; import { AlertExecutionStatusValues } from '../../types'; import { RecoveredActionGroup } from '../../../common'; +import { RegistryAlertType } from '../../alert_type_registry'; const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); @@ -49,15 +50,17 @@ beforeEach(() => { setGlobalDate(); describe('aggregate()', () => { - const listedTypes = new Set([ + const listedTypes = new Set([ { actionGroups: [], actionVariables: undefined, defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, id: 'myType', name: 'myType', producer: 'myApp', + enabledInLicense: true, }, ]); beforeEach(() => { @@ -104,11 +107,13 @@ describe('aggregate()', () => { name: 'Test', actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, producer: 'alerts', authorizedConsumers: { myApp: { read: true, all: true }, }, + enabledInLicense: true, }, ]) ); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts index 4e273ee3a9e44..5f830a6c5bc51 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts @@ -1186,6 +1186,7 @@ describe('create()', () => { threshold: schema.number({ min: 0, max: 1 }), }), }, + minimumLicenseRequired: 'basic', async executor() {}, producer: 'alerts', }); @@ -1622,4 +1623,14 @@ describe('create()', () => { } ); }); + + test('throws error when ensureActionTypeEnabled throws', async () => { + const data = getMockData(); + alertTypeRegistry.ensureAlertTypeEnabled.mockImplementation(() => { + throw new Error('Fail'); + }); + await expect(alertsClient.create({ data })).rejects.toThrowErrorMatchingInlineSnapshot( + `"Fail"` + ); + }); }); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts index ff64150dc2b79..0efc8782e84c0 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts @@ -18,6 +18,7 @@ import { httpServerMock } from '../../../../../../src/core/server/mocks'; import { auditServiceMock } from '../../../../security/server/audit/index.mock'; import { getBeforeSetup, setGlobalDate } from './lib'; import { RecoveredActionGroup } from '../../../common'; +import { RegistryAlertType } from '../../alert_type_registry'; const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); @@ -53,15 +54,17 @@ beforeEach(() => { setGlobalDate(); describe('find()', () => { - const listedTypes = new Set([ + const listedTypes = new Set([ { actionGroups: [], recoveryActionGroup: RecoveredActionGroup, actionVariables: undefined, defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', id: 'myType', name: 'myType', producer: 'myApp', + enabledInLicense: true, }, ]); beforeEach(() => { @@ -116,10 +119,12 @@ describe('find()', () => { actionGroups: [{ id: 'default', name: 'Default' }], recoveryActionGroup: RecoveredActionGroup, defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', producer: 'alerts', authorizedConsumers: { myApp: { read: true, all: true }, }, + enabledInLicense: true, }, ]) ); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/lib.ts b/x-pack/plugins/alerts/server/alerts_client/tests/lib.ts index 8f692cf548a9a..9be1e39fb3e05 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/lib.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/lib.ts @@ -85,6 +85,7 @@ export function getBeforeSetup( actionGroups: [{ id: 'default', name: 'Default' }], recoveryActionGroup: RecoveredActionGroup, defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', async executor() {}, producer: 'alerts', })); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/list_alert_types.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/list_alert_types.test.ts index f3521965d615d..ddb4778821905 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/list_alert_types.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/list_alert_types.test.ts @@ -10,10 +10,14 @@ import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; import { alertsAuthorizationMock } from '../../authorization/alerts_authorization.mock'; import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/server/mocks'; import { actionsAuthorizationMock } from '../../../../actions/server/mocks'; -import { AlertsAuthorization } from '../../authorization/alerts_authorization'; +import { + AlertsAuthorization, + RegistryAlertTypeWithAuth, +} from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; import { getBeforeSetup } from './lib'; import { RecoveredActionGroup } from '../../../common'; +import { RegistryAlertType } from '../../alert_type_registry'; const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); @@ -47,23 +51,27 @@ beforeEach(() => { describe('listAlertTypes', () => { let alertsClient: AlertsClient; - const alertingAlertType = { + const alertingAlertType: RegistryAlertType = { actionGroups: [], actionVariables: undefined, defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, id: 'alertingAlertType', name: 'alertingAlertType', producer: 'alerts', + enabledInLicense: true, }; - const myAppAlertType = { + const myAppAlertType: RegistryAlertType = { actionGroups: [], actionVariables: undefined, defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, id: 'myAppAlertType', name: 'myAppAlertType', producer: 'myApp', + enabledInLicense: true, }; const setOfAlertTypes = new Set([myAppAlertType, alertingAlertType]); @@ -80,7 +88,7 @@ describe('listAlertTypes', () => { test('should return a list of AlertTypes that exist in the registry', async () => { alertTypeRegistry.list.mockReturnValue(setOfAlertTypes); authorization.filterByAlertTypeAuthorization.mockResolvedValue( - new Set([ + new Set([ { ...myAppAlertType, authorizedConsumers }, { ...alertingAlertType, authorizedConsumers }, ]) @@ -94,23 +102,27 @@ describe('listAlertTypes', () => { }); describe('authorization', () => { - const listedTypes = new Set([ + const listedTypes = new Set([ { actionGroups: [], actionVariables: undefined, defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, id: 'myType', name: 'myType', producer: 'myApp', + enabledInLicense: true, }, { id: 'myOtherType', name: 'Test', actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, producer: 'alerts', + enabledInLicense: true, }, ]); beforeEach(() => { @@ -118,17 +130,19 @@ describe('listAlertTypes', () => { }); test('should return a list of AlertTypes that exist in the registry only if the user is authorised to get them', async () => { - const authorizedTypes = new Set([ + const authorizedTypes = new Set([ { id: 'myType', name: 'Test', actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, producer: 'alerts', authorizedConsumers: { myApp: { read: true, all: true }, }, + enabledInLicense: true, }, ]); authorization.filterByAlertTypeAuthorization.mockResolvedValue(authorizedTypes); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts index 42cec57b555de..3396a9c73e367 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts @@ -103,6 +103,7 @@ describe('update()', () => { name: 'Test', actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, async executor() {}, producer: 'alerts', @@ -695,6 +696,7 @@ describe('update()', () => { name: 'Test', actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, validate: { params: schema.object({ @@ -1045,6 +1047,7 @@ describe('update()', () => { name: 'Test', actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, async executor() {}, producer: 'alerts', diff --git a/x-pack/plugins/alerts/server/alerts_client_conflict_retries.test.ts b/x-pack/plugins/alerts/server/alerts_client_conflict_retries.test.ts index aaa70a2594a5e..c5fba397bdb8e 100644 --- a/x-pack/plugins/alerts/server/alerts_client_conflict_retries.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client_conflict_retries.test.ts @@ -333,6 +333,7 @@ beforeEach(() => { name: 'Test', actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, async executor() {}, producer: 'alerts', @@ -343,6 +344,7 @@ beforeEach(() => { name: 'Test', actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, async executor() {}, producer: 'alerts', diff --git a/x-pack/plugins/alerts/server/authorization/alerts_authorization.test.ts b/x-pack/plugins/alerts/server/authorization/alerts_authorization.test.ts index ccc325d468c54..a7d9421073483 100644 --- a/x-pack/plugins/alerts/server/authorization/alerts_authorization.test.ts +++ b/x-pack/plugins/alerts/server/authorization/alerts_authorization.test.ts @@ -17,6 +17,7 @@ import { alertsAuthorizationAuditLoggerMock } from './audit_logger.mock'; import { AlertsAuthorizationAuditLogger, AuthorizationResult } from './audit_logger'; import uuid from 'uuid'; import { RecoveredActionGroup } from '../../common'; +import { RegistryAlertType } from '../alert_type_registry'; const alertTypeRegistry = alertTypeRegistryMock.create(); const features: jest.Mocked = featuresPluginMock.createStart(); @@ -173,6 +174,7 @@ beforeEach(() => { name: 'My Alert Type', actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, async executor() {}, producer: 'myApp', @@ -532,32 +534,38 @@ describe('AlertsAuthorization', () => { }); describe('getFindAuthorizationFilter', () => { - const myOtherAppAlertType = { + const myOtherAppAlertType: RegistryAlertType = { actionGroups: [], actionVariables: undefined, defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, id: 'myOtherAppAlertType', name: 'myOtherAppAlertType', producer: 'alerts', + enabledInLicense: true, }; - const myAppAlertType = { + const myAppAlertType: RegistryAlertType = { actionGroups: [], actionVariables: undefined, defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, id: 'myAppAlertType', name: 'myAppAlertType', producer: 'myApp', + enabledInLicense: true, }; - const mySecondAppAlertType = { + const mySecondAppAlertType: RegistryAlertType = { actionGroups: [], actionVariables: undefined, defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, id: 'mySecondAppAlertType', name: 'mySecondAppAlertType', producer: 'myApp', + enabledInLicense: true, }; const setOfAlertTypes = new Set([myAppAlertType, myOtherAppAlertType, mySecondAppAlertType]); @@ -825,23 +833,27 @@ describe('AlertsAuthorization', () => { }); describe('filterByAlertTypeAuthorization', () => { - const myOtherAppAlertType = { + const myOtherAppAlertType: RegistryAlertType = { actionGroups: [], actionVariables: undefined, defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, id: 'myOtherAppAlertType', name: 'myOtherAppAlertType', producer: 'myOtherApp', + enabledInLicense: true, }; - const myAppAlertType = { + const myAppAlertType: RegistryAlertType = { actionGroups: [], actionVariables: undefined, defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, id: 'myAppAlertType', name: 'myAppAlertType', producer: 'myApp', + enabledInLicense: true, }; const setOfAlertTypes = new Set([myAppAlertType, myOtherAppAlertType]); @@ -884,7 +896,9 @@ describe('AlertsAuthorization', () => { }, }, "defaultActionGroupId": "default", + "enabledInLicense": true, "id": "myAppAlertType", + "minimumLicenseRequired": "basic", "name": "myAppAlertType", "producer": "myApp", "recoveryActionGroup": Object { @@ -914,7 +928,9 @@ describe('AlertsAuthorization', () => { }, }, "defaultActionGroupId": "default", + "enabledInLicense": true, "id": "myOtherAppAlertType", + "minimumLicenseRequired": "basic", "name": "myOtherAppAlertType", "producer": "myOtherApp", "recoveryActionGroup": Object { @@ -984,7 +1000,9 @@ describe('AlertsAuthorization', () => { }, }, "defaultActionGroupId": "default", + "enabledInLicense": true, "id": "myOtherAppAlertType", + "minimumLicenseRequired": "basic", "name": "myOtherAppAlertType", "producer": "myOtherApp", "recoveryActionGroup": Object { @@ -1010,7 +1028,9 @@ describe('AlertsAuthorization', () => { }, }, "defaultActionGroupId": "default", + "enabledInLicense": true, "id": "myAppAlertType", + "minimumLicenseRequired": "basic", "name": "myAppAlertType", "producer": "myApp", "recoveryActionGroup": Object { @@ -1075,7 +1095,9 @@ describe('AlertsAuthorization', () => { }, }, "defaultActionGroupId": "default", + "enabledInLicense": true, "id": "myAppAlertType", + "minimumLicenseRequired": "basic", "name": "myAppAlertType", "producer": "myApp", "recoveryActionGroup": Object { @@ -1169,7 +1191,9 @@ describe('AlertsAuthorization', () => { }, }, "defaultActionGroupId": "default", + "enabledInLicense": true, "id": "myOtherAppAlertType", + "minimumLicenseRequired": "basic", "name": "myOtherAppAlertType", "producer": "myOtherApp", "recoveryActionGroup": Object { @@ -1195,7 +1219,9 @@ describe('AlertsAuthorization', () => { }, }, "defaultActionGroupId": "default", + "enabledInLicense": true, "id": "myAppAlertType", + "minimumLicenseRequired": "basic", "name": "myAppAlertType", "producer": "myApp", "recoveryActionGroup": Object { @@ -1273,7 +1299,9 @@ describe('AlertsAuthorization', () => { }, }, "defaultActionGroupId": "default", + "enabledInLicense": true, "id": "myOtherAppAlertType", + "minimumLicenseRequired": "basic", "name": "myOtherAppAlertType", "producer": "myOtherApp", "recoveryActionGroup": Object { diff --git a/x-pack/plugins/alerts/server/authorization/alerts_authorization_kuery.test.ts b/x-pack/plugins/alerts/server/authorization/alerts_authorization_kuery.test.ts index 4be52f12da9c7..8249047c0ef39 100644 --- a/x-pack/plugins/alerts/server/authorization/alerts_authorization_kuery.test.ts +++ b/x-pack/plugins/alerts/server/authorization/alerts_authorization_kuery.test.ts @@ -22,9 +22,11 @@ describe('asFiltersByAlertTypeAndConsumer', () => { id: 'myAppAlertType', name: 'myAppAlertType', producer: 'myApp', + minimumLicenseRequired: 'basic', authorizedConsumers: { myApp: { read: true, all: true }, }, + enabledInLicense: true, }, ]) ) @@ -42,6 +44,7 @@ describe('asFiltersByAlertTypeAndConsumer', () => { { actionGroups: [], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, id: 'myAppAlertType', name: 'myAppAlertType', @@ -51,6 +54,7 @@ describe('asFiltersByAlertTypeAndConsumer', () => { myApp: { read: true, all: true }, myOtherApp: { read: true, all: true }, }, + enabledInLicense: true, }, ]) ) @@ -68,6 +72,7 @@ describe('asFiltersByAlertTypeAndConsumer', () => { { actionGroups: [], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, id: 'myAppAlertType', name: 'myAppAlertType', @@ -78,10 +83,12 @@ describe('asFiltersByAlertTypeAndConsumer', () => { myOtherApp: { read: true, all: true }, myAppWithSubFeature: { read: true, all: true }, }, + enabledInLicense: true, }, { actionGroups: [], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, id: 'myOtherAppAlertType', name: 'myOtherAppAlertType', @@ -92,10 +99,12 @@ describe('asFiltersByAlertTypeAndConsumer', () => { myOtherApp: { read: true, all: true }, myAppWithSubFeature: { read: true, all: true }, }, + enabledInLicense: true, }, { actionGroups: [], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, id: 'mySecondAppAlertType', name: 'mySecondAppAlertType', @@ -106,6 +115,7 @@ describe('asFiltersByAlertTypeAndConsumer', () => { myOtherApp: { read: true, all: true }, myAppWithSubFeature: { read: true, all: true }, }, + enabledInLicense: true, }, ]) ) diff --git a/x-pack/plugins/alerts/server/lib/errors/alert_type_disabled.ts b/x-pack/plugins/alerts/server/lib/errors/alert_type_disabled.ts new file mode 100644 index 0000000000000..9a8ebc61118c3 --- /dev/null +++ b/x-pack/plugins/alerts/server/lib/errors/alert_type_disabled.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 { KibanaResponseFactory } from '../../../../../../src/core/server'; +import { ErrorThatHandlesItsOwnResponse } from './types'; + +export type AlertTypeDisabledReason = + | 'config' + | 'license_unavailable' + | 'license_invalid' + | 'license_expired'; + +export class AlertTypeDisabledError extends Error implements ErrorThatHandlesItsOwnResponse { + public readonly reason: AlertTypeDisabledReason; + + constructor(message: string, reason: AlertTypeDisabledReason) { + super(message); + this.reason = reason; + } + + public sendResponse(res: KibanaResponseFactory) { + return res.forbidden({ body: { message: this.message } }); + } +} diff --git a/x-pack/plugins/alerts/server/lib/errors/types.ts b/x-pack/plugins/alerts/server/lib/errors/types.ts new file mode 100644 index 0000000000000..949dc348265ae --- /dev/null +++ b/x-pack/plugins/alerts/server/lib/errors/types.ts @@ -0,0 +1,11 @@ +/* + * 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 { KibanaResponseFactory, IKibanaResponse } from '../../../../../../src/core/server'; + +export interface ErrorThatHandlesItsOwnResponse extends Error { + sendResponse(res: KibanaResponseFactory): IKibanaResponse; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/constants.ts b/x-pack/plugins/alerts/server/lib/get_alert_type_feature_usage_name.ts similarity index 69% rename from x-pack/plugins/actions/server/builtin_action_types/case/constants.ts rename to x-pack/plugins/alerts/server/lib/get_alert_type_feature_usage_name.ts index 1f2bc7f5e8e53..cd7c89d391e9b 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/case/constants.ts +++ b/x-pack/plugins/alerts/server/lib/get_alert_type_feature_usage_name.ts @@ -4,4 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export const SUPPORTED_SOURCE_FIELDS = ['title', 'comments', 'description']; +export function getAlertTypeFeatureUsageName(alertTypeName: string) { + return `Alert: ${alertTypeName}`; +} diff --git a/x-pack/plugins/alerts/server/lib/license_api_access.ts b/x-pack/plugins/alerts/server/lib/license_api_access.ts index f9ef51f6b3c9a..ddbcaa90dcee1 100644 --- a/x-pack/plugins/alerts/server/lib/license_api_access.ts +++ b/x-pack/plugins/alerts/server/lib/license_api_access.ts @@ -5,9 +5,9 @@ */ import Boom from '@hapi/boom'; -import { LicenseState } from './license_state'; +import { ILicenseState } from './license_state'; -export function verifyApiAccess(licenseState: LicenseState) { +export function verifyApiAccess(licenseState: ILicenseState) { const licenseCheckResults = licenseState.getLicenseInformation(); if (licenseCheckResults.showAppLink && licenseCheckResults.enableAppLink) { diff --git a/x-pack/plugins/alerts/server/lib/license_state.mock.ts b/x-pack/plugins/alerts/server/lib/license_state.mock.ts index aaccbfcc0af0e..0bab8e65af168 100644 --- a/x-pack/plugins/alerts/server/lib/license_state.mock.ts +++ b/x-pack/plugins/alerts/server/lib/license_state.mock.ts @@ -4,35 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { of } from 'rxjs'; -import { LicenseState } from './license_state'; -import { ILicense } from '../../../licensing/server'; +import { ILicenseState } from './license_state'; -export const mockLicenseState = () => { - const license: ILicense = { - uid: '123', - status: 'active', - isActive: true, - signature: 'sig', - isAvailable: true, - toJSON: () => ({ - signature: 'sig', +export const createLicenseStateMock = () => { + const licenseState: jest.Mocked = { + clean: jest.fn(), + getLicenseInformation: jest.fn(), + ensureLicenseForAlertType: jest.fn(), + getLicenseCheckForAlertType: jest.fn().mockResolvedValue({ + isValid: true, }), - getUnavailableReason: () => undefined, - hasAtLeast() { - return true; - }, - check() { - return { - state: 'valid', - }; - }, - getFeature() { - return { - isAvailable: true, - isEnabled: true, - }; - }, + checkLicense: jest.fn().mockResolvedValue({ + state: 'valid', + }), + setNotifyUsage: jest.fn(), }; - return new LicenseState(of(license)); + return licenseState; +}; + +export const licenseStateMock = { + create: createLicenseStateMock, }; diff --git a/x-pack/plugins/alerts/server/lib/license_state.test.ts b/x-pack/plugins/alerts/server/lib/license_state.test.ts index 50b4e6b4439f7..94db4c946ab00 100644 --- a/x-pack/plugins/alerts/server/lib/license_state.test.ts +++ b/x-pack/plugins/alerts/server/lib/license_state.test.ts @@ -4,11 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; -import { LicenseState } from './license_state'; +import { AlertType } from '../types'; +import { Subject } from 'rxjs'; +import { LicenseState, ILicenseState } from './license_state'; import { licensingMock } from '../../../licensing/server/mocks'; +import { ILicense } from '../../../licensing/server'; -describe('license_state', () => { +describe('checkLicense()', () => { const getRawLicense = jest.fn(); beforeEach(() => { @@ -27,8 +29,8 @@ describe('license_state', () => { it('check application link should be disabled', () => { const licensing = licensingMock.createSetup(); const licenseState = new LicenseState(licensing.license$); - const alertingLicenseInfo = licenseState.checkLicense(getRawLicense()); - expect(alertingLicenseInfo.enableAppLink).to.be(false); + const actionsLicenseInfo = licenseState.checkLicense(getRawLicense()); + expect(actionsLicenseInfo.enableAppLink).toBe(false); }); }); @@ -44,8 +46,231 @@ describe('license_state', () => { it('check application link should be enabled', () => { const licensing = licensingMock.createSetup(); const licenseState = new LicenseState(licensing.license$); - const alertingLicenseInfo = licenseState.checkLicense(getRawLicense()); - expect(alertingLicenseInfo.enableAppLink).to.be(true); + const actionsLicenseInfo = licenseState.checkLicense(getRawLicense()); + expect(actionsLicenseInfo.showAppLink).toBe(true); }); }); }); + +describe('getLicenseCheckForAlertType', () => { + let license: Subject; + let licenseState: ILicenseState; + const mockNotifyUsage = jest.fn(); + const alertType: AlertType = { + id: 'test', + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + defaultActionGroupId: 'default', + executor: jest.fn(), + producer: 'alerts', + minimumLicenseRequired: 'gold', + recoveryActionGroup: { id: 'recovered', name: 'Recovered' }, + }; + + beforeEach(() => { + license = new Subject(); + licenseState = new LicenseState(license); + licenseState.setNotifyUsage(mockNotifyUsage); + }); + + test('should return false when license not defined', () => { + expect( + licenseState.getLicenseCheckForAlertType( + alertType.id, + alertType.name, + alertType.minimumLicenseRequired + ) + ).toEqual({ + isValid: false, + reason: 'unavailable', + }); + }); + + test('should return false when license not available', () => { + license.next(createUnavailableLicense()); + expect( + licenseState.getLicenseCheckForAlertType( + alertType.id, + alertType.name, + alertType.minimumLicenseRequired + ) + ).toEqual({ + isValid: false, + reason: 'unavailable', + }); + }); + + test('should return false when license is expired', () => { + const expiredLicense = licensingMock.createLicense({ license: { status: 'expired' } }); + license.next(expiredLicense); + expect( + licenseState.getLicenseCheckForAlertType( + alertType.id, + alertType.name, + alertType.minimumLicenseRequired + ) + ).toEqual({ + isValid: false, + reason: 'expired', + }); + }); + + test('should return false when license is invalid', () => { + const basicLicense = licensingMock.createLicense({ + license: { status: 'active', type: 'basic' }, + }); + license.next(basicLicense); + expect( + licenseState.getLicenseCheckForAlertType( + alertType.id, + alertType.name, + alertType.minimumLicenseRequired + ) + ).toEqual({ + isValid: false, + reason: 'invalid', + }); + }); + + test('should return true when license is valid', () => { + const goldLicense = licensingMock.createLicense({ + license: { status: 'active', type: 'gold' }, + }); + license.next(goldLicense); + expect( + licenseState.getLicenseCheckForAlertType( + alertType.id, + alertType.name, + alertType.minimumLicenseRequired + ) + ).toEqual({ + isValid: true, + }); + }); + + test('should not call notifyUsage by default', () => { + const goldLicense = licensingMock.createLicense({ + license: { status: 'active', type: 'gold' }, + }); + license.next(goldLicense); + licenseState.getLicenseCheckForAlertType(alertType.id, alertType.name, 'gold'); + expect(mockNotifyUsage).not.toHaveBeenCalled(); + }); + + test('should not call notifyUsage on basic action types', () => { + const basicLicense = licensingMock.createLicense({ + license: { status: 'active', type: 'basic' }, + }); + license.next(basicLicense); + licenseState.getLicenseCheckForAlertType(alertType.id, alertType.name, 'basic'); + expect(mockNotifyUsage).not.toHaveBeenCalled(); + }); + + test('should call notifyUsage when specified', () => { + const goldLicense = licensingMock.createLicense({ + license: { status: 'active', type: 'gold' }, + }); + license.next(goldLicense); + licenseState.getLicenseCheckForAlertType( + alertType.id, + alertType.name, + alertType.minimumLicenseRequired, + { notifyUsage: true } + ); + expect(mockNotifyUsage).toHaveBeenCalledWith('Alert: Test'); + }); +}); + +describe('ensureLicenseForAlertType()', () => { + let license: Subject; + let licenseState: ILicenseState; + const mockNotifyUsage = jest.fn(); + const alertType: AlertType = { + id: 'test', + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + defaultActionGroupId: 'default', + executor: jest.fn(), + producer: 'alerts', + minimumLicenseRequired: 'gold', + recoveryActionGroup: { id: 'recovered', name: 'Recovered' }, + }; + + beforeEach(() => { + license = new Subject(); + licenseState = new LicenseState(license); + licenseState.setNotifyUsage(mockNotifyUsage); + }); + + test('should throw when license not defined', () => { + expect(() => + licenseState.ensureLicenseForAlertType(alertType) + ).toThrowErrorMatchingInlineSnapshot( + `"Alert type test is disabled because license information is not available at this time."` + ); + }); + + test('should throw when license not available', () => { + license.next(createUnavailableLicense()); + expect(() => + licenseState.ensureLicenseForAlertType(alertType) + ).toThrowErrorMatchingInlineSnapshot( + `"Alert type test is disabled because license information is not available at this time."` + ); + }); + + test('should throw when license is expired', () => { + const expiredLicense = licensingMock.createLicense({ license: { status: 'expired' } }); + license.next(expiredLicense); + expect(() => + licenseState.ensureLicenseForAlertType(alertType) + ).toThrowErrorMatchingInlineSnapshot( + `"Alert type test is disabled because your basic license has expired."` + ); + }); + + test('should throw when license is invalid', () => { + const basicLicense = licensingMock.createLicense({ + license: { status: 'active', type: 'basic' }, + }); + license.next(basicLicense); + expect(() => + licenseState.ensureLicenseForAlertType(alertType) + ).toThrowErrorMatchingInlineSnapshot( + `"Alert test is disabled because it requires a Gold license. Contact your administrator to upgrade your license."` + ); + }); + + test('should not throw when license is valid', () => { + const goldLicense = licensingMock.createLicense({ + license: { status: 'active', type: 'gold' }, + }); + license.next(goldLicense); + licenseState.ensureLicenseForAlertType(alertType); + }); + + test('should call notifyUsage', () => { + const goldLicense = licensingMock.createLicense({ + license: { status: 'active', type: 'gold' }, + }); + license.next(goldLicense); + licenseState.ensureLicenseForAlertType(alertType); + expect(mockNotifyUsage).toHaveBeenCalledWith('Alert: Test'); + }); +}); + +function createUnavailableLicense() { + const unavailableLicense = licensingMock.createLicenseMock(); + unavailableLicense.isAvailable = false; + return unavailableLicense; +} diff --git a/x-pack/plugins/alerts/server/lib/license_state.ts b/x-pack/plugins/alerts/server/lib/license_state.ts index ead6b743f1719..dea5b3338a5be 100644 --- a/x-pack/plugins/alerts/server/lib/license_state.ts +++ b/x-pack/plugins/alerts/server/lib/license_state.ts @@ -6,10 +6,17 @@ import Boom from '@hapi/boom'; import { i18n } from '@kbn/i18n'; +import type { PublicMethodsOf } from '@kbn/utility-types'; import { assertNever } from '@kbn/std'; import { Observable, Subscription } from 'rxjs'; -import { ILicense } from '../../../licensing/common/types'; +import { LicensingPluginStart } from '../../../licensing/server'; +import { ILicense, LicenseType } from '../../../licensing/common/types'; import { PLUGIN } from '../constants/plugin'; +import { getAlertTypeFeatureUsageName } from './get_alert_type_feature_usage_name'; +import { AlertType } from '../types'; +import { AlertTypeDisabledError } from './errors/alert_type_disabled'; + +export type ILicenseState = PublicMethodsOf; export interface AlertingLicenseInformation { showAppLink: boolean; @@ -20,12 +27,15 @@ export interface AlertingLicenseInformation { export class LicenseState { private licenseInformation: AlertingLicenseInformation = this.checkLicense(undefined); private subscription: Subscription; + private license?: ILicense; + private _notifyUsage: LicensingPluginStart['featureUsage']['notifyUsage'] | null = null; constructor(license$: Observable) { this.subscription = license$.subscribe(this.updateInformation.bind(this)); } private updateInformation(license: ILicense | undefined) { + this.license = license; this.licenseInformation = this.checkLicense(license); } @@ -37,6 +47,47 @@ export class LicenseState { return this.licenseInformation; } + public setNotifyUsage(notifyUsage: LicensingPluginStart['featureUsage']['notifyUsage']) { + this._notifyUsage = notifyUsage; + } + + public getLicenseCheckForAlertType( + alertTypeId: string, + alertTypeName: string, + minimumLicenseRequired: LicenseType, + { notifyUsage }: { notifyUsage: boolean } = { notifyUsage: false } + ): { isValid: true } | { isValid: false; reason: 'unavailable' | 'expired' | 'invalid' } { + if (notifyUsage) { + this.notifyUsage(alertTypeName, minimumLicenseRequired); + } + + if (!this.license?.isAvailable) { + return { isValid: false, reason: 'unavailable' }; + } + + const check = this.license.check(alertTypeId, minimumLicenseRequired); + + switch (check.state) { + case 'expired': + return { isValid: false, reason: 'expired' }; + case 'invalid': + return { isValid: false, reason: 'invalid' }; + case 'unavailable': + return { isValid: false, reason: 'unavailable' }; + case 'valid': + return { isValid: true }; + default: + return assertNever(check.state); + } + } + + private notifyUsage(alertTypeName: string, minimumLicenseRequired: LicenseType) { + // No need to notify usage on basic alert types + if (this._notifyUsage && minimumLicenseRequired !== 'basic') { + this._notifyUsage(getAlertTypeFeatureUsageName(alertTypeName)); + } + } + public checkLicense(license: ILicense | undefined): AlertingLicenseInformation { if (!license || !license.isAvailable) { return { @@ -78,6 +129,53 @@ export class LicenseState { return assertNever(check.state); } } + + public ensureLicenseForAlertType(alertType: AlertType) { + this.notifyUsage(alertType.name, alertType.minimumLicenseRequired); + + const check = this.getLicenseCheckForAlertType( + alertType.id, + alertType.name, + alertType.minimumLicenseRequired + ); + + if (check.isValid) { + return; + } + switch (check.reason) { + case 'unavailable': + throw new AlertTypeDisabledError( + i18n.translate('xpack.alerts.serverSideErrors.unavailableLicenseErrorMessage', { + defaultMessage: + 'Alert type {alertTypeId} is disabled because license information is not available at this time.', + values: { + alertTypeId: alertType.id, + }, + }), + 'license_unavailable' + ); + case 'expired': + throw new AlertTypeDisabledError( + i18n.translate('xpack.alerts.serverSideErrors.expirerdLicenseErrorMessage', { + defaultMessage: + 'Alert type {alertTypeId} is disabled because your {licenseType} license has expired.', + values: { alertTypeId: alertType.id, licenseType: this.license!.type }, + }), + 'license_expired' + ); + case 'invalid': + throw new AlertTypeDisabledError( + i18n.translate('xpack.alerts.serverSideErrors.invalidLicenseErrorMessage', { + defaultMessage: + 'Alert {alertTypeId} is disabled because it requires a Gold license. Contact your administrator to upgrade your license.', + values: { alertTypeId: alertType.id }, + }), + 'license_invalid' + ); + default: + assertNever(check.reason); + } + } } export function verifyApiAccessFactory(licenseState: LicenseState) { diff --git a/x-pack/plugins/alerts/server/lib/validate_alert_type_params.test.ts b/x-pack/plugins/alerts/server/lib/validate_alert_type_params.test.ts index 1e6c26c02e65b..2814eaef3e02a 100644 --- a/x-pack/plugins/alerts/server/lib/validate_alert_type_params.test.ts +++ b/x-pack/plugins/alerts/server/lib/validate_alert_type_params.test.ts @@ -19,6 +19,7 @@ test('should return passed in params when validation not defined', () => { }, ], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', async executor() {}, producer: 'alerts', }, @@ -41,6 +42,7 @@ test('should validate and apply defaults when params is valid', () => { }, ], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', validate: { params: schema.object({ param1: schema.string(), @@ -71,6 +73,7 @@ test('should validate and throw error when params is invalid', () => { }, ], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', validate: { params: schema.object({ param1: schema.string(), diff --git a/x-pack/plugins/alerts/server/manual_tests/action_param_templates.sh b/x-pack/plugins/alerts/server/manual_tests/action_param_templates.sh new file mode 100644 index 0000000000000..5b209fdd3f598 --- /dev/null +++ b/x-pack/plugins/alerts/server/manual_tests/action_param_templates.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash + +# This will create 3 actions and 1 alert that runs those actions. +# The actions run will need to do action-specific escaping for the +# actions to work correctly, which was fixed in 7.11.0. +# +# The actions run are Slack, Webhook, and email. The Webhook action also +# posts to the same Slack webhook. The email posts to maildev. +# +# After running the script, check Slack and the maildev web interface +# to make sure the actions ran appropriately. You can also edit the +# alert name to other interesting text to see how it renders. +# +# you will need the following env vars set for Slack: +# SLACK_WEBHOOKURL +# +# expects you're running maildev with the default options via +# npx maildev +# +# you'll need jq installed +# https://stedolan.github.io/jq/download/ + +KIBANA_URL=https://elastic:changeme@localhost:5601 + +# create email action +ACTION_ID_EMAIL=`curl -X POST --insecure --silent \ + $KIBANA_URL/api/actions/action \ + -H "kbn-xsrf: foo" -H "content-type: application/json" \ + -d '{ + "actionTypeId": ".email", + "name": "email for action_param_templates test", + "config": { + "from": "team-alerting@example.com", + "host": "localhost", + "port": 1025 + }, + "secrets": { + } + }' | jq -r '.id'` +echo "email action id: $ACTION_ID_EMAIL" + +# create slack action +ACTION_ID_SLACK=`curl -X POST --insecure --silent \ + $KIBANA_URL/api/actions/action \ + -H "kbn-xsrf: foo" -H "content-type: application/json" \ + -d "{ + \"actionTypeId\": \".slack\", + \"name\": \"slack for action_param_templates test\", + \"config\": { + }, + \"secrets\": { + \"webhookUrl\": \"$SLACK_WEBHOOKURL\" + } + }" | jq -r '.id'` +echo "slack action id: $ACTION_ID_SLACK" + +# create webhook action +ACTION_ID_WEBHOOK=`curl -X POST --insecure --silent \ + $KIBANA_URL/api/actions/action \ + -H "kbn-xsrf: foo" -H "content-type: application/json" \ + -d "{ + \"actionTypeId\": \".webhook\", + \"name\": \"webhook for action_param_templates test\", + \"config\": { + \"url\": \"$SLACK_WEBHOOKURL\", + \"headers\": { \"Content-type\": \"application/json\" } + }, + \"secrets\": { + } + }" | jq -r '.id'` +echo "webhook action id: $ACTION_ID_WEBHOOK" + +WEBHOOK_BODY="{ \\\"text\\\": \\\"text from webhook {{alertName}}\\\" }" + +# create alert +ALERT_ID=`curl -X POST --insecure --silent \ + $KIBANA_URL/api/alerts/alert \ + -H "kbn-xsrf: foo" -H "content-type: application/json" \ + -d "{ + \"alertTypeId\": \".index-threshold\", + \"name\": \"alert for action_param_templates test\u000awith newline and *bold*\", + \"schedule\": { \"interval\": \"30s\" }, + \"consumer\": \"alerts\", + \"tags\": [], + \"actions\": [ + { + \"group\": \"threshold met\", + \"id\": \"$ACTION_ID_EMAIL\", + \"params\":{ + \"to\": [\"team-alerting@example.com\"], + \"subject\": \"subject {{alertName}}\", + \"message\": \"message {{alertName}}\" + } + }, + { + \"group\": \"threshold met\", + \"id\": \"$ACTION_ID_SLACK\", + \"params\":{ + \"message\": \"message from slack {{alertName}}\" + } + }, + { + \"group\": \"threshold met\", + \"id\": \"$ACTION_ID_WEBHOOK\", + \"params\":{ + \"body\": \"$WEBHOOK_BODY\" + } + } + ], + \"params\": { + \"index\": [\".kibana\"], + \"timeField\": \"updated_at\", + \"aggType\": \"count\", + \"groupBy\": \"all\", + \"timeWindowSize\": 100, + \"timeWindowUnit\": \"d\", + \"thresholdComparator\": \">\", + \"threshold\":[0] + } + }" #| jq -r '.id'` +echo "alert id: $ALERT_ID" diff --git a/x-pack/plugins/alerts/server/plugin.test.ts b/x-pack/plugins/alerts/server/plugin.test.ts index 48fd2e12336a8..0c9a09b11532b 100644 --- a/x-pack/plugins/alerts/server/plugin.test.ts +++ b/x-pack/plugins/alerts/server/plugin.test.ts @@ -4,7 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AlertingPlugin, AlertingPluginsSetup, AlertingPluginsStart } from './plugin'; +import { + AlertingPlugin, + AlertingPluginsSetup, + AlertingPluginsStart, + PluginSetupContract, +} from './plugin'; import { coreMock, statusServiceMock } from '../../../../src/core/server/mocks'; import { licensingMock } from '../../licensing/server/mocks'; import { encryptedSavedObjectsMock } from '../../encrypted_saved_objects/server/mocks'; @@ -14,9 +19,16 @@ import { KibanaRequest, CoreSetup } from 'kibana/server'; import { featuresPluginMock } from '../../features/server/mocks'; import { KibanaFeature } from '../../features/server'; import { AlertsConfig } from './config'; +import { AlertType } from './types'; +import { eventLogMock } from '../../event_log/server/mocks'; +import { actionsMock } from '../../actions/server/mocks'; describe('Alerting Plugin', () => { describe('setup()', () => { + let plugin: AlertingPlugin; + let coreSetup: ReturnType; + let pluginsSetup: jest.Mocked; + it('should log warning when Encrypted Saved Objects plugin is using an ephemeral encryption key', async () => { const context = coreMock.createPluginInitializerContext({ healthCheck: { @@ -27,9 +39,9 @@ describe('Alerting Plugin', () => { removalDelay: '1h', }, }); - const plugin = new AlertingPlugin(context); + plugin = new AlertingPlugin(context); - const coreSetup = coreMock.createSetup(); + coreSetup = coreMock.createSetup(); const encryptedSavedObjectsSetup = encryptedSavedObjectsMock.createSetup(); const statusMock = statusServiceMock.createSetupContract(); await plugin.setup( @@ -55,6 +67,56 @@ describe('Alerting Plugin', () => { 'APIs are disabled because the Encrypted Saved Objects plugin uses an ephemeral encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command.' ); }); + + describe('registerType()', () => { + let setup: PluginSetupContract; + const sampleAlertType: AlertType = { + id: 'test', + name: 'test', + minimumLicenseRequired: 'basic', + actionGroups: [], + defaultActionGroupId: 'default', + producer: 'test', + async executor() {}, + }; + + beforeEach(async () => { + coreSetup = coreMock.createSetup(); + pluginsSetup = { + taskManager: taskManagerMock.createSetup(), + encryptedSavedObjects: encryptedSavedObjectsMock.createSetup(), + licensing: licensingMock.createSetup(), + eventLog: eventLogMock.createSetup(), + actions: actionsMock.createSetup(), + statusService: statusServiceMock.createSetupContract(), + }; + setup = await plugin.setup(coreSetup, pluginsSetup); + }); + + it('should throw error when license type is invalid', async () => { + expect(() => + setup.registerType({ + ...sampleAlertType, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + minimumLicenseRequired: 'foo' as any, + }) + ).toThrowErrorMatchingInlineSnapshot(`"\\"foo\\" is not a valid license type"`); + }); + + it('should not throw when license type is gold', async () => { + setup.registerType({ + ...sampleAlertType, + minimumLicenseRequired: 'gold', + }); + }); + + it('should not throw when license type is basic', async () => { + setup.registerType({ + ...sampleAlertType, + minimumLicenseRequired: 'basic', + }); + }); + }); }); describe('start()', () => { @@ -106,6 +168,7 @@ describe('Alerting Plugin', () => { }, encryptedSavedObjects: encryptedSavedObjectsMock.createStart(), features: mockFeatures(), + licensing: licensingMock.createStart(), } as unknown) as AlertingPluginsStart ); @@ -160,6 +223,7 @@ describe('Alerting Plugin', () => { }, encryptedSavedObjects: encryptedSavedObjectsMock.createStart(), features: mockFeatures(), + licensing: licensingMock.createStart(), } as unknown) as AlertingPluginsStart ); diff --git a/x-pack/plugins/alerts/server/plugin.ts b/x-pack/plugins/alerts/server/plugin.ts index e526c65b90102..63861f5050f25 100644 --- a/x-pack/plugins/alerts/server/plugin.ts +++ b/x-pack/plugins/alerts/server/plugin.ts @@ -19,7 +19,7 @@ import { AlertsClient } from './alerts_client'; import { AlertTypeRegistry } from './alert_type_registry'; import { TaskRunnerFactory } from './task_runner'; import { AlertsClientFactory } from './alerts_client_factory'; -import { LicenseState } from './lib/license_state'; +import { ILicenseState, LicenseState } from './lib/license_state'; import { KibanaRequest, Logger, @@ -54,12 +54,20 @@ import { unmuteAlertInstanceRoute, healthRoute, } from './routes'; -import { LicensingPluginSetup } from '../../licensing/server'; +import { LICENSE_TYPE, LicensingPluginSetup, LicensingPluginStart } from '../../licensing/server'; import { PluginSetupContract as ActionsPluginSetupContract, PluginStartContract as ActionsPluginStartContract, } from '../../actions/server'; -import { AlertsHealth, Services } from './types'; +import { + AlertInstanceContext, + AlertInstanceState, + AlertsHealth, + AlertType, + AlertTypeParams, + AlertTypeState, + Services, +} from './types'; import { registerAlertsUsageCollector } from './usage'; import { initializeAlertingTelemetry, scheduleAlertingTelemetry } from './usage/task'; import { IEventLogger, IEventLogService, IEventLogClientService } from '../../event_log/server'; @@ -90,8 +98,16 @@ export const LEGACY_EVENT_LOG_ACTIONS = { }; export interface PluginSetupContract { - registerType: AlertTypeRegistry['register']; + registerType< + Params extends AlertTypeParams = AlertTypeParams, + State extends AlertTypeState = AlertTypeState, + InstanceState extends AlertInstanceState = AlertInstanceState, + InstanceContext extends AlertInstanceContext = AlertInstanceContext + >( + alertType: AlertType + ): void; } + export interface PluginStartContract { listTypes: AlertTypeRegistry['list']; getAlertsClientWithRequest(request: KibanaRequest): PublicMethodsOf; @@ -114,6 +130,7 @@ export interface AlertingPluginsStart { encryptedSavedObjects: EncryptedSavedObjectsPluginStart; features: FeaturesPluginStart; eventLog: IEventLogClientService; + licensing: LicensingPluginStart; spaces?: SpacesPluginStart; security?: SecurityPluginStart; } @@ -123,7 +140,7 @@ export class AlertingPlugin { private readonly logger: Logger; private alertTypeRegistry?: AlertTypeRegistry; private readonly taskRunnerFactory: TaskRunnerFactory; - private licenseState: LicenseState | null = null; + private licenseState: ILicenseState | null = null; private isESOUsingEphemeralEncryptionKey?: boolean; private security?: SecurityPluginSetup; private readonly alertsClientFactory: AlertsClientFactory; @@ -181,6 +198,8 @@ export class AlertingPlugin { const alertTypeRegistry = new AlertTypeRegistry({ taskManager: plugins.taskManager, taskRunnerFactory: this.taskRunnerFactory, + licenseState: this.licenseState, + licensing: plugins.licensing, }); this.alertTypeRegistry = alertTypeRegistry; @@ -250,7 +269,17 @@ export class AlertingPlugin { healthRoute(router, this.licenseState, plugins.encryptedSavedObjects); return { - registerType: alertTypeRegistry.register.bind(alertTypeRegistry), + registerType< + Params extends AlertTypeParams = AlertTypeParams, + State extends AlertTypeState = AlertTypeState, + InstanceState extends AlertInstanceState = AlertInstanceState, + InstanceContext extends AlertInstanceContext = AlertInstanceContext + >(alertType: AlertType) { + if (!(alertType.minimumLicenseRequired in LICENSE_TYPE)) { + throw new Error(`"${alertType.minimumLicenseRequired}" is not a valid license type`); + } + alertTypeRegistry.register(alertType); + }, }; } @@ -262,8 +291,11 @@ export class AlertingPlugin { alertTypeRegistry, alertsClientFactory, security, + licenseState, } = this; + licenseState?.setNotifyUsage(plugins.licensing.featureUsage.notifyUsage); + const encryptedSavedObjectsClient = plugins.encryptedSavedObjects.getClient({ includedHiddenTypes: ['alert'], }); @@ -313,6 +345,7 @@ export class AlertingPlugin { basePathService: core.http.basePath, eventLogger: this.eventLogger!, internalSavedObjectsRepository: core.savedObjects.createInternalRepository(['alert']), + alertTypeRegistry: this.alertTypeRegistry!, }); this.eventLogService!.registerSavedObjectProvider('alert', (request) => { diff --git a/x-pack/plugins/alerts/server/routes/aggregate.test.ts b/x-pack/plugins/alerts/server/routes/aggregate.test.ts index 498ee7ba2da58..199c336dd977d 100644 --- a/x-pack/plugins/alerts/server/routes/aggregate.test.ts +++ b/x-pack/plugins/alerts/server/routes/aggregate.test.ts @@ -6,7 +6,7 @@ import { aggregateAlertRoute } from './aggregate'; import { httpServiceMock } from 'src/core/server/mocks'; -import { mockLicenseState } from '../lib/license_state.mock'; +import { licenseStateMock } from '../lib/license_state.mock'; import { verifyApiAccess } from '../lib/license_api_access'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { alertsClientMock } from '../alerts_client.mock'; @@ -23,7 +23,7 @@ beforeEach(() => { describe('aggregateAlertRoute', () => { it('aggregate alerts with proper parameters', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); aggregateAlertRoute(router, licenseState); @@ -84,7 +84,7 @@ describe('aggregateAlertRoute', () => { }); it('ensures the license allows aggregating alerts', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); aggregateAlertRoute(router, licenseState); @@ -116,7 +116,7 @@ describe('aggregateAlertRoute', () => { }); it('ensures the license check prevents aggregating alerts', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); (verifyApiAccess as jest.Mock).mockImplementation(() => { diff --git a/x-pack/plugins/alerts/server/routes/aggregate.ts b/x-pack/plugins/alerts/server/routes/aggregate.ts index 2c36521b07269..0fcfb6f6147e7 100644 --- a/x-pack/plugins/alerts/server/routes/aggregate.ts +++ b/x-pack/plugins/alerts/server/routes/aggregate.ts @@ -12,7 +12,7 @@ import { IKibanaResponse, KibanaResponseFactory, } from 'kibana/server'; -import { LicenseState } from '../lib/license_state'; +import { ILicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { BASE_ALERT_API_PATH } from '../../common'; import { renameKeys } from './lib/rename_keys'; @@ -38,7 +38,7 @@ const querySchema = schema.object({ filter: schema.maybe(schema.string()), }); -export const aggregateAlertRoute = (router: IRouter, licenseState: LicenseState) => { +export const aggregateAlertRoute = (router: IRouter, licenseState: ILicenseState) => { router.get( { path: `${BASE_ALERT_API_PATH}/_aggregate`, diff --git a/x-pack/plugins/alerts/server/routes/create.test.ts b/x-pack/plugins/alerts/server/routes/create.test.ts index 90c075f129b8c..5597b315158cd 100644 --- a/x-pack/plugins/alerts/server/routes/create.test.ts +++ b/x-pack/plugins/alerts/server/routes/create.test.ts @@ -6,11 +6,12 @@ import { createAlertRoute } from './create'; import { httpServiceMock } from 'src/core/server/mocks'; -import { mockLicenseState } from '../lib/license_state.mock'; +import { licenseStateMock } from '../lib/license_state.mock'; import { verifyApiAccess } from '../lib/license_api_access'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { alertsClientMock } from '../alerts_client.mock'; import { Alert } from '../../common/alert'; +import { AlertTypeDisabledError } from '../lib/errors/alert_type_disabled'; const alertsClient = alertsClientMock.create(); @@ -74,7 +75,7 @@ describe('createAlertRoute', () => { }; it('creates an alert with proper parameters', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); createAlertRoute(router, licenseState); @@ -134,7 +135,7 @@ describe('createAlertRoute', () => { }); it('ensures the license allows creating alerts', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); createAlertRoute(router, licenseState); @@ -151,7 +152,7 @@ describe('createAlertRoute', () => { }); it('ensures the license check prevents creating alerts', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); (verifyApiAccess as jest.Mock).mockImplementation(() => { @@ -170,4 +171,21 @@ describe('createAlertRoute', () => { expect(verifyApiAccess).toHaveBeenCalledWith(licenseState); }); + + it('ensures the alert type gets validated for the license', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + createAlertRoute(router, licenseState); + + const [, handler] = router.post.mock.calls[0]; + + alertsClient.create.mockRejectedValue(new AlertTypeDisabledError('Fail', 'license_invalid')); + + const [context, req, res] = mockHandlerArguments({ alertsClient }, {}, ['ok', 'forbidden']); + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } }); + }); }); diff --git a/x-pack/plugins/alerts/server/routes/create.ts b/x-pack/plugins/alerts/server/routes/create.ts index f54aec8fe0cf0..a34a3118985fa 100644 --- a/x-pack/plugins/alerts/server/routes/create.ts +++ b/x-pack/plugins/alerts/server/routes/create.ts @@ -12,11 +12,12 @@ import { IKibanaResponse, KibanaResponseFactory, } from 'kibana/server'; -import { LicenseState } from '../lib/license_state'; +import { ILicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { validateDurationSchema } from '../lib'; import { handleDisabledApiKeysError } from './lib/error_handler'; import { Alert, AlertNotifyWhenType, BASE_ALERT_API_PATH, validateNotifyWhenType } from '../types'; +import { AlertTypeDisabledError } from '../lib/errors/alert_type_disabled'; export const bodySchema = schema.object({ name: schema.string(), @@ -41,7 +42,7 @@ export const bodySchema = schema.object({ notifyWhen: schema.nullable(schema.string({ validate: validateNotifyWhenType })), }); -export const createAlertRoute = (router: IRouter, licenseState: LicenseState) => { +export const createAlertRoute = (router: IRouter, licenseState: ILicenseState) => { router.post( { path: `${BASE_ALERT_API_PATH}/alert`, @@ -63,10 +64,17 @@ export const createAlertRoute = (router: IRouter, licenseState: LicenseState) => const alertsClient = context.alerting.getAlertsClient(); const alert = req.body; const notifyWhen = alert?.notifyWhen ? (alert.notifyWhen as AlertNotifyWhenType) : null; - const alertRes: Alert = await alertsClient.create({ data: { ...alert, notifyWhen } }); - return res.ok({ - body: alertRes, - }); + try { + const alertRes: Alert = await alertsClient.create({ data: { ...alert, notifyWhen } }); + return res.ok({ + body: alertRes, + }); + } catch (e) { + if (e instanceof AlertTypeDisabledError) { + return e.sendResponse(res); + } + throw e; + } }) ) ); diff --git a/x-pack/plugins/alerts/server/routes/delete.test.ts b/x-pack/plugins/alerts/server/routes/delete.test.ts index d9c5aa2d59c87..e704ed498fc0c 100644 --- a/x-pack/plugins/alerts/server/routes/delete.test.ts +++ b/x-pack/plugins/alerts/server/routes/delete.test.ts @@ -5,7 +5,7 @@ */ import { deleteAlertRoute } from './delete'; import { httpServiceMock } from 'src/core/server/mocks'; -import { mockLicenseState } from '../lib/license_state.mock'; +import { licenseStateMock } from '../lib/license_state.mock'; import { verifyApiAccess } from '../lib/license_api_access'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { alertsClientMock } from '../alerts_client.mock'; @@ -22,7 +22,7 @@ beforeEach(() => { describe('deleteAlertRoute', () => { it('deletes an alert with proper parameters', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); deleteAlertRoute(router, licenseState); @@ -58,7 +58,7 @@ describe('deleteAlertRoute', () => { }); it('ensures the license allows deleting alerts', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); deleteAlertRoute(router, licenseState); @@ -80,7 +80,7 @@ describe('deleteAlertRoute', () => { }); it('ensures the license check prevents deleting alerts', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); (verifyApiAccess as jest.Mock).mockImplementation(() => { diff --git a/x-pack/plugins/alerts/server/routes/delete.ts b/x-pack/plugins/alerts/server/routes/delete.ts index b073c59149171..3ac975d3a1546 100644 --- a/x-pack/plugins/alerts/server/routes/delete.ts +++ b/x-pack/plugins/alerts/server/routes/delete.ts @@ -12,7 +12,7 @@ import { IKibanaResponse, KibanaResponseFactory, } from 'kibana/server'; -import { LicenseState } from '../lib/license_state'; +import { ILicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { BASE_ALERT_API_PATH } from '../../common'; @@ -20,7 +20,7 @@ const paramSchema = schema.object({ id: schema.string(), }); -export const deleteAlertRoute = (router: IRouter, licenseState: LicenseState) => { +export const deleteAlertRoute = (router: IRouter, licenseState: ILicenseState) => { router.delete( { path: `${BASE_ALERT_API_PATH}/alert/{id}`, diff --git a/x-pack/plugins/alerts/server/routes/disable.test.ts b/x-pack/plugins/alerts/server/routes/disable.test.ts index 74f7b2eb8a570..4e736eb315d35 100644 --- a/x-pack/plugins/alerts/server/routes/disable.test.ts +++ b/x-pack/plugins/alerts/server/routes/disable.test.ts @@ -6,9 +6,10 @@ import { disableAlertRoute } from './disable'; import { httpServiceMock } from 'src/core/server/mocks'; -import { mockLicenseState } from '../lib/license_state.mock'; +import { licenseStateMock } from '../lib/license_state.mock'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { alertsClientMock } from '../alerts_client.mock'; +import { AlertTypeDisabledError } from '../lib/errors/alert_type_disabled'; const alertsClient = alertsClientMock.create(); @@ -22,7 +23,7 @@ beforeEach(() => { describe('disableAlertRoute', () => { it('disables an alert', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); disableAlertRoute(router, licenseState); @@ -56,4 +57,24 @@ describe('disableAlertRoute', () => { expect(res.noContent).toHaveBeenCalled(); }); + + it('ensures the alert type gets validated for the license', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + disableAlertRoute(router, licenseState); + + const [, handler] = router.post.mock.calls[0]; + + alertsClient.disable.mockRejectedValue(new AlertTypeDisabledError('Fail', 'license_invalid')); + + const [context, req, res] = mockHandlerArguments({ alertsClient }, { params: {}, body: {} }, [ + 'ok', + 'forbidden', + ]); + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } }); + }); }); diff --git a/x-pack/plugins/alerts/server/routes/disable.ts b/x-pack/plugins/alerts/server/routes/disable.ts index 234f8ed959a5d..e96cb397f554b 100644 --- a/x-pack/plugins/alerts/server/routes/disable.ts +++ b/x-pack/plugins/alerts/server/routes/disable.ts @@ -12,15 +12,16 @@ import { IKibanaResponse, KibanaResponseFactory, } from 'kibana/server'; -import { LicenseState } from '../lib/license_state'; +import { ILicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { BASE_ALERT_API_PATH } from '../../common'; +import { AlertTypeDisabledError } from '../lib/errors/alert_type_disabled'; const paramSchema = schema.object({ id: schema.string(), }); -export const disableAlertRoute = (router: IRouter, licenseState: LicenseState) => { +export const disableAlertRoute = (router: IRouter, licenseState: ILicenseState) => { router.post( { path: `${BASE_ALERT_API_PATH}/alert/{id}/_disable`, @@ -39,8 +40,15 @@ export const disableAlertRoute = (router: IRouter, licenseState: LicenseState) = } const alertsClient = context.alerting.getAlertsClient(); const { id } = req.params; - await alertsClient.disable({ id }); - return res.noContent(); + try { + await alertsClient.disable({ id }); + return res.noContent(); + } catch (e) { + if (e instanceof AlertTypeDisabledError) { + return e.sendResponse(res); + } + throw e; + } }) ); }; diff --git a/x-pack/plugins/alerts/server/routes/enable.test.ts b/x-pack/plugins/alerts/server/routes/enable.test.ts index c9575ef87f767..8db0f2ae68938 100644 --- a/x-pack/plugins/alerts/server/routes/enable.test.ts +++ b/x-pack/plugins/alerts/server/routes/enable.test.ts @@ -5,9 +5,10 @@ */ import { enableAlertRoute } from './enable'; import { httpServiceMock } from 'src/core/server/mocks'; -import { mockLicenseState } from '../lib/license_state.mock'; +import { licenseStateMock } from '../lib/license_state.mock'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { alertsClientMock } from '../alerts_client.mock'; +import { AlertTypeDisabledError } from '../lib/errors/alert_type_disabled'; const alertsClient = alertsClientMock.create(); @@ -21,7 +22,7 @@ beforeEach(() => { describe('enableAlertRoute', () => { it('enables an alert', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); enableAlertRoute(router, licenseState); @@ -55,4 +56,24 @@ describe('enableAlertRoute', () => { expect(res.noContent).toHaveBeenCalled(); }); + + it('ensures the alert type gets validated for the license', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + enableAlertRoute(router, licenseState); + + const [, handler] = router.post.mock.calls[0]; + + alertsClient.enable.mockRejectedValue(new AlertTypeDisabledError('Fail', 'license_invalid')); + + const [context, req, res] = mockHandlerArguments({ alertsClient }, { params: {}, body: {} }, [ + 'ok', + 'forbidden', + ]); + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } }); + }); }); diff --git a/x-pack/plugins/alerts/server/routes/enable.ts b/x-pack/plugins/alerts/server/routes/enable.ts index c162b4a9844b3..81c5027c7587b 100644 --- a/x-pack/plugins/alerts/server/routes/enable.ts +++ b/x-pack/plugins/alerts/server/routes/enable.ts @@ -12,16 +12,17 @@ import { IKibanaResponse, KibanaResponseFactory, } from 'kibana/server'; -import { LicenseState } from '../lib/license_state'; +import { ILicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { BASE_ALERT_API_PATH } from '../../common'; import { handleDisabledApiKeysError } from './lib/error_handler'; +import { AlertTypeDisabledError } from '../lib/errors/alert_type_disabled'; const paramSchema = schema.object({ id: schema.string(), }); -export const enableAlertRoute = (router: IRouter, licenseState: LicenseState) => { +export const enableAlertRoute = (router: IRouter, licenseState: ILicenseState) => { router.post( { path: `${BASE_ALERT_API_PATH}/alert/{id}/_enable`, @@ -41,8 +42,15 @@ export const enableAlertRoute = (router: IRouter, licenseState: LicenseState) => } const alertsClient = context.alerting.getAlertsClient(); const { id } = req.params; - await alertsClient.enable({ id }); - return res.noContent(); + try { + await alertsClient.enable({ id }); + return res.noContent(); + } catch (e) { + if (e instanceof AlertTypeDisabledError) { + return e.sendResponse(res); + } + throw e; + } }) ) ); diff --git a/x-pack/plugins/alerts/server/routes/find.test.ts b/x-pack/plugins/alerts/server/routes/find.test.ts index 46702f96a2e10..c6c98ca662712 100644 --- a/x-pack/plugins/alerts/server/routes/find.test.ts +++ b/x-pack/plugins/alerts/server/routes/find.test.ts @@ -6,7 +6,7 @@ import { findAlertRoute } from './find'; import { httpServiceMock } from 'src/core/server/mocks'; -import { mockLicenseState } from '../lib/license_state.mock'; +import { licenseStateMock } from '../lib/license_state.mock'; import { verifyApiAccess } from '../lib/license_api_access'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { alertsClientMock } from '../alerts_client.mock'; @@ -23,7 +23,7 @@ beforeEach(() => { describe('findAlertRoute', () => { it('finds alerts with proper parameters', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); findAlertRoute(router, licenseState); @@ -82,7 +82,7 @@ describe('findAlertRoute', () => { }); it('ensures the license allows finding alerts', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); findAlertRoute(router, licenseState); @@ -113,7 +113,7 @@ describe('findAlertRoute', () => { }); it('ensures the license check prevents finding alerts', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); (verifyApiAccess as jest.Mock).mockImplementation(() => { diff --git a/x-pack/plugins/alerts/server/routes/find.ts b/x-pack/plugins/alerts/server/routes/find.ts index ef3b16dc9e517..487ff571187f4 100644 --- a/x-pack/plugins/alerts/server/routes/find.ts +++ b/x-pack/plugins/alerts/server/routes/find.ts @@ -12,7 +12,7 @@ import { IKibanaResponse, KibanaResponseFactory, } from 'kibana/server'; -import { LicenseState } from '../lib/license_state'; +import { ILicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { BASE_ALERT_API_PATH } from '../../common'; import { renameKeys } from './lib/rename_keys'; @@ -43,7 +43,7 @@ const querySchema = schema.object({ filter: schema.maybe(schema.string()), }); -export const findAlertRoute = (router: IRouter, licenseState: LicenseState) => { +export const findAlertRoute = (router: IRouter, licenseState: ILicenseState) => { router.get( { path: `${BASE_ALERT_API_PATH}/_find`, diff --git a/x-pack/plugins/alerts/server/routes/get.test.ts b/x-pack/plugins/alerts/server/routes/get.test.ts index 51ac64bbef182..21e52ece82d2d 100644 --- a/x-pack/plugins/alerts/server/routes/get.test.ts +++ b/x-pack/plugins/alerts/server/routes/get.test.ts @@ -6,7 +6,7 @@ import { getAlertRoute } from './get'; import { httpServiceMock } from 'src/core/server/mocks'; -import { mockLicenseState } from '../lib/license_state.mock'; +import { licenseStateMock } from '../lib/license_state.mock'; import { verifyApiAccess } from '../lib/license_api_access'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { alertsClientMock } from '../alerts_client.mock'; @@ -60,7 +60,7 @@ describe('getAlertRoute', () => { }; it('gets an alert with proper parameters', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); getAlertRoute(router, licenseState); @@ -88,7 +88,7 @@ describe('getAlertRoute', () => { }); it('ensures the license allows getting alerts', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); getAlertRoute(router, licenseState); @@ -111,7 +111,7 @@ describe('getAlertRoute', () => { }); it('ensures the license check prevents getting alerts', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); (verifyApiAccess as jest.Mock).mockImplementation(() => { diff --git a/x-pack/plugins/alerts/server/routes/get.ts b/x-pack/plugins/alerts/server/routes/get.ts index 0f3fc4b2f3e41..ae592f37cd55c 100644 --- a/x-pack/plugins/alerts/server/routes/get.ts +++ b/x-pack/plugins/alerts/server/routes/get.ts @@ -12,7 +12,7 @@ import { IKibanaResponse, KibanaResponseFactory, } from 'kibana/server'; -import { LicenseState } from '../lib/license_state'; +import { ILicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { BASE_ALERT_API_PATH } from '../../common'; @@ -20,7 +20,7 @@ const paramSchema = schema.object({ id: schema.string(), }); -export const getAlertRoute = (router: IRouter, licenseState: LicenseState) => { +export const getAlertRoute = (router: IRouter, licenseState: ILicenseState) => { router.get( { path: `${BASE_ALERT_API_PATH}/alert/{id}`, diff --git a/x-pack/plugins/alerts/server/routes/get_alert_instance_summary.test.ts b/x-pack/plugins/alerts/server/routes/get_alert_instance_summary.test.ts index 8957a3d7c091e..eb0d3ad480eec 100644 --- a/x-pack/plugins/alerts/server/routes/get_alert_instance_summary.test.ts +++ b/x-pack/plugins/alerts/server/routes/get_alert_instance_summary.test.ts @@ -6,7 +6,7 @@ import { getAlertInstanceSummaryRoute } from './get_alert_instance_summary'; import { httpServiceMock } from 'src/core/server/mocks'; -import { mockLicenseState } from '../lib/license_state.mock'; +import { licenseStateMock } from '../lib/license_state.mock'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { SavedObjectsErrorHelpers } from 'src/core/server'; import { alertsClientMock } from '../alerts_client.mock'; @@ -40,7 +40,7 @@ describe('getAlertInstanceSummaryRoute', () => { }; it('gets alert instance summary', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); getAlertInstanceSummaryRoute(router, licenseState); @@ -78,7 +78,7 @@ describe('getAlertInstanceSummaryRoute', () => { }); it('returns NOT-FOUND when alert is not found', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); getAlertInstanceSummaryRoute(router, licenseState); diff --git a/x-pack/plugins/alerts/server/routes/get_alert_instance_summary.ts b/x-pack/plugins/alerts/server/routes/get_alert_instance_summary.ts index 11a10c2967a58..33f331f7dce02 100644 --- a/x-pack/plugins/alerts/server/routes/get_alert_instance_summary.ts +++ b/x-pack/plugins/alerts/server/routes/get_alert_instance_summary.ts @@ -12,7 +12,7 @@ import { IKibanaResponse, KibanaResponseFactory, } from 'kibana/server'; -import { LicenseState } from '../lib/license_state'; +import { ILicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { BASE_ALERT_API_PATH } from '../../common'; @@ -24,7 +24,7 @@ const querySchema = schema.object({ dateStart: schema.maybe(schema.string()), }); -export const getAlertInstanceSummaryRoute = (router: IRouter, licenseState: LicenseState) => { +export const getAlertInstanceSummaryRoute = (router: IRouter, licenseState: ILicenseState) => { router.get( { path: `${BASE_ALERT_API_PATH}/alert/{id}/_instance_summary`, diff --git a/x-pack/plugins/alerts/server/routes/get_alert_state.test.ts b/x-pack/plugins/alerts/server/routes/get_alert_state.test.ts index d5bf9737d39ab..a3d0a93b34998 100644 --- a/x-pack/plugins/alerts/server/routes/get_alert_state.test.ts +++ b/x-pack/plugins/alerts/server/routes/get_alert_state.test.ts @@ -6,7 +6,7 @@ import { getAlertStateRoute } from './get_alert_state'; import { httpServiceMock } from 'src/core/server/mocks'; -import { mockLicenseState } from '../lib/license_state.mock'; +import { licenseStateMock } from '../lib/license_state.mock'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { SavedObjectsErrorHelpers } from 'src/core/server'; import { alertsClientMock } from '../alerts_client.mock'; @@ -40,7 +40,7 @@ describe('getAlertStateRoute', () => { }; it('gets alert state', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); getAlertStateRoute(router, licenseState); @@ -76,7 +76,7 @@ describe('getAlertStateRoute', () => { }); it('returns NO-CONTENT when alert exists but has no task state yet', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); getAlertStateRoute(router, licenseState); @@ -112,7 +112,7 @@ describe('getAlertStateRoute', () => { }); it('returns NOT-FOUND when alert is not found', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); getAlertStateRoute(router, licenseState); diff --git a/x-pack/plugins/alerts/server/routes/get_alert_state.ts b/x-pack/plugins/alerts/server/routes/get_alert_state.ts index 089fc80fca355..52ad8f9f31874 100644 --- a/x-pack/plugins/alerts/server/routes/get_alert_state.ts +++ b/x-pack/plugins/alerts/server/routes/get_alert_state.ts @@ -12,7 +12,7 @@ import { IKibanaResponse, KibanaResponseFactory, } from 'kibana/server'; -import { LicenseState } from '../lib/license_state'; +import { ILicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { BASE_ALERT_API_PATH } from '../../common'; @@ -20,7 +20,7 @@ const paramSchema = schema.object({ id: schema.string(), }); -export const getAlertStateRoute = (router: IRouter, licenseState: LicenseState) => { +export const getAlertStateRoute = (router: IRouter, licenseState: ILicenseState) => { router.get( { path: `${BASE_ALERT_API_PATH}/alert/{id}/state`, diff --git a/x-pack/plugins/alerts/server/routes/health.test.ts b/x-pack/plugins/alerts/server/routes/health.test.ts index d1967c6dd9bf8..2361f0c90e031 100644 --- a/x-pack/plugins/alerts/server/routes/health.test.ts +++ b/x-pack/plugins/alerts/server/routes/health.test.ts @@ -9,7 +9,7 @@ import { httpServiceMock } from 'src/core/server/mocks'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; import { verifyApiAccess } from '../lib/license_api_access'; -import { mockLicenseState } from '../lib/license_state.mock'; +import { licenseStateMock } from '../lib/license_state.mock'; import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/mocks'; import { alertsClientMock } from '../alerts_client.mock'; import { HealthStatus } from '../types'; @@ -45,7 +45,7 @@ describe('healthRoute', () => { it('registers the route', async () => { const router = httpServiceMock.createRouter(); - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup(); encryptedSavedObjects.usingEphemeralEncryptionKey = false; healthRoute(router, licenseState, encryptedSavedObjects); @@ -58,7 +58,7 @@ describe('healthRoute', () => { it('queries the usage api', async () => { const router = httpServiceMock.createRouter(); - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup(); encryptedSavedObjects.usingEphemeralEncryptionKey = false; healthRoute(router, licenseState, encryptedSavedObjects); @@ -87,7 +87,7 @@ describe('healthRoute', () => { it('evaluates whether Encrypted Saved Objects is using an ephemeral encryption key', async () => { const router = httpServiceMock.createRouter(); - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup(); encryptedSavedObjects.usingEphemeralEncryptionKey = true; healthRoute(router, licenseState, encryptedSavedObjects); @@ -127,7 +127,7 @@ describe('healthRoute', () => { it('evaluates missing security info from the usage api to mean that the security plugin is disbled', async () => { const router = httpServiceMock.createRouter(); - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup(); encryptedSavedObjects.usingEphemeralEncryptionKey = false; healthRoute(router, licenseState, encryptedSavedObjects); @@ -167,7 +167,7 @@ describe('healthRoute', () => { it('evaluates missing security http info from the usage api to mean that the security plugin is disbled', async () => { const router = httpServiceMock.createRouter(); - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup(); encryptedSavedObjects.usingEphemeralEncryptionKey = false; healthRoute(router, licenseState, encryptedSavedObjects); @@ -207,7 +207,7 @@ describe('healthRoute', () => { it('evaluates security enabled, and missing ssl info from the usage api to mean that the user cannot generate keys', async () => { const router = httpServiceMock.createRouter(); - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup(); encryptedSavedObjects.usingEphemeralEncryptionKey = false; healthRoute(router, licenseState, encryptedSavedObjects); @@ -247,7 +247,7 @@ describe('healthRoute', () => { it('evaluates security enabled, SSL info present but missing http info from the usage api to mean that the user cannot generate keys', async () => { const router = httpServiceMock.createRouter(); - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup(); encryptedSavedObjects.usingEphemeralEncryptionKey = false; healthRoute(router, licenseState, encryptedSavedObjects); @@ -289,7 +289,7 @@ describe('healthRoute', () => { it('evaluates security and tls enabled to mean that the user can generate keys', async () => { const router = httpServiceMock.createRouter(); - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup(); encryptedSavedObjects.usingEphemeralEncryptionKey = false; healthRoute(router, licenseState, encryptedSavedObjects); diff --git a/x-pack/plugins/alerts/server/routes/health.ts b/x-pack/plugins/alerts/server/routes/health.ts index bfd5b1e272287..962ad7e1bb29a 100644 --- a/x-pack/plugins/alerts/server/routes/health.ts +++ b/x-pack/plugins/alerts/server/routes/health.ts @@ -11,7 +11,7 @@ import { IKibanaResponse, KibanaResponseFactory, } from 'kibana/server'; -import { LicenseState } from '../lib/license_state'; +import { ILicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { AlertingFrameworkHealth } from '../types'; import { EncryptedSavedObjectsPluginSetup } from '../../../encrypted_saved_objects/server'; @@ -29,7 +29,7 @@ interface XPackUsageSecurity { export function healthRoute( router: IRouter, - licenseState: LicenseState, + licenseState: ILicenseState, encryptedSavedObjects: EncryptedSavedObjectsPluginSetup ) { router.get( diff --git a/x-pack/plugins/alerts/server/routes/list_alert_types.test.ts b/x-pack/plugins/alerts/server/routes/list_alert_types.test.ts index b18c79fd67484..86baaf86b2d4f 100644 --- a/x-pack/plugins/alerts/server/routes/list_alert_types.test.ts +++ b/x-pack/plugins/alerts/server/routes/list_alert_types.test.ts @@ -6,11 +6,12 @@ import { listAlertTypesRoute } from './list_alert_types'; import { httpServiceMock } from 'src/core/server/mocks'; -import { mockLicenseState } from '../lib/license_state.mock'; +import { licenseStateMock } from '../lib/license_state.mock'; import { verifyApiAccess } from '../lib/license_api_access'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { alertsClientMock } from '../alerts_client.mock'; import { RecoveredActionGroup } from '../../common'; +import { RegistryAlertTypeWithAuth } from '../authorization'; const alertsClient = alertsClientMock.create(); @@ -24,7 +25,7 @@ beforeEach(() => { describe('listAlertTypesRoute', () => { it('lists alert types with proper parameters', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); listAlertTypesRoute(router, licenseState); @@ -44,6 +45,7 @@ describe('listAlertTypesRoute', () => { }, ], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, authorizedConsumers: {}, actionVariables: { @@ -51,7 +53,8 @@ describe('listAlertTypesRoute', () => { state: [], }, producer: 'test', - }, + enabledInLicense: true, + } as RegistryAlertTypeWithAuth, ]; alertsClient.listAlertTypes.mockResolvedValueOnce(new Set(listTypes)); @@ -73,7 +76,9 @@ describe('listAlertTypesRoute', () => { }, "authorizedConsumers": Object {}, "defaultActionGroupId": "default", + "enabledInLicense": true, "id": "1", + "minimumLicenseRequired": "basic", "name": "name", "producer": "test", "recoveryActionGroup": Object { @@ -93,7 +98,7 @@ describe('listAlertTypesRoute', () => { }); it('ensures the license allows listing alert types', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); listAlertTypesRoute(router, licenseState); @@ -113,6 +118,7 @@ describe('listAlertTypesRoute', () => { }, ], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, authorizedConsumers: {}, actionVariables: { @@ -120,7 +126,8 @@ describe('listAlertTypesRoute', () => { state: [], }, producer: 'alerts', - }, + enabledInLicense: true, + } as RegistryAlertTypeWithAuth, ]; alertsClient.listAlertTypes.mockResolvedValueOnce(new Set(listTypes)); @@ -139,7 +146,7 @@ describe('listAlertTypesRoute', () => { }); it('ensures the license check prevents listing alert types', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); (verifyApiAccess as jest.Mock).mockImplementation(() => { @@ -163,6 +170,7 @@ describe('listAlertTypesRoute', () => { }, ], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, authorizedConsumers: {}, actionVariables: { @@ -170,7 +178,8 @@ describe('listAlertTypesRoute', () => { state: [], }, producer: 'alerts', - }, + enabledInLicense: true, + } as RegistryAlertTypeWithAuth, ]; alertsClient.listAlertTypes.mockResolvedValueOnce(new Set(listTypes)); diff --git a/x-pack/plugins/alerts/server/routes/list_alert_types.ts b/x-pack/plugins/alerts/server/routes/list_alert_types.ts index bf516120fbe93..9b4b352e211f1 100644 --- a/x-pack/plugins/alerts/server/routes/list_alert_types.ts +++ b/x-pack/plugins/alerts/server/routes/list_alert_types.ts @@ -11,11 +11,11 @@ import { IKibanaResponse, KibanaResponseFactory, } from 'kibana/server'; -import { LicenseState } from '../lib/license_state'; +import { ILicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { BASE_ALERT_API_PATH } from '../../common'; -export const listAlertTypesRoute = (router: IRouter, licenseState: LicenseState) => { +export const listAlertTypesRoute = (router: IRouter, licenseState: ILicenseState) => { router.get( { path: `${BASE_ALERT_API_PATH}/list_alert_types`, diff --git a/x-pack/plugins/alerts/server/routes/mute_all.test.ts b/x-pack/plugins/alerts/server/routes/mute_all.test.ts index efa3cdebad8ff..2599672e02fb4 100644 --- a/x-pack/plugins/alerts/server/routes/mute_all.test.ts +++ b/x-pack/plugins/alerts/server/routes/mute_all.test.ts @@ -6,9 +6,10 @@ import { muteAllAlertRoute } from './mute_all'; import { httpServiceMock } from 'src/core/server/mocks'; -import { mockLicenseState } from '../lib/license_state.mock'; +import { licenseStateMock } from '../lib/license_state.mock'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { alertsClientMock } from '../alerts_client.mock'; +import { AlertTypeDisabledError } from '../lib/errors/alert_type_disabled'; const alertsClient = alertsClientMock.create(); jest.mock('../lib/license_api_access.ts', () => ({ @@ -21,7 +22,7 @@ beforeEach(() => { describe('muteAllAlertRoute', () => { it('mute an alert', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); muteAllAlertRoute(router, licenseState); @@ -55,4 +56,24 @@ describe('muteAllAlertRoute', () => { expect(res.noContent).toHaveBeenCalled(); }); + + it('ensures the alert type gets validated for the license', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + muteAllAlertRoute(router, licenseState); + + const [, handler] = router.post.mock.calls[0]; + + alertsClient.muteAll.mockRejectedValue(new AlertTypeDisabledError('Fail', 'license_invalid')); + + const [context, req, res] = mockHandlerArguments({ alertsClient }, { params: {}, body: {} }, [ + 'ok', + 'forbidden', + ]); + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } }); + }); }); diff --git a/x-pack/plugins/alerts/server/routes/mute_all.ts b/x-pack/plugins/alerts/server/routes/mute_all.ts index 6735121d4edb0..224216961bb7f 100644 --- a/x-pack/plugins/alerts/server/routes/mute_all.ts +++ b/x-pack/plugins/alerts/server/routes/mute_all.ts @@ -12,15 +12,16 @@ import { IKibanaResponse, KibanaResponseFactory, } from 'kibana/server'; -import { LicenseState } from '../lib/license_state'; +import { ILicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { BASE_ALERT_API_PATH } from '../../common'; +import { AlertTypeDisabledError } from '../lib/errors/alert_type_disabled'; const paramSchema = schema.object({ id: schema.string(), }); -export const muteAllAlertRoute = (router: IRouter, licenseState: LicenseState) => { +export const muteAllAlertRoute = (router: IRouter, licenseState: ILicenseState) => { router.post( { path: `${BASE_ALERT_API_PATH}/alert/{id}/_mute_all`, @@ -39,8 +40,15 @@ export const muteAllAlertRoute = (router: IRouter, licenseState: LicenseState) = } const alertsClient = context.alerting.getAlertsClient(); const { id } = req.params; - await alertsClient.muteAll({ id }); - return res.noContent(); + try { + await alertsClient.muteAll({ id }); + return res.noContent(); + } catch (e) { + if (e instanceof AlertTypeDisabledError) { + return e.sendResponse(res); + } + throw e; + } }) ); }; diff --git a/x-pack/plugins/alerts/server/routes/mute_instance.test.ts b/x-pack/plugins/alerts/server/routes/mute_instance.test.ts index 6e700e4e3fd46..cdfe4c5a80f8a 100644 --- a/x-pack/plugins/alerts/server/routes/mute_instance.test.ts +++ b/x-pack/plugins/alerts/server/routes/mute_instance.test.ts @@ -6,9 +6,10 @@ import { muteAlertInstanceRoute } from './mute_instance'; import { httpServiceMock } from 'src/core/server/mocks'; -import { mockLicenseState } from '../lib/license_state.mock'; +import { licenseStateMock } from '../lib/license_state.mock'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { alertsClientMock } from '../alerts_client.mock'; +import { AlertTypeDisabledError } from '../lib/errors/alert_type_disabled'; const alertsClient = alertsClientMock.create(); jest.mock('../lib/license_api_access.ts', () => ({ @@ -21,7 +22,7 @@ beforeEach(() => { describe('muteAlertInstanceRoute', () => { it('mutes an alert instance', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); muteAlertInstanceRoute(router, licenseState); @@ -59,4 +60,26 @@ describe('muteAlertInstanceRoute', () => { expect(res.noContent).toHaveBeenCalled(); }); + + it('ensures the alert type gets validated for the license', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + muteAlertInstanceRoute(router, licenseState); + + const [, handler] = router.post.mock.calls[0]; + + alertsClient.muteInstance.mockRejectedValue( + new AlertTypeDisabledError('Fail', 'license_invalid') + ); + + const [context, req, res] = mockHandlerArguments({ alertsClient }, { params: {}, body: {} }, [ + 'ok', + 'forbidden', + ]); + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } }); + }); }); diff --git a/x-pack/plugins/alerts/server/routes/mute_instance.ts b/x-pack/plugins/alerts/server/routes/mute_instance.ts index 5e2ffc7d519ed..b374866177231 100644 --- a/x-pack/plugins/alerts/server/routes/mute_instance.ts +++ b/x-pack/plugins/alerts/server/routes/mute_instance.ts @@ -12,18 +12,19 @@ import { IKibanaResponse, KibanaResponseFactory, } from 'kibana/server'; -import { LicenseState } from '../lib/license_state'; +import { ILicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { BASE_ALERT_API_PATH } from '../../common'; import { renameKeys } from './lib/rename_keys'; import { MuteOptions } from '../alerts_client'; +import { AlertTypeDisabledError } from '../lib/errors/alert_type_disabled'; const paramSchema = schema.object({ alert_id: schema.string(), alert_instance_id: schema.string(), }); -export const muteAlertInstanceRoute = (router: IRouter, licenseState: LicenseState) => { +export const muteAlertInstanceRoute = (router: IRouter, licenseState: ILicenseState) => { router.post( { path: `${BASE_ALERT_API_PATH}/alert/{alert_id}/alert_instance/{alert_instance_id}/_mute`, @@ -48,8 +49,15 @@ export const muteAlertInstanceRoute = (router: IRouter, licenseState: LicenseSta }; const renamedQuery = renameKeys>(renameMap, req.params); - await alertsClient.muteInstance(renamedQuery); - return res.noContent(); + try { + await alertsClient.muteInstance(renamedQuery); + return res.noContent(); + } catch (e) { + if (e instanceof AlertTypeDisabledError) { + return e.sendResponse(res); + } + throw e; + } }) ); }; diff --git a/x-pack/plugins/alerts/server/routes/unmute_all.test.ts b/x-pack/plugins/alerts/server/routes/unmute_all.test.ts index 81fdc5bb4dd76..b58d34f25324c 100644 --- a/x-pack/plugins/alerts/server/routes/unmute_all.test.ts +++ b/x-pack/plugins/alerts/server/routes/unmute_all.test.ts @@ -5,9 +5,10 @@ */ import { unmuteAllAlertRoute } from './unmute_all'; import { httpServiceMock } from 'src/core/server/mocks'; -import { mockLicenseState } from '../lib/license_state.mock'; +import { licenseStateMock } from '../lib/license_state.mock'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { alertsClientMock } from '../alerts_client.mock'; +import { AlertTypeDisabledError } from '../lib/errors/alert_type_disabled'; const alertsClient = alertsClientMock.create(); jest.mock('../lib/license_api_access.ts', () => ({ @@ -20,7 +21,7 @@ beforeEach(() => { describe('unmuteAllAlertRoute', () => { it('unmutes an alert', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); unmuteAllAlertRoute(router, licenseState); @@ -54,4 +55,24 @@ describe('unmuteAllAlertRoute', () => { expect(res.noContent).toHaveBeenCalled(); }); + + it('ensures the alert type gets validated for the license', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + unmuteAllAlertRoute(router, licenseState); + + const [, handler] = router.post.mock.calls[0]; + + alertsClient.unmuteAll.mockRejectedValue(new AlertTypeDisabledError('Fail', 'license_invalid')); + + const [context, req, res] = mockHandlerArguments({ alertsClient }, { params: {}, body: {} }, [ + 'ok', + 'forbidden', + ]); + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } }); + }); }); diff --git a/x-pack/plugins/alerts/server/routes/unmute_all.ts b/x-pack/plugins/alerts/server/routes/unmute_all.ts index a987380541696..e249ec7ffa58f 100644 --- a/x-pack/plugins/alerts/server/routes/unmute_all.ts +++ b/x-pack/plugins/alerts/server/routes/unmute_all.ts @@ -12,15 +12,16 @@ import { IKibanaResponse, KibanaResponseFactory, } from 'kibana/server'; -import { LicenseState } from '../lib/license_state'; +import { ILicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { BASE_ALERT_API_PATH } from '../../common'; +import { AlertTypeDisabledError } from '../lib/errors/alert_type_disabled'; const paramSchema = schema.object({ id: schema.string(), }); -export const unmuteAllAlertRoute = (router: IRouter, licenseState: LicenseState) => { +export const unmuteAllAlertRoute = (router: IRouter, licenseState: ILicenseState) => { router.post( { path: `${BASE_ALERT_API_PATH}/alert/{id}/_unmute_all`, @@ -39,8 +40,15 @@ export const unmuteAllAlertRoute = (router: IRouter, licenseState: LicenseState) } const alertsClient = context.alerting.getAlertsClient(); const { id } = req.params; - await alertsClient.unmuteAll({ id }); - return res.noContent(); + try { + await alertsClient.unmuteAll({ id }); + return res.noContent(); + } catch (e) { + if (e instanceof AlertTypeDisabledError) { + return e.sendResponse(res); + } + throw e; + } }) ); }; diff --git a/x-pack/plugins/alerts/server/routes/unmute_instance.test.ts b/x-pack/plugins/alerts/server/routes/unmute_instance.test.ts index 04e97dbe5e538..96985c489d3f5 100644 --- a/x-pack/plugins/alerts/server/routes/unmute_instance.test.ts +++ b/x-pack/plugins/alerts/server/routes/unmute_instance.test.ts @@ -6,9 +6,10 @@ import { unmuteAlertInstanceRoute } from './unmute_instance'; import { httpServiceMock } from 'src/core/server/mocks'; -import { mockLicenseState } from '../lib/license_state.mock'; +import { licenseStateMock } from '../lib/license_state.mock'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { alertsClientMock } from '../alerts_client.mock'; +import { AlertTypeDisabledError } from '../lib/errors/alert_type_disabled'; const alertsClient = alertsClientMock.create(); jest.mock('../lib/license_api_access.ts', () => ({ @@ -21,7 +22,7 @@ beforeEach(() => { describe('unmuteAlertInstanceRoute', () => { it('unmutes an alert instance', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); unmuteAlertInstanceRoute(router, licenseState); @@ -59,4 +60,26 @@ describe('unmuteAlertInstanceRoute', () => { expect(res.noContent).toHaveBeenCalled(); }); + + it('ensures the alert type gets validated for the license', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + unmuteAlertInstanceRoute(router, licenseState); + + const [, handler] = router.post.mock.calls[0]; + + alertsClient.unmuteInstance.mockRejectedValue( + new AlertTypeDisabledError('Fail', 'license_invalid') + ); + + const [context, req, res] = mockHandlerArguments({ alertsClient }, { params: {}, body: {} }, [ + 'ok', + 'forbidden', + ]); + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } }); + }); }); diff --git a/x-pack/plugins/alerts/server/routes/unmute_instance.ts b/x-pack/plugins/alerts/server/routes/unmute_instance.ts index 15b882e585804..bcab6e21578aa 100644 --- a/x-pack/plugins/alerts/server/routes/unmute_instance.ts +++ b/x-pack/plugins/alerts/server/routes/unmute_instance.ts @@ -12,16 +12,17 @@ import { IKibanaResponse, KibanaResponseFactory, } from 'kibana/server'; -import { LicenseState } from '../lib/license_state'; +import { ILicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { BASE_ALERT_API_PATH } from '../../common'; +import { AlertTypeDisabledError } from '../lib/errors/alert_type_disabled'; const paramSchema = schema.object({ alertId: schema.string(), alertInstanceId: schema.string(), }); -export const unmuteAlertInstanceRoute = (router: IRouter, licenseState: LicenseState) => { +export const unmuteAlertInstanceRoute = (router: IRouter, licenseState: ILicenseState) => { router.post( { path: `${BASE_ALERT_API_PATH}/alert/{alertId}/alert_instance/{alertInstanceId}/_unmute`, @@ -40,8 +41,15 @@ export const unmuteAlertInstanceRoute = (router: IRouter, licenseState: LicenseS } const alertsClient = context.alerting.getAlertsClient(); const { alertId, alertInstanceId } = req.params; - await alertsClient.unmuteInstance({ alertId, alertInstanceId }); - return res.noContent(); + try { + await alertsClient.unmuteInstance({ alertId, alertInstanceId }); + return res.noContent(); + } catch (e) { + if (e instanceof AlertTypeDisabledError) { + return e.sendResponse(res); + } + throw e; + } }) ); }; diff --git a/x-pack/plugins/alerts/server/routes/update.test.ts b/x-pack/plugins/alerts/server/routes/update.test.ts index 89619bd853707..96c84616cba70 100644 --- a/x-pack/plugins/alerts/server/routes/update.test.ts +++ b/x-pack/plugins/alerts/server/routes/update.test.ts @@ -6,10 +6,11 @@ import { updateAlertRoute } from './update'; import { httpServiceMock } from 'src/core/server/mocks'; -import { mockLicenseState } from '../lib/license_state.mock'; +import { licenseStateMock } from '../lib/license_state.mock'; import { verifyApiAccess } from '../lib/license_api_access'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { alertsClientMock } from '../alerts_client.mock'; +import { AlertTypeDisabledError } from '../lib/errors/alert_type_disabled'; import { AlertNotifyWhenType } from '../../common'; const alertsClient = alertsClientMock.create(); @@ -46,7 +47,7 @@ describe('updateAlertRoute', () => { }; it('updates an alert with proper parameters', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); updateAlertRoute(router, licenseState); @@ -124,7 +125,7 @@ describe('updateAlertRoute', () => { }); it('ensures the license allows updating alerts', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); updateAlertRoute(router, licenseState); @@ -167,7 +168,7 @@ describe('updateAlertRoute', () => { }); it('ensures the license check prevents updating alerts', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); (verifyApiAccess as jest.Mock).mockImplementation(() => { @@ -212,4 +213,24 @@ describe('updateAlertRoute', () => { expect(verifyApiAccess).toHaveBeenCalledWith(licenseState); }); + + it('ensures the alert type gets validated for the license', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + updateAlertRoute(router, licenseState); + + const [, handler] = router.put.mock.calls[0]; + + alertsClient.update.mockRejectedValue(new AlertTypeDisabledError('Fail', 'license_invalid')); + + const [context, req, res] = mockHandlerArguments({ alertsClient }, { params: {}, body: {} }, [ + 'ok', + 'forbidden', + ]); + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } }); + }); }); diff --git a/x-pack/plugins/alerts/server/routes/update.ts b/x-pack/plugins/alerts/server/routes/update.ts index 96b3156525f79..d3ecc9eb3e381 100644 --- a/x-pack/plugins/alerts/server/routes/update.ts +++ b/x-pack/plugins/alerts/server/routes/update.ts @@ -12,11 +12,12 @@ import { IKibanaResponse, KibanaResponseFactory, } from 'kibana/server'; -import { LicenseState } from '../lib/license_state'; +import { ILicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { validateDurationSchema } from '../lib'; import { handleDisabledApiKeysError } from './lib/error_handler'; import { AlertNotifyWhenType, BASE_ALERT_API_PATH, validateNotifyWhenType } from '../../common'; +import { AlertTypeDisabledError } from '../lib/errors/alert_type_disabled'; const paramSchema = schema.object({ id: schema.string(), @@ -42,7 +43,7 @@ const bodySchema = schema.object({ notifyWhen: schema.nullable(schema.string({ validate: validateNotifyWhenType })), }); -export const updateAlertRoute = (router: IRouter, licenseState: LicenseState) => { +export const updateAlertRoute = (router: IRouter, licenseState: ILicenseState) => { router.put( { path: `${BASE_ALERT_API_PATH}/alert/{id}`, @@ -64,8 +65,8 @@ export const updateAlertRoute = (router: IRouter, licenseState: LicenseState) => const alertsClient = context.alerting.getAlertsClient(); const { id } = req.params; const { name, actions, params, schedule, tags, throttle, notifyWhen } = req.body; - return res.ok({ - body: await alertsClient.update({ + try { + const alertRes = await alertsClient.update({ id, data: { name, @@ -76,8 +77,16 @@ export const updateAlertRoute = (router: IRouter, licenseState: LicenseState) => throttle, notifyWhen: notifyWhen as AlertNotifyWhenType, }, - }), - }); + }); + return res.ok({ + body: alertRes, + }); + } catch (e) { + if (e instanceof AlertTypeDisabledError) { + return e.sendResponse(res); + } + throw e; + } }) ) ); diff --git a/x-pack/plugins/alerts/server/routes/update_api_key.test.ts b/x-pack/plugins/alerts/server/routes/update_api_key.test.ts index 5aa91d215be90..13bd341af2232 100644 --- a/x-pack/plugins/alerts/server/routes/update_api_key.test.ts +++ b/x-pack/plugins/alerts/server/routes/update_api_key.test.ts @@ -6,9 +6,10 @@ import { updateApiKeyRoute } from './update_api_key'; import { httpServiceMock } from 'src/core/server/mocks'; -import { mockLicenseState } from '../lib/license_state.mock'; +import { licenseStateMock } from '../lib/license_state.mock'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { alertsClientMock } from '../alerts_client.mock'; +import { AlertTypeDisabledError } from '../lib/errors/alert_type_disabled'; const alertsClient = alertsClientMock.create(); jest.mock('../lib/license_api_access.ts', () => ({ @@ -21,7 +22,7 @@ beforeEach(() => { describe('updateApiKeyRoute', () => { it('updates api key for an alert', async () => { - const licenseState = mockLicenseState(); + const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); updateApiKeyRoute(router, licenseState); @@ -55,4 +56,26 @@ describe('updateApiKeyRoute', () => { expect(res.noContent).toHaveBeenCalled(); }); + + it('ensures the alert type gets validated for the license', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + updateApiKeyRoute(router, licenseState); + + const [, handler] = router.post.mock.calls[0]; + + alertsClient.updateApiKey.mockRejectedValue( + new AlertTypeDisabledError('Fail', 'license_invalid') + ); + + const [context, req, res] = mockHandlerArguments({ alertsClient }, { params: {}, body: {} }, [ + 'ok', + 'forbidden', + ]); + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } }); + }); }); diff --git a/x-pack/plugins/alerts/server/routes/update_api_key.ts b/x-pack/plugins/alerts/server/routes/update_api_key.ts index d44649b05b929..fb7639d975980 100644 --- a/x-pack/plugins/alerts/server/routes/update_api_key.ts +++ b/x-pack/plugins/alerts/server/routes/update_api_key.ts @@ -12,16 +12,17 @@ import { IKibanaResponse, KibanaResponseFactory, } from 'kibana/server'; -import { LicenseState } from '../lib/license_state'; +import { ILicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { BASE_ALERT_API_PATH } from '../../common'; import { handleDisabledApiKeysError } from './lib/error_handler'; +import { AlertTypeDisabledError } from '../lib/errors/alert_type_disabled'; const paramSchema = schema.object({ id: schema.string(), }); -export const updateApiKeyRoute = (router: IRouter, licenseState: LicenseState) => { +export const updateApiKeyRoute = (router: IRouter, licenseState: ILicenseState) => { router.post( { path: `${BASE_ALERT_API_PATH}/alert/{id}/_update_api_key`, @@ -41,8 +42,15 @@ export const updateApiKeyRoute = (router: IRouter, licenseState: LicenseState) = } const alertsClient = context.alerting.getAlertsClient(); const { id } = req.params; - await alertsClient.updateApiKey({ id }); - return res.noContent(); + try { + await alertsClient.updateApiKey({ id }); + return res.noContent(); + } catch (e) { + if (e instanceof AlertTypeDisabledError) { + return e.sendResponse(res); + } + throw e; + } }) ) ); diff --git a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts index da123f0251a2a..4cb82e9cc86a1 100644 --- a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts @@ -7,7 +7,11 @@ import { AlertType } from '../types'; import { createExecutionHandler } from './create_execution_handler'; import { loggingSystemMock } from '../../../../../src/core/server/mocks'; -import { actionsMock, actionsClientMock } from '../../../actions/server/mocks'; +import { + actionsMock, + actionsClientMock, + renderActionParameterTemplatesDefault, +} from '../../../actions/server/mocks'; import { eventLoggerMock } from '../../../event_log/server/event_logger.mock'; import { KibanaRequest } from 'kibana/server'; import { asSavedObjectExecutionSource } from '../../../actions/server'; @@ -20,6 +24,7 @@ const alertType: AlertType = { { id: 'other-group', name: 'Other Group' }, ], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: { id: 'recovered', name: 'Recovered', @@ -69,6 +74,9 @@ beforeEach(() => { createExecutionHandlerParams.actionsPlugin.getActionsClientWithRequest.mockResolvedValue( actionsClient ); + createExecutionHandlerParams.actionsPlugin.renderActionParameterTemplates.mockImplementation( + renderActionParameterTemplatesDefault + ); }); test('enqueues execution per selected action', async () => { diff --git a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts index 1f73c7103b2df..e02a4a1c823c0 100644 --- a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts +++ b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts @@ -78,7 +78,9 @@ export function createExecutionHandler({ return { ...action, params: transformActionParams({ + actionsPlugin, alertId, + actionTypeId: action.actionTypeId, alertName, spaceId, tags, diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts index d3d0a54417ee3..7545f9a18c4ce 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts @@ -28,15 +28,19 @@ import { IEventLogger } from '../../../event_log/server'; import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; import { Alert, RecoveredActionGroup } from '../../common'; import { omit } from 'lodash'; +import { NormalizedAlertType } from '../alert_type_registry'; +import { alertTypeRegistryMock } from '../alert_type_registry.mock'; const alertType = { id: 'test', name: 'My test alert', actionGroups: [{ id: 'default', name: 'Default' }, RecoveredActionGroup], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, executor: jest.fn(), producer: 'alerts', }; + let fakeTimer: sinon.SinonFakeTimers; describe('Task Runner', () => { @@ -69,6 +73,7 @@ describe('Task Runner', () => { const services = alertsMock.createAlertServices(); const actionsClient = actionsClientMock.create(); const alertsClient = alertsClientMock.create(); + const alertTypeRegistry = alertTypeRegistryMock.create(); const taskRunnerFactoryInitializerParams: jest.Mocked & { actionsPlugin: jest.Mocked; @@ -83,6 +88,7 @@ describe('Task Runner', () => { basePathService: httpServiceMock.createBasePath(), eventLogger: eventLoggerMock.create(), internalSavedObjectsRepository: savedObjectsRepositoryMock.create(), + alertTypeRegistry, }; const mockedAlertTypeSavedObject: Alert = { @@ -137,11 +143,14 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams.actionsPlugin.getActionsClientWithRequest.mockResolvedValue( actionsClient ); + taskRunnerFactoryInitializerParams.actionsPlugin.renderActionParameterTemplates.mockImplementation( + (actionTypeId, params) => params + ); }); test('successfully executes the task', async () => { const taskRunner = new TaskRunner( - alertType, + alertType as NormalizedAlertType, { ...mockedTaskInstance, state: { @@ -237,7 +246,7 @@ describe('Task Runner', () => { } ); const taskRunner = new TaskRunner( - alertType, + alertType as NormalizedAlertType, mockedTaskInstance, taskRunnerFactoryInitializerParams ); @@ -388,7 +397,7 @@ describe('Task Runner', () => { } ); const taskRunner = new TaskRunner( - alertType, + alertType as NormalizedAlertType, mockedTaskInstance, taskRunnerFactoryInitializerParams ); @@ -498,7 +507,7 @@ describe('Task Runner', () => { } ); const taskRunner = new TaskRunner( - alertType, + alertType as NormalizedAlertType, mockedTaskInstance, taskRunnerFactoryInitializerParams ); @@ -543,7 +552,7 @@ describe('Task Runner', () => { } ); const taskRunner = new TaskRunner( - alertType, + alertType as NormalizedAlertType, { ...mockedTaskInstance, state: { @@ -637,7 +646,7 @@ describe('Task Runner', () => { } ); const taskRunner = new TaskRunner( - alertType, + alertType as NormalizedAlertType, { ...mockedTaskInstance, state: { @@ -679,7 +688,7 @@ describe('Task Runner', () => { } ); const taskRunner = new TaskRunner( - alertType, + alertType as NormalizedAlertType, { ...mockedTaskInstance, state: { @@ -725,7 +734,7 @@ describe('Task Runner', () => { } ); const taskRunner = new TaskRunner( - alertType, + alertType as NormalizedAlertType, mockedTaskInstance, taskRunnerFactoryInitializerParams ); @@ -893,7 +902,7 @@ describe('Task Runner', () => { } ); const taskRunner = new TaskRunner( - alertType, + alertType as NormalizedAlertType, { ...mockedTaskInstance, state: { @@ -993,7 +1002,7 @@ describe('Task Runner', () => { } ); const taskRunner = new TaskRunner( - alertTypeWithCustomRecovery, + alertTypeWithCustomRecovery as NormalizedAlertType, { ...mockedTaskInstance, state: { @@ -1085,7 +1094,7 @@ describe('Task Runner', () => { ); const date = new Date().toISOString(); const taskRunner = new TaskRunner( - alertType, + alertType as NormalizedAlertType, { ...mockedTaskInstance, state: { @@ -1215,7 +1224,7 @@ describe('Task Runner', () => { param1: schema.string(), }), }, - }, + } as NormalizedAlertType, mockedTaskInstance, taskRunnerFactoryInitializerParams ); @@ -1243,7 +1252,7 @@ describe('Task Runner', () => { test('uses API key when provided', async () => { const taskRunner = new TaskRunner( - alertType, + alertType as NormalizedAlertType, mockedTaskInstance, taskRunnerFactoryInitializerParams ); @@ -1276,7 +1285,7 @@ describe('Task Runner', () => { test(`doesn't use API key when not provided`, async () => { const taskRunner = new TaskRunner( - alertType, + alertType as NormalizedAlertType, mockedTaskInstance, taskRunnerFactoryInitializerParams ); @@ -1306,7 +1315,7 @@ describe('Task Runner', () => { test('rescheduled the Alert if the schedule has update during a task run', async () => { const taskRunner = new TaskRunner( - alertType, + alertType as NormalizedAlertType, mockedTaskInstance, taskRunnerFactoryInitializerParams ); @@ -1347,7 +1356,7 @@ describe('Task Runner', () => { ); const taskRunner = new TaskRunner( - alertType, + alertType as NormalizedAlertType, mockedTaskInstance, taskRunnerFactoryInitializerParams ); @@ -1414,7 +1423,7 @@ describe('Task Runner', () => { }); const taskRunner = new TaskRunner( - alertType, + alertType as NormalizedAlertType, mockedTaskInstance, taskRunnerFactoryInitializerParams ); @@ -1467,13 +1476,80 @@ describe('Task Runner', () => { `); }); + test('recovers gracefully when the Alert Task Runner throws an exception when license is higher than supported', async () => { + alertTypeRegistry.ensureAlertTypeEnabled.mockImplementation(() => { + throw new Error('OMG'); + }); + + const taskRunner = new TaskRunner( + alertType as NormalizedAlertType, + mockedTaskInstance, + taskRunnerFactoryInitializerParams + ); + + alertsClient.get.mockResolvedValue(mockedAlertTypeSavedObject); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + + const runnerResult = await taskRunner.run(); + + expect(runnerResult).toMatchInlineSnapshot(` + Object { + "schedule": Object { + "interval": "10s", + }, + "state": Object {}, + } + `); + + const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; + expect(eventLogger.logEvent).toHaveBeenCalledTimes(1); + expect(eventLogger.logEvent.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "@timestamp": "1970-01-01T00:00:00.000Z", + "error": Object { + "message": "OMG", + }, + "event": Object { + "action": "execute", + "outcome": "failure", + "reason": "license", + }, + "kibana": Object { + "alerting": Object { + "status": "error", + }, + "saved_objects": Array [ + Object { + "id": "1", + "namespace": undefined, + "rel": "primary", + "type": "alert", + }, + ], + }, + "message": "test:1: execution failed", + }, + ], + ] + `); + }); + test('recovers gracefully when the Alert Task Runner throws an exception when getting internal Services', async () => { taskRunnerFactoryInitializerParams.getServices.mockImplementation(() => { throw new Error('OMG'); }); const taskRunner = new TaskRunner( - alertType, + alertType as NormalizedAlertType, mockedTaskInstance, taskRunnerFactoryInitializerParams ); @@ -1540,7 +1616,7 @@ describe('Task Runner', () => { }); const taskRunner = new TaskRunner( - alertType, + alertType as NormalizedAlertType, mockedTaskInstance, taskRunnerFactoryInitializerParams ); @@ -1610,7 +1686,7 @@ describe('Task Runner', () => { const legacyTaskInstance = omit(mockedTaskInstance, 'schedule'); const taskRunner = new TaskRunner( - alertType, + alertType as NormalizedAlertType, legacyTaskInstance, taskRunnerFactoryInitializerParams ); @@ -1648,7 +1724,7 @@ describe('Task Runner', () => { ); const taskRunner = new TaskRunner( - alertType, + alertType as NormalizedAlertType, { ...mockedTaskInstance, state: originalAlertSate, @@ -1679,7 +1755,7 @@ describe('Task Runner', () => { }); const taskRunner = new TaskRunner( - alertType, + alertType as NormalizedAlertType, mockedTaskInstance, taskRunnerFactoryInitializerParams ); diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.ts index 2073528f2c75e..17ab090610745 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.ts @@ -30,6 +30,7 @@ import { SanitizedAlert, AlertExecutionStatus, AlertExecutionStatusErrorReasons, + AlertTypeRegistry, } from '../types'; import { promiseResult, map, Resultable, asOk, asErr, resolveErr } from '../lib/result_type'; import { taskInstanceToAlertTaskInstance } from './alert_task_instance'; @@ -59,6 +60,7 @@ export class TaskRunner { private logger: Logger; private taskInstance: AlertTaskInstance; private alertType: NormalizedAlertType; + private readonly alertTypeRegistry: AlertTypeRegistry; constructor( alertType: NormalizedAlertType, @@ -69,6 +71,7 @@ export class TaskRunner { this.logger = context.logger; this.alertType = alertType; this.taskInstance = taskInstanceToAlertTaskInstance(taskInstance); + this.alertTypeRegistry = context.alertTypeRegistry; } async getApiKeyForAlertPermissions(alertId: string, spaceId: string) { @@ -365,6 +368,11 @@ export class TaskRunner { throw new ErrorWithReason(AlertExecutionStatusErrorReasons.Read, err); } + try { + this.alertTypeRegistry.ensureAlertTypeEnabled(alert.alertTypeId); + } catch (err) { + throw new ErrorWithReason(AlertExecutionStatusErrorReasons.License, err); + } return { state: await promiseResult( this.validateAndExecuteAlert(services, apiKey, alert, event) diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner_factory.test.ts b/x-pack/plugins/alerts/server/task_runner/task_runner_factory.test.ts index 4c685d2fdec82..6c58b64fffa92 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner_factory.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner_factory.test.ts @@ -16,12 +16,15 @@ import { import { actionsMock } from '../../../actions/server/mocks'; import { alertsMock, alertsClientMock } from '../mocks'; import { eventLoggerMock } from '../../../event_log/server/event_logger.mock'; +import { NormalizedAlertType } from '../alert_type_registry'; +import { alertTypeRegistryMock } from '../alert_type_registry.mock'; -const alertType = { +const alertType: NormalizedAlertType = { id: 'test', name: 'My test alert', actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', recoveryActionGroup: { id: 'recovered', name: 'Recovered', @@ -72,6 +75,7 @@ describe('Task Runner Factory', () => { basePathService: httpServiceMock.createBasePath(), eventLogger: eventLoggerMock.create(), internalSavedObjectsRepository: savedObjectsRepositoryMock.create(), + alertTypeRegistry: alertTypeRegistryMock.create(), }; beforeEach(() => { diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner_factory.ts b/x-pack/plugins/alerts/server/task_runner/task_runner_factory.ts index 405afbf53c075..1fe94972bd4b0 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner_factory.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner_factory.ts @@ -13,7 +13,7 @@ import { import { RunContext } from '../../../task_manager/server'; import { EncryptedSavedObjectsClient } from '../../../encrypted_saved_objects/server'; import { PluginStartContract as ActionsPluginStartContract } from '../../../actions/server'; -import { GetServicesFunction, SpaceIdToNamespaceFunction } from '../types'; +import { AlertTypeRegistry, GetServicesFunction, SpaceIdToNamespaceFunction } from '../types'; import { TaskRunner } from './task_runner'; import { IEventLogger } from '../../../event_log/server'; import { AlertsClient } from '../alerts_client'; @@ -29,6 +29,7 @@ export interface TaskRunnerContext { spaceIdToNamespace: SpaceIdToNamespaceFunction; basePathService: IBasePath; internalSavedObjectsRepository: ISavedObjectsRepository; + alertTypeRegistry: AlertTypeRegistry; } export class TaskRunnerFactory { diff --git a/x-pack/plugins/alerts/server/task_runner/transform_action_params.test.ts b/x-pack/plugins/alerts/server/task_runner/transform_action_params.test.ts index 782b9fc07207b..39468c2913b5f 100644 --- a/x-pack/plugins/alerts/server/task_runner/transform_action_params.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/transform_action_params.test.ts @@ -5,6 +5,17 @@ */ import { transformActionParams } from './transform_action_params'; +import { actionsMock, renderActionParameterTemplatesDefault } from '../../../actions/server/mocks'; + +const actionsPlugin = actionsMock.createStart(); +const actionTypeId = 'test-actionTypeId'; + +beforeEach(() => { + jest.resetAllMocks(); + actionsPlugin.renderActionParameterTemplates.mockImplementation( + renderActionParameterTemplatesDefault + ); +}); test('skips non string parameters', () => { const actionParams = { @@ -16,6 +27,8 @@ test('skips non string parameters', () => { message: 'Value "{{params.foo}}" exists', }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, context: {}, state: {}, @@ -48,6 +61,8 @@ test('missing parameters get emptied out', () => { message2: 'This message "{{context.value2}}" is missing', }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, context: {}, state: {}, @@ -73,6 +88,8 @@ test('context parameters are passed to templates', () => { message: 'Value "{{context.foo}}" exists', }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, state: {}, context: { foo: 'fooVal' }, @@ -97,6 +114,8 @@ test('state parameters are passed to templates', () => { message: 'Value "{{state.bar}}" exists', }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, state: { bar: 'barVal' }, context: {}, @@ -121,6 +140,8 @@ test('alertId is passed to templates', () => { message: 'Value "{{alertId}}" exists', }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, state: {}, context: {}, @@ -145,6 +166,8 @@ test('alertName is passed to templates', () => { message: 'Value "{{alertName}}" exists', }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, state: {}, context: {}, @@ -169,6 +192,8 @@ test('tags is passed to templates', () => { message: 'Value "{{tags}}" exists', }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, state: {}, context: {}, @@ -193,6 +218,8 @@ test('undefined tags is passed to templates', () => { message: 'Value "{{tags}}" is undefined and renders as empty string', }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, state: {}, context: {}, @@ -216,6 +243,8 @@ test('empty tags is passed to templates', () => { message: 'Value "{{tags}}" is an empty array and renders as empty string', }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, state: {}, context: {}, @@ -240,6 +269,8 @@ test('spaceId is passed to templates', () => { message: 'Value "{{spaceId}}" exists', }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, state: {}, context: {}, @@ -264,6 +295,8 @@ test('alertInstanceId is passed to templates', () => { message: 'Value "{{alertInstanceId}}" exists', }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, state: {}, context: {}, @@ -288,6 +321,8 @@ test('alertActionGroup is passed to templates', () => { message: 'Value "{{alertActionGroup}}" exists', }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, state: {}, context: {}, @@ -312,6 +347,8 @@ test('alertActionGroupName is passed to templates', () => { message: 'Value "{{alertActionGroupName}}" exists', }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, state: {}, context: {}, @@ -337,6 +374,8 @@ test('date is passed to templates', () => { }; const dateBefore = Date.now(); const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, state: {}, context: {}, @@ -363,6 +402,8 @@ test('works recursively', () => { }, }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, state: { value: 'state' }, context: { value: 'context' }, @@ -391,6 +432,8 @@ test('works recursively with arrays', () => { }, }; const result = transformActionParams({ + actionsPlugin, + actionTypeId, actionParams, state: { value: 'state' }, context: { value: 'context' }, diff --git a/x-pack/plugins/alerts/server/task_runner/transform_action_params.ts b/x-pack/plugins/alerts/server/task_runner/transform_action_params.ts index 9cb746ee197a4..669e11a354a41 100644 --- a/x-pack/plugins/alerts/server/task_runner/transform_action_params.ts +++ b/x-pack/plugins/alerts/server/task_runner/transform_action_params.ts @@ -4,17 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import Mustache from 'mustache'; -import { isString, cloneDeepWith } from 'lodash'; import { AlertActionParams, AlertInstanceState, AlertInstanceContext, AlertTypeParams, } from '../types'; +import { PluginStartContract as ActionsPluginStartContract } from '../../../actions/server'; interface TransformActionParamsOptions { + actionsPlugin: ActionsPluginStartContract; alertId: string; + actionTypeId: string; alertName: string; spaceId: string; tags?: string[]; @@ -29,7 +30,9 @@ interface TransformActionParamsOptions { } export function transformActionParams({ + actionsPlugin, alertId, + actionTypeId, alertName, spaceId, tags, @@ -42,31 +45,22 @@ export function transformActionParams({ state, alertParams, }: TransformActionParamsOptions): AlertActionParams { - const result = cloneDeepWith(actionParams, (value: unknown) => { - if (!isString(value)) return; - - // when the list of variables we pass in here changes, - // the UI will need to be updated as well; see: - // x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.ts - const variables = { - alertId, - alertName, - spaceId, - tags, - alertInstanceId, - alertActionGroup, - alertActionGroupName, - alertActionSubgroup, - context, - date: new Date().toISOString(), - state, - params: alertParams, - }; - return Mustache.render(value, variables); - }); - - // The return type signature for `cloneDeep()` ends up taking the return - // type signature for the customizer, but rather than pollute the customizer - // with casts, seemed better to just do it in one place, here. - return (result as unknown) as AlertActionParams; + // when the list of variables we pass in here changes, + // the UI will need to be updated as well; see: + // x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.ts + const variables = { + alertId, + alertName, + spaceId, + tags, + alertInstanceId, + alertActionGroup, + alertActionGroupName, + alertActionSubgroup, + context, + date: new Date().toISOString(), + state, + params: alertParams, + }; + return actionsPlugin.renderActionParameterTemplates(actionTypeId, actionParams, variables); } diff --git a/x-pack/plugins/alerts/server/types.ts b/x-pack/plugins/alerts/server/types.ts index a5aee8dbf3b60..8704068c3e51a 100644 --- a/x-pack/plugins/alerts/server/types.ts +++ b/x-pack/plugins/alerts/server/types.ts @@ -30,6 +30,7 @@ import { AlertsHealth, AlertNotifyWhenType, } from '../common'; +import { LicenseType } from '../../licensing/server'; export type WithoutQueryAndParams = Pick>; export type GetServicesFunction = (request: KibanaRequest) => Services; @@ -84,6 +85,16 @@ export interface ActionVariable { description: string; } +// signature of the alert type executor function +export type ExecutorType< + Params, + State, + InstanceState extends AlertInstanceState = AlertInstanceState, + InstanceContext extends AlertInstanceContext = AlertInstanceContext +> = ( + options: AlertExecutorOptions +) => Promise; + export interface AlertType< Params extends AlertTypeParams = AlertTypeParams, State extends AlertTypeState = AlertTypeState, @@ -98,17 +109,14 @@ export interface AlertType< actionGroups: ActionGroup[]; defaultActionGroupId: ActionGroup['id']; recoveryActionGroup?: ActionGroup; - executor: ({ - services, - params, - state, - }: AlertExecutorOptions) => Promise; + executor: ExecutorType; producer: string; actionVariables?: { context?: ActionVariable[]; state?: ActionVariable[]; params?: ActionVariable[]; }; + minimumLicenseRequired: LicenseType; } export interface RawAlertAction extends SavedObjectAttributes { diff --git a/x-pack/plugins/alerts/server/usage/alerts_telemetry.ts b/x-pack/plugins/alerts/server/usage/alerts_telemetry.ts index 389178de3e5d0..6edebb1decb61 100644 --- a/x-pack/plugins/alerts/server/usage/alerts_telemetry.ts +++ b/x-pack/plugins/alerts/server/usage/alerts_telemetry.ts @@ -215,7 +215,6 @@ export async function getTotalCountAggregations(callCluster: LegacyAPICaller, ki const results = await callCluster('search', { index: kibanaInex, - rest_total_hits_as_int: true, body: { query: { bool: { @@ -289,7 +288,6 @@ export async function getTotalCountAggregations(callCluster: LegacyAPICaller, ki export async function getTotalCountInUse(callCluster: LegacyAPICaller, kibanaInex: string) { const searchResult: SearchResponse = await callCluster('search', { index: kibanaInex, - rest_total_hits_as_int: true, body: { query: { bool: { diff --git a/x-pack/plugins/apm/common/alert_types.ts b/x-pack/plugins/apm/common/alert_types.ts index a234226d18034..7cc36253ef581 100644 --- a/x-pack/plugins/apm/common/alert_types.ts +++ b/x-pack/plugins/apm/common/alert_types.ts @@ -29,6 +29,7 @@ export const ALERT_TYPES_CONFIG = { }), actionGroups: [THRESHOLD_MET_GROUP], defaultActionGroupId: 'threshold_met', + minimumLicenseRequired: 'basic', producer: 'apm', }, [AlertType.TransactionDuration]: { @@ -37,6 +38,7 @@ export const ALERT_TYPES_CONFIG = { }), actionGroups: [THRESHOLD_MET_GROUP], defaultActionGroupId: 'threshold_met', + minimumLicenseRequired: 'basic', producer: 'apm', }, [AlertType.TransactionDurationAnomaly]: { @@ -45,6 +47,7 @@ export const ALERT_TYPES_CONFIG = { }), actionGroups: [THRESHOLD_MET_GROUP], defaultActionGroupId: 'threshold_met', + minimumLicenseRequired: 'basic', producer: 'apm', }, [AlertType.TransactionErrorRate]: { @@ -53,6 +56,7 @@ export const ALERT_TYPES_CONFIG = { }), actionGroups: [THRESHOLD_MET_GROUP], defaultActionGroupId: 'threshold_met', + minimumLicenseRequired: 'basic', producer: 'apm', }, }; diff --git a/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx b/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx index aa1d21dd1d580..88a897d7baf50 100644 --- a/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { AlertType } from '../../../../common/alert_types'; import { TriggersAndActionsUIPublicPluginStart } from '../../../../../triggers_actions_ui/public'; @@ -23,17 +23,21 @@ export function AlertingFlyout(props: Props) { const { services: { triggersActionsUi }, } = useKibana(); + + const onCloseAddFlyout = useCallback(() => setAddFlyoutVisibility(false), [ + setAddFlyoutVisibility, + ]); + const addAlertFlyout = useMemo( () => alertType && triggersActionsUi.getAddAlertFlyout({ consumer: 'apm', - addFlyoutVisible, - setAddFlyoutVisibility, + onClose: onCloseAddFlyout, alertTypeId: alertType, canChangeTrigger: false, }), - [addFlyoutVisible, alertType, setAddFlyoutVisibility, triggersActionsUi] + [alertType, onCloseAddFlyout, triggersActionsUi] ); - return <>{addAlertFlyout}; + return <>{addFlyoutVisible && addAlertFlyout}; } diff --git a/x-pack/plugins/apm/public/components/app/Correlations/ErrorCorrelations.tsx b/x-pack/plugins/apm/public/components/app/Correlations/ErrorCorrelations.tsx index 07ab89afd4108..25973b9bda388 100644 --- a/x-pack/plugins/apm/public/components/app/Correlations/ErrorCorrelations.tsx +++ b/x-pack/plugins/apm/public/components/app/Correlations/ErrorCorrelations.tsx @@ -16,7 +16,13 @@ import { } from '@elastic/charts'; import React, { useState } from 'react'; import { useParams } from 'react-router-dom'; -import { EuiTitle, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiComboBox, + EuiAccordion, +} from '@elastic/eui'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; import { @@ -35,12 +41,26 @@ type SignificantTerm = NonNullable< CorrelationsApiResponse['significantTerms'] >[0]; +const initialFieldNames = [ + 'transaction.name', + 'user.username', + 'user.id', + 'host.ip', + 'user_agent.name', + 'kubernetes.pod.uuid', + 'kubernetes.pod.name', + 'url.domain', + 'container.id', + 'service.node.name', +].map((label) => ({ label })); + export function ErrorCorrelations() { const [ selectedSignificantTerm, setSelectedSignificantTerm, ] = useState(null); + const [fieldNames, setFieldNames] = useState(initialFieldNames); const { serviceName } = useParams<{ serviceName?: string }>(); const { urlParams, uiFilters } = useUrlParams(); const { transactionName, transactionType, start, end } = urlParams; @@ -57,13 +77,20 @@ export function ErrorCorrelations() { start, end, uiFilters: JSON.stringify(uiFilters), - fieldNames: - 'transaction.name,user.username,user.id,host.ip,user_agent.name,kubernetes.pod.uuid,kubernetes.pod.name,url.domain,container.id,service.node.name', + fieldNames: fieldNames.map((field) => field.label).join(','), }, }, }); } - }, [serviceName, start, end, transactionName, transactionType, uiFilters]); + }, [ + serviceName, + start, + end, + transactionName, + transactionType, + uiFilters, + fieldNames, + ]); return ( <> @@ -72,14 +99,30 @@ export function ErrorCorrelations() {

Error rate over time

+ + + + + + setFieldNames((names) => [...names, { label: term }]) + } + /> + + - + [0]; +const initialFieldNames = [ + 'user.username', + 'user.id', + 'host.ip', + 'user_agent.name', + 'kubernetes.pod.uuid', + 'kubernetes.pod.name', + 'url.domain', + 'container.id', + 'service.node.name', +].map((label) => ({ label })); + export function LatencyCorrelations() { const [ selectedSignificantTerm, setSelectedSignificantTerm, ] = useState(null); + const [fieldNames, setFieldNames] = useState(initialFieldNames); + const [durationPercentile, setDurationPercentile] = useState('50'); const { serviceName } = useParams<{ serviceName?: string }>(); const { urlParams, uiFilters } = useUrlParams(); const { transactionName, transactionType, start, end } = urlParams; @@ -58,30 +77,28 @@ export function LatencyCorrelations() { start, end, uiFilters: JSON.stringify(uiFilters), - durationPercentile: '50', - fieldNames: - 'user.username,user.id,host.ip,user_agent.name,kubernetes.pod.uuid,kubernetes.pod.name,url.domain,container.id,service.node.name', + durationPercentile, + fieldNames: fieldNames.map((field) => field.label).join(','), }, }, }); } - }, [serviceName, start, end, transactionName, transactionType, uiFilters]); + }, [ + serviceName, + start, + end, + transactionName, + transactionType, + uiFilters, + durationPercentile, + fieldNames, + ]); return ( <> - - -

Average latency over time

-
- -

Latency distribution

@@ -94,8 +111,42 @@ export function LatencyCorrelations() {
+ + + + + + + setDurationPercentile(e.currentTarget.value) + } + /> + + + + + { + setFieldNames((names) => [...names, { label: term }]); + }} + /> + + + + + p.y ?? 0), - ...data.significantTerms.flatMap((term) => - term.timeseries.map((p) => p.y ?? 0) - ), - ]; - return Math.max(...yValues); -} - function getDistributionYMax(data?: CorrelationsApiResponse) { if (!data?.overall) { return 0; @@ -134,65 +171,6 @@ function getDistributionYMax(data?: CorrelationsApiResponse) { return Math.max(...yValues); } -function LatencyTimeseriesChart({ - data, - selectedSignificantTerm, - status, -}: { - data?: CorrelationsApiResponse; - selectedSignificantTerm: SignificantTerm | null; - status: FETCH_STATUS; -}) { - const dateFormatter = timeFormatter('HH:mm:ss'); - - const yMax = getTimeseriesYMax(data); - const durationFormatter = getDurationFormatter(yMax); - - return ( - - - - - - durationFormatter(d).formatted} - /> - - - - {selectedSignificantTerm !== null ? ( - - ) : null} - - - ); -} - function LatencyDistributionChart({ data, selectedSignificantTerm, diff --git a/x-pack/plugins/apm/public/components/app/Correlations/SignificantTermsTable.tsx b/x-pack/plugins/apm/public/components/app/Correlations/SignificantTermsTable.tsx index 350f64367b766..a3989d22abf68 100644 --- a/x-pack/plugins/apm/public/components/app/Correlations/SignificantTermsTable.tsx +++ b/x-pack/plugins/apm/public/components/app/Correlations/SignificantTermsTable.tsx @@ -5,13 +5,15 @@ */ import React from 'react'; -import { EuiBadge, EuiIcon, EuiToolTip, EuiLink } from '@elastic/eui'; +import { EuiIcon, EuiLink } from '@elastic/eui'; import { useHistory } from 'react-router-dom'; import { EuiBasicTable } from '@elastic/eui'; -import { asPercent, asInteger } from '../../../../common/utils/formatters'; +import { EuiBasicTableColumn } from '@elastic/eui'; +import { EuiCode } from '@elastic/eui'; +import { asInteger, asPercent } from '../../../../common/utils/formatters'; import { APIReturnType } from '../../../services/rest/createCallApmApi'; import { FETCH_STATUS } from '../../../hooks/use_fetcher'; -import { createHref } from '../../shared/Links/url_helpers'; +import { createHref, push } from '../../shared/Links/url_helpers'; type CorrelationsApiResponse = | APIReturnType<'GET /api/apm/correlations/failed_transactions'> @@ -24,39 +26,32 @@ type SignificantTerm = NonNullable< interface Props { significantTerms?: T[]; status: FETCH_STATUS; + cardinalityColumnName: string; setSelectedSignificantTerm: (term: T | null) => void; } export function SignificantTermsTable({ significantTerms, status, + cardinalityColumnName, setSelectedSignificantTerm, }: Props) { const history = useHistory(); - const columns = [ + const columns: Array> = [ { - field: 'matches', - name: 'Matches', + width: '100px', + field: 'score', + name: 'Score', render: (_: any, term: T) => { - return ( - - <> - 0.03 ? 'primary' : 'secondary' - } - > - {asPercent(term.fgCount, term.bgCount)} - - ({Math.round(term.score)}) - - - ); + return {Math.round(term.score)}; + }, + }, + { + field: 'cardinality', + name: cardinalityColumnName, + render: (_: any, term: T) => { + const matches = asPercent(term.fgCount, term.bgCount); + return `${asInteger(term.fgCount)} (${matches})`; }, }, { @@ -64,13 +59,45 @@ export function SignificantTermsTable({ name: 'Field name', }, { - field: 'filedValue', + field: 'fieldValue', name: 'Field value', render: (_: any, term: T) => String(term.fieldValue).slice(0, 50), }, { - field: 'filedValue', - name: '', + width: '100px', + actions: [ + { + name: 'Focus', + description: 'Focus on this term', + icon: 'magnifyWithPlus', + type: 'icon', + onClick: (term: T) => { + push(history, { + query: { + kuery: `${term.fieldName}:"${encodeURIComponent( + term.fieldValue + )}"`, + }, + }); + }, + }, + { + name: 'Exclude', + description: 'Exclude this term', + icon: 'magnifyWithMinus', + type: 'icon', + onClick: (term: T) => { + push(history, { + query: { + kuery: `not ${term.fieldName}:"${encodeURIComponent( + term.fieldValue + )}"`, + }, + }); + }, + }, + ], + name: 'Actions', render: (_: any, term: T) => { return ( <> @@ -85,6 +112,7 @@ export function SignificantTermsTable({ > +  /  - View correlations + View significant terms + + {isFlyoutVisible && ( setIsFlyoutVisible(false)} > -

Correlations

+

Significant terms

{urlParams.kuery ? ( - - Filtering by - {urlParams.kuery} - - Clear - - + <> + + Filtering by + {urlParams.kuery} + + Clear + + + + ) : null} + +

+ Significant terms is an experimental feature and in active + development. Bugs and surprises are to be expected but let us + know your feedback so we can improve it. +

+
+ + +
diff --git a/x-pack/plugins/apm/public/components/app/TraceOverview/index.tsx b/x-pack/plugins/apm/public/components/app/TraceOverview/index.tsx index ab10d6b4f46a0..399455e4150d4 100644 --- a/x-pack/plugins/apm/public/components/app/TraceOverview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TraceOverview/index.tsx @@ -14,6 +14,7 @@ import { APIReturnType } from '../../../services/rest/createCallApmApi'; import { LocalUIFilters } from '../../shared/LocalUIFilters'; import { SearchBar } from '../../shared/search_bar'; import { TraceList } from './TraceList'; +import { Correlations } from '../Correlations'; type TracesAPIResponse = APIReturnType<'GET /api/apm/traces'>; const DEFAULT_RESPONSE: TracesAPIResponse = { @@ -61,6 +62,7 @@ export function TraceOverview() { + diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/TransactionTabs.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/TransactionTabs.tsx index 331fa17ba8bf8..11d5987a74f76 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/TransactionTabs.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/TransactionTabs.tsx @@ -135,6 +135,7 @@ function LogsTabContent({ transaction }: { transaction: Transaction }) { startTimestamp={startTimestamp - framePaddingMs} endTimestamp={endTimestamp + framePaddingMs} query={`trace.id:"${transaction.trace.id}" OR "${transaction.trace.id}"`} + height={640} /> ); } diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/ServiceList/ServiceListMetric.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/ServiceList/ServiceListMetric.tsx index 77257f5af7c7e..f5dc0ca162b01 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/ServiceList/ServiceListMetric.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/ServiceList/ServiceListMetric.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; -import { SparkPlotWithValueLabel } from '../../../shared/charts/spark_plot/spark_plot_with_value_label'; +import { SparkPlot } from '../../../shared/charts/spark_plot'; export function ServiceListMetric({ color, @@ -15,11 +15,5 @@ export function ServiceListMetric({ series?: Array<{ x: number; y: number | null }>; valueLabel: React.ReactNode; }) { - return ( - - ); + return ; } diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx index b1d725bba0ca9..fba9cecac0144 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx @@ -23,7 +23,6 @@ import { useLocalStorage } from '../../../hooks/useLocalStorage'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { LocalUIFilters } from '../../shared/LocalUIFilters'; import { SearchBar } from '../../shared/search_bar'; -import { Correlations } from '../Correlations'; import { NoServicesMessage } from './no_services_message'; import { ServiceList } from './ServiceList'; import { MLCallout } from './ServiceList/MLCallout'; @@ -138,7 +137,6 @@ export function ServiceInventory() { - diff --git a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx index 1a86e7baac83f..6db5b1ae7bc7c 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx @@ -4,14 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiFlexGroup, - EuiFlexItem, - EuiPage, - EuiPanel, - EuiTitle, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiPage, EuiPanel } from '@elastic/eui'; import React from 'react'; import { useTrackPageview } from '../../../../../observability/public'; import { isRumAgentName } from '../../../../common/agent_name'; @@ -23,6 +16,7 @@ import { TransactionErrorRateChart } from '../../shared/charts/transaction_error import { SearchBar } from '../../shared/search_bar'; import { ServiceOverviewDependenciesTable } from './service_overview_dependencies_table'; import { ServiceOverviewErrorsTable } from './service_overview_errors_table'; +import { ServiceOverviewInstancesTable } from './service_overview_instances_table'; import { ServiceOverviewThroughputChart } from './service_overview_throughput_chart'; import { ServiceOverviewTransactionsTable } from './service_overview_transactions_table'; @@ -101,36 +95,9 @@ export function ServiceOverview({ - - - - -

- {i18n.translate( - 'xpack.apm.serviceOverview.instancesLatencyDistributionChartTitle', - { - defaultMessage: 'Instances latency distribution', - } - )} -

-
-
-
- - - -

- {i18n.translate( - 'xpack.apm.serviceOverview.instancesTableTitle', - { - defaultMessage: 'Instances', - } - )} -

-
-
-
-
+ + +
diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx index 87ff702e0a960..b27941eee9beb 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx @@ -27,12 +27,12 @@ import { TruncateWithTooltip } from '../../../shared/truncate_with_tooltip'; import { TableLinkFlexItem } from '../table_link_flex_item'; import { AgentIcon } from '../../../shared/AgentIcon'; import { TableFetchWrapper } from '../../../shared/table_fetch_wrapper'; -import { SparkPlotWithValueLabel } from '../../../shared/charts/spark_plot/spark_plot_with_value_label'; +import { SparkPlot } from '../../../shared/charts/spark_plot'; import { px, unit } from '../../../../style/variables'; import { ImpactBar } from '../../../shared/ImpactBar'; import { ServiceOverviewLink } from '../../../shared/Links/apm/service_overview_link'; import { SpanIcon } from '../../../shared/span_icon'; -import { ServiceOverviewTableContainer } from '../service_overview_table'; +import { ServiceOverviewTableContainer } from '../service_overview_table_container'; interface Props { serviceName: string; @@ -88,7 +88,7 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) { width: px(unit * 10), render: (_, { latency }) => { return ( - { return ( - { return ( - { return ( - - { - setTableOptions({ - pageIndex: newTableOptions.page?.index ?? 0, - sort: newTableOptions.sort - ? { - field: newTableOptions.sort.field as SortField, - direction: newTableOptions.sort.direction, - } - : DEFAULT_SORT, - }); - }} - sorting={{ - enableAllColumns: true, - sort: { - direction: sort.direction, - field: sort.field, - }, - }} - /> + + { + setTableOptions({ + pageIndex: newTableOptions.page?.index ?? 0, + sort: newTableOptions.sort + ? { + field: newTableOptions.sort.field as SortField, + direction: newTableOptions.sort.direction, + } + : DEFAULT_SORT, + }); + }} + sorting={{ + enableAllColumns: true, + sort: { + direction: sort.direction, + field: sort.field, + }, + }} + /> +
diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx new file mode 100644 index 0000000000000..c9b4801883160 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx @@ -0,0 +1,276 @@ +/* + * 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 { EuiFlexItem } from '@elastic/eui'; +import { EuiInMemoryTable } from '@elastic/eui'; +import { EuiTitle } from '@elastic/eui'; +import { EuiBasicTableColumn } from '@elastic/eui'; +import { EuiFlexGroup } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { ValuesType } from 'utility-types'; +import { isJavaAgentName } from '../../../../../common/agent_name'; +import { UNIDENTIFIED_SERVICE_NODES_LABEL } from '../../../../../common/i18n'; +import { SERVICE_NODE_NAME_MISSING } from '../../../../../common/service_nodes'; +import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; +import { + asDuration, + asPercent, + asTransactionRate, +} from '../../../../../common/utils/formatters'; +import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; +import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; +import { + APIReturnType, + callApmApi, +} from '../../../../services/rest/createCallApmApi'; +import { TruncateWithTooltip } from '../../../shared/truncate_with_tooltip'; +import { TableFetchWrapper } from '../../../shared/table_fetch_wrapper'; +import { SparkPlot } from '../../../shared/charts/spark_plot'; +import { px, unit } from '../../../../style/variables'; +import { ServiceOverviewTableContainer } from '../service_overview_table_container'; +import { ServiceNodeMetricOverviewLink } from '../../../shared/Links/apm/ServiceNodeMetricOverviewLink'; +import { MetricOverviewLink } from '../../../shared/Links/apm/MetricOverviewLink'; + +type ServiceInstanceItem = ValuesType< + APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances'> +>; + +interface Props { + serviceName: string; +} + +export function ServiceOverviewInstancesTable({ serviceName }: Props) { + const { agentName, transactionType } = useApmServiceContext(); + + const { + urlParams: { start, end }, + uiFilters, + } = useUrlParams(); + + const columns: Array> = [ + { + field: 'name', + name: i18n.translate( + 'xpack.apm.serviceOverview.instancesTableColumnNodeName', + { + defaultMessage: 'Node name', + } + ), + render: (_, item) => { + const { serviceNodeName } = item; + const isMissingServiceNodeName = + serviceNodeName === SERVICE_NODE_NAME_MISSING; + const text = isMissingServiceNodeName + ? UNIDENTIFIED_SERVICE_NODES_LABEL + : serviceNodeName; + + const link = isJavaAgentName(agentName) ? ( + + {text} + + ) : ( + ({ + ...query, + kuery: isMissingServiceNodeName + ? `NOT (service.node.name:*)` + : `service.node.name:"${item.serviceNodeName}"`, + })} + > + {text} + + ); + + return ; + }, + sortable: true, + }, + { + field: 'latencyValue', + name: i18n.translate( + 'xpack.apm.serviceOverview.instancesTableColumnLatency', + { + defaultMessage: 'Latency', + } + ), + width: px(unit * 10), + render: (_, { latency }) => { + return ( + + ); + }, + sortable: true, + }, + { + field: 'throughputValue', + name: i18n.translate( + 'xpack.apm.serviceOverview.instancesTableColumnThroughput', + { + defaultMessage: 'Traffic', + } + ), + width: px(unit * 10), + render: (_, { throughput }) => { + return ( + + ); + }, + sortable: true, + }, + { + field: 'errorRateValue', + name: i18n.translate( + 'xpack.apm.serviceOverview.instancesTableColumnErrorRate', + { + defaultMessage: 'Error rate', + } + ), + width: px(unit * 8), + render: (_, { errorRate }) => { + return ( + + ); + }, + sortable: true, + }, + { + field: 'cpuUsageValue', + name: i18n.translate( + 'xpack.apm.serviceOverview.instancesTableColumnCpuUsage', + { + defaultMessage: 'CPU usage (avg.)', + } + ), + width: px(unit * 8), + render: (_, { cpuUsage }) => { + return ( + + ); + }, + sortable: true, + }, + { + field: 'memoryUsageValue', + name: i18n.translate( + 'xpack.apm.serviceOverview.instancesTableColumnMemoryUsage', + { + defaultMessage: 'Memory usage (avg.)', + } + ), + width: px(unit * 8), + render: (_, { memoryUsage }) => { + return ( + + ); + }, + sortable: true, + }, + ]; + + const { data = [], status } = useFetcher(() => { + if (!start || !end || !transactionType) { + return; + } + + return callApmApi({ + endpoint: + 'GET /api/apm/services/{serviceName}/service_overview_instances', + params: { + path: { + serviceName, + }, + query: { + start, + end, + transactionType, + uiFilters: JSON.stringify(uiFilters), + numBuckets: 20, + }, + }, + }); + }, [start, end, serviceName, transactionType, uiFilters]); + + // need top-level sortable fields for the managed table + const items = data.map((item) => ({ + ...item, + latencyValue: item.latency?.value ?? 0, + throughputValue: item.throughput?.value ?? 0, + errorRateValue: item.errorRate?.value ?? 0, + cpuUsageValue: item.cpuUsage?.value ?? 0, + memoryUsageValue: item.memoryUsage?.value ?? 0, + })); + + const isLoading = + status === FETCH_STATUS.LOADING || status === FETCH_STATUS.PENDING; + + return ( + + + +

+ {i18n.translate('xpack.apm.serviceOverview.instancesTableTitle', { + defaultMessage: 'All instances', + })} +

+
+
+ + + + + + + +
+ ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_table.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_table_container.tsx similarity index 72% rename from x-pack/plugins/apm/public/components/app/service_overview/service_overview_table.tsx rename to x-pack/plugins/apm/public/components/app/service_overview/service_overview_table_container.tsx index 99753adfcd36d..e5113cebd3dcb 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_table.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_table_container.tsx @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiBasicTable, EuiBasicTableProps } from '@elastic/eui'; -import React from 'react'; import styled from 'styled-components'; /** @@ -43,14 +41,3 @@ export const ServiceOverviewTableContainer = styled.div<{ isEmptyAndLoading ? 'hidden' : 'visible'}; } `; - -export function ServiceOverviewTable(props: EuiBasicTableProps) { - const { items, loading } = props; - const isEmptyAndLoading = !!(items.length === 0 && loading); - - return ( - - - - ); -} diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx index 886c95cde7248..e50af6f53c728 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx @@ -15,6 +15,7 @@ import React, { useState } from 'react'; import styled from 'styled-components'; import { EuiToolTip } from '@elastic/eui'; import { ValuesType } from 'utility-types'; +import { EuiBasicTable } from '@elastic/eui'; import { useLatencyAggregationType } from '../../../../hooks/use_latency_Aggregation_type'; import { LatencyAggregationType } from '../../../../../common/latency_aggregation_types'; import { @@ -33,9 +34,9 @@ import { TransactionDetailLink } from '../../../shared/Links/apm/TransactionDeta import { TransactionOverviewLink } from '../../../shared/Links/apm/TransactionOverviewLink'; import { TableFetchWrapper } from '../../../shared/table_fetch_wrapper'; import { TableLinkFlexItem } from '../table_link_flex_item'; -import { SparkPlotWithValueLabel } from '../../../shared/charts/spark_plot/spark_plot_with_value_label'; +import { SparkPlot } from '../../../shared/charts/spark_plot'; import { ImpactBar } from '../../../shared/ImpactBar'; -import { ServiceOverviewTable } from '../service_overview_table'; +import { ServiceOverviewTableContainer } from '../service_overview_table_container'; type ServiceTransactionGroupItem = ValuesType< APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups/overview'>['transactionGroups'] @@ -208,7 +209,7 @@ export function ServiceOverviewTransactionsTable({ serviceName }: Props) { width: px(unit * 10), render: (_, { latency }) => { return ( - { return ( - { return ( - - { - setTableOptions({ - pageIndex: newTableOptions.page?.index ?? 0, - sort: newTableOptions.sort - ? { - field: newTableOptions.sort.field as SortField, - direction: newTableOptions.sort.direction, - } - : DEFAULT_SORT, - }); - }} - sorting={{ - enableAllColumns: true, - sort: { - direction: sort.direction, - field: sort.field, - }, - }} - /> + + { + setTableOptions({ + pageIndex: newTableOptions.page?.index ?? 0, + sort: newTableOptions.sort + ? { + field: newTableOptions.sort.field as SortField, + direction: newTableOptions.sort.direction, + } + : DEFAULT_SORT, + }); + }} + sorting={{ + enableAllColumns: true, + sort: { + direction: sort.direction, + field: sort.field, + }, + }} + /> +
diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.tsx index 111dd5d00a978..7acc2542a65f3 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.tsx @@ -18,6 +18,7 @@ import { APMQueryParams, fromQuery, toQuery } from '../url_helpers'; interface Props extends EuiLinkAnchorProps { path?: string; query?: APMQueryParams; + mergeQuery?: (query: APMQueryParams) => APMQueryParams; children?: React.ReactNode; } @@ -74,11 +75,14 @@ export function getAPMHref({ }); } -export function APMLink({ path = '', query, ...rest }: Props) { +export function APMLink({ path = '', query, mergeQuery, ...rest }: Props) { const { core } = useApmPluginContext(); const { search } = useLocation(); const { basePath } = core.http; - const href = getAPMHref({ basePath, path, search, query }); + + const mergedQuery = mergeQuery ? mergeQuery(query ?? {}) : query; + + const href = getAPMHref({ basePath, path, search, query: mergedQuery }); return ; } diff --git a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx index ab1e725a08dff..e620acd56aadd 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx @@ -3,6 +3,10 @@ * 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 } from '@elastic/eui'; +import React from 'react'; +import { EuiIcon } from '@elastic/eui'; import { AreaSeries, Chart, @@ -10,62 +14,82 @@ import { ScaleType, Settings, } from '@elastic/charts'; -import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText } from '@elastic/eui'; -import React from 'react'; import { merge } from 'lodash'; import { useChartTheme } from '../../../../../../observability/public'; -import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; -import { px } from '../../../../style/variables'; +import { px, unit } from '../../../../style/variables'; +import { useTheme } from '../../../../hooks/use_theme'; + +type Color = + | 'euiColorVis0' + | 'euiColorVis1' + | 'euiColorVis2' + | 'euiColorVis3' + | 'euiColorVis4' + | 'euiColorVis5' + | 'euiColorVis6' + | 'euiColorVis7' + | 'euiColorVis8' + | 'euiColorVis9'; -interface Props { - color: string; +export function SparkPlot({ + color, + series, + valueLabel, + compact, +}: { + color: Color; series?: Array<{ x: number; y: number | null }> | null; - width: string; -} + valueLabel: React.ReactNode; + compact?: boolean; +}) { + const theme = useTheme(); + const defaultChartTheme = useChartTheme(); -export function SparkPlot(props: Props) { - const { series, color, width } = props; - const chartTheme = useChartTheme(); + const sparkplotChartTheme = merge({}, defaultChartTheme, { + chartMargins: { left: 0, right: 0, top: 0, bottom: 0 }, + lineSeriesStyle: { + point: { opacity: 0 }, + }, + areaSeriesStyle: { + point: { opacity: 0 }, + }, + }); - if (!series || series.every((point) => point.y === null)) { - return ( - - - - - - - {NOT_AVAILABLE_LABEL} - - - - ); - } + const colorValue = theme.eui[color]; return ( - - - - + + + {!series || series.every((point) => point.y === null) ? ( + + ) : ( + + + + + )} + + + {valueLabel} + + ); } diff --git a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/spark_plot_with_value_label/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/spark_plot_with_value_label/index.tsx deleted file mode 100644 index 7ca89c5a27504..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/spark_plot_with_value_label/index.tsx +++ /dev/null @@ -1,54 +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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import React from 'react'; -import { px, unit } from '../../../../../style/variables'; -import { useTheme } from '../../../../../hooks/use_theme'; -import { SparkPlot } from '../'; - -type Color = - | 'euiColorVis0' - | 'euiColorVis1' - | 'euiColorVis2' - | 'euiColorVis3' - | 'euiColorVis4' - | 'euiColorVis5' - | 'euiColorVis6' - | 'euiColorVis7' - | 'euiColorVis8' - | 'euiColorVis9'; - -export function SparkPlotWithValueLabel({ - color, - series, - valueLabel, - compact, -}: { - color: Color; - series?: Array<{ x: number; y: number | null }> | null; - valueLabel: React.ReactNode; - compact?: boolean; -}) { - const theme = useTheme(); - - const colorValue = theme.eui[color]; - - return ( - - - - - - {valueLabel} - - - ); -} diff --git a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts index 124f61ed031fe..36fdf45d805f1 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts @@ -59,6 +59,7 @@ export function registerErrorCountAlertType({ ], }, producer: 'apm', + minimumLicenseRequired: 'basic', executor: async ({ services, params }) => { const config = await config$.pipe(take(1)).toPromise(); const alertParams = params; diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts index cad5f5f8b9b56..48fc0899f029c 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts @@ -68,6 +68,7 @@ export function registerTransactionDurationAlertType({ ], }, producer: 'apm', + minimumLicenseRequired: 'basic', executor: async ({ services, params }) => { const config = await config$.pipe(take(1)).toPromise(); const alertParams = params; diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts index b9e970ace869d..f3df0092dbbbd 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts @@ -67,6 +67,7 @@ export function registerTransactionDurationAnomalyAlertType({ ], }, producer: 'apm', + minimumLicenseRequired: 'basic', executor: async ({ services, params, state }) => { if (!ml) { return; diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts index 2753b378754f8..766705e2803b1 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts @@ -65,6 +65,7 @@ export function registerTransactionErrorRateAlertType({ ], }, producer: 'apm', + minimumLicenseRequired: 'basic', executor: async ({ services, params: alertParams }) => { const config = await config$.pipe(take(1)).toPromise(); const indices = await getApmIndices({ diff --git a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_failed_transactions/index.ts b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_failed_transactions/index.ts index ba739310bc342..64d9ebb192eb3 100644 --- a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_failed_transactions/index.ts +++ b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_failed_transactions/index.ts @@ -4,12 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isEmpty } from 'lodash'; +import { isEmpty, omit } from 'lodash'; import { EventOutcome } from '../../../../common/event_outcome'; import { - formatTopSignificantTerms, + processSignificantTermAggs, TopSigTerm, -} from '../get_correlations_for_slow_transactions/format_top_significant_terms'; +} from '../process_significant_term_aggs'; import { AggregationOptionsByType } from '../../../../../../typings/elasticsearch/aggregations'; import { ESFilter } from '../../../../../../typings/elasticsearch'; import { rangeFilter } from '../../../../common/utils/range_filter'; @@ -61,38 +61,56 @@ export async function getCorrelationsForFailedTransactions({ const params = { apm: { events: [ProcessorEvent.transaction] }, + track_total_hits: true, body: { size: 0, query: { - bool: { - // foreground filters - filter: [ - ...backgroundFilters, - { term: { [EVENT_OUTCOME]: EventOutcome.failure } }, - ], + bool: { filter: backgroundFilters }, + }, + aggs: { + failed_transactions: { + filter: { term: { [EVENT_OUTCOME]: EventOutcome.failure } }, + + // significant term aggs + aggs: fieldNames.reduce((acc, fieldName) => { + return { + ...acc, + [fieldName]: { + significant_terms: { + size: 10, + field: fieldName, + background_filter: { bool: { filter: backgroundFilters } }, + }, + }, + }; + }, {} as Record), }, }, - aggs: fieldNames.reduce((acc, fieldName) => { - return { - ...acc, - [fieldName]: { - significant_terms: { - size: 10, - field: fieldName, - background_filter: { bool: { filter: backgroundFilters } }, - }, - }, - }; - }, {} as Record), }, }; const response = await apmEventClient.search(params); - const topSigTerms = formatTopSignificantTerms(response.aggregations); - return getChartsForTopSigTerms({ setup, backgroundFilters, topSigTerms }); + if (!response.aggregations) { + return {}; + } + + const failedTransactionCount = + response.aggregations?.failed_transactions.doc_count; + const totalTransactionCount = response.hits.total.value; + const avgErrorRate = (failedTransactionCount / totalTransactionCount) * 100; + const sigTermAggs = omit( + response.aggregations?.failed_transactions, + 'doc_count' + ); + + const topSigTerms = processSignificantTermAggs({ + sigTermAggs, + thresholdPercentage: avgErrorRate, + }); + return getErrorRateTimeSeries({ setup, backgroundFilters, topSigTerms }); } -export async function getChartsForTopSigTerms({ +export async function getErrorRateTimeSeries({ setup, backgroundFilters, topSigTerms, diff --git a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_charts_for_top_sig_terms.ts b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_latency_distribution.ts similarity index 76% rename from x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_charts_for_top_sig_terms.ts rename to x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_latency_distribution.ts index cbefd5e2133e5..d6d3283ca5e9a 100644 --- a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_charts_for_top_sig_terms.ts +++ b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_latency_distribution.ts @@ -10,11 +10,10 @@ import { ESFilter } from '../../../../../../typings/elasticsearch'; import { TRANSACTION_DURATION } from '../../../../common/elasticsearch_fieldnames'; import { ProcessorEvent } from '../../../../common/processor_event'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; -import { getBucketSize } from '../../helpers/get_bucket_size'; -import { TopSigTerm } from './format_top_significant_terms'; +import { TopSigTerm } from '../process_significant_term_aggs'; import { getMaxLatency } from './get_max_latency'; -export async function getChartsForTopSigTerms({ +export async function getLatencyDistribution({ setup, backgroundFilters, topSigTerms, @@ -23,8 +22,7 @@ export async function getChartsForTopSigTerms({ backgroundFilters: ESFilter[]; topSigTerms: TopSigTerm[]; }) { - const { start, end, apmEventClient } = setup; - const { intervalString } = getBucketSize({ start, end, numBuckets: 30 }); + const { apmEventClient } = setup; if (isEmpty(topSigTerms)) { return {}; @@ -62,30 +60,12 @@ export async function getChartsForTopSigTerms({ }, }; - const timeseriesAgg = { - date_histogram: { - field: '@timestamp', - fixed_interval: intervalString, - min_doc_count: 0, - extended_bounds: { min: start, max: end }, - }, - aggs: { - average: { - avg: { - // TODO: add support for metrics - field: TRANSACTION_DURATION, - }, - }, - }, - }; - const perTermAggs = topSigTerms.reduce( (acc, term, index) => { acc[`term_${index}`] = { filter: { term: { [term.fieldName]: term.fieldValue } }, aggs: { distribution: distributionAgg, - timeseries: timeseriesAgg, }, }; return acc; @@ -96,7 +76,6 @@ export async function getChartsForTopSigTerms({ filter: AggregationOptionsByType['filter']; aggs: { distribution: typeof distributionAgg; - timeseries: typeof timeseriesAgg; }; } > @@ -111,7 +90,6 @@ export async function getChartsForTopSigTerms({ aggs: { // overall aggs distribution: distributionAgg, - timeseries: timeseriesAgg, // per term aggs ...perTermAggs, @@ -126,13 +104,6 @@ export async function getChartsForTopSigTerms({ return; } - function formatTimeseries(timeseries: Agg['timeseries']) { - return timeseries.buckets.map((bucket) => ({ - x: bucket.key, - y: bucket.average.value, - })); - } - function formatDistribution(distribution: Agg['distribution']) { const total = distribution.doc_count; return distribution.dist_filtered_by_latency.buckets.map((bucket) => ({ @@ -144,7 +115,6 @@ export async function getChartsForTopSigTerms({ return { distributionInterval, overall: { - timeseries: formatTimeseries(response.aggregations.timeseries), distribution: formatDistribution(response.aggregations.distribution), }, significantTerms: topSigTerms.map((topSig, index) => { @@ -153,7 +123,6 @@ export async function getChartsForTopSigTerms({ return { ...topSig, - timeseries: formatTimeseries(agg.timeseries), distribution: formatDistribution(agg.distribution), }; }), diff --git a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_max_latency.ts b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_max_latency.ts index 3f86d2900e85b..358ed8e652eff 100644 --- a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_max_latency.ts +++ b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_max_latency.ts @@ -8,7 +8,7 @@ import { ESFilter } from '../../../../../../typings/elasticsearch'; import { TRANSACTION_DURATION } from '../../../../common/elasticsearch_fieldnames'; import { ProcessorEvent } from '../../../../common/processor_event'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; -import { TopSigTerm } from './format_top_significant_terms'; +import { TopSigTerm } from '../process_significant_term_aggs'; export async function getMaxLatency({ setup, diff --git a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/index.ts b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/index.ts index b8a5ab93591a4..c8642e28434e2 100644 --- a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/index.ts +++ b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/index.ts @@ -16,8 +16,8 @@ import { import { ProcessorEvent } from '../../../../common/processor_event'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { getDurationForPercentile } from './get_duration_for_percentile'; -import { formatTopSignificantTerms } from './format_top_significant_terms'; -import { getChartsForTopSigTerms } from './get_charts_for_top_sig_terms'; +import { processSignificantTermAggs } from '../process_significant_term_aggs'; +import { getLatencyDistribution } from './get_latency_distribution'; export async function getCorrelationsForSlowTransactions({ serviceName, @@ -90,6 +90,19 @@ export async function getCorrelationsForSlowTransactions({ }; const response = await apmEventClient.search(params); - const topSigTerms = formatTopSignificantTerms(response.aggregations); - return getChartsForTopSigTerms({ setup, backgroundFilters, topSigTerms }); + + if (!response.aggregations) { + return {}; + } + + const topSigTerms = processSignificantTermAggs({ + sigTermAggs: response.aggregations, + thresholdPercentage: 100 - durationPercentile, + }); + + return getLatencyDistribution({ + setup, + backgroundFilters, + topSigTerms, + }); } diff --git a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/format_top_significant_terms.ts b/x-pack/plugins/apm/server/lib/correlations/process_significant_term_aggs.ts similarity index 63% rename from x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/format_top_significant_terms.ts rename to x-pack/plugins/apm/server/lib/correlations/process_significant_term_aggs.ts index f168b49fb18fd..502bb66f71db6 100644 --- a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/format_top_significant_terms.ts +++ b/x-pack/plugins/apm/server/lib/correlations/process_significant_term_aggs.ts @@ -8,7 +8,7 @@ import { orderBy } from 'lodash'; import { AggregationOptionsByType, AggregationResultOf, -} from '../../../../../../typings/elasticsearch/aggregations'; +} from '../../../../../typings/elasticsearch/aggregations'; export interface TopSigTerm { bgCount: number; @@ -18,15 +18,19 @@ export interface TopSigTerm { score: number; } -type SigTermAggs = AggregationResultOf< +type SigTermAgg = AggregationResultOf< { significant_terms: AggregationOptionsByType['significant_terms'] }, {} >; -export function formatTopSignificantTerms( - aggregations?: Record -) { - const significantTerms = Object.entries(aggregations ?? []).flatMap( +export function processSignificantTermAggs({ + sigTermAggs, + thresholdPercentage, +}: { + sigTermAggs: Record; + thresholdPercentage: number; +}) { + const significantTerms = Object.entries(sigTermAggs).flatMap( ([fieldName, agg]) => { return agg.buckets.map((bucket) => ({ fieldName, @@ -39,6 +43,11 @@ export function formatTopSignificantTerms( ); // get top 10 terms ordered by score - const topSigTerms = orderBy(significantTerms, 'score', 'desc').slice(0, 10); + const topSigTerms = orderBy(significantTerms, 'score', 'desc') + .filter(({ bgCount, fgCount }) => { + // only include results that are above the threshold + return Math.floor((fgCount / bgCount) * 100) > thresholdPercentage; + }) + .slice(0, 10); return topSigTerms; } diff --git a/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_system_metric_stats.ts b/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_system_metric_stats.ts new file mode 100644 index 0000000000000..7e9e04f3eaae4 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_system_metric_stats.ts @@ -0,0 +1,150 @@ +/* + * 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 { AggregationOptionsByType } from '../../../../../../typings/elasticsearch'; +import { rangeFilter } from '../../../../common/utils/range_filter'; +import { SERVICE_NODE_NAME_MISSING } from '../../../../common/service_nodes'; +import { + METRIC_CGROUP_MEMORY_USAGE_BYTES, + METRIC_PROCESS_CPU_PERCENT, + METRIC_SYSTEM_FREE_MEMORY, + METRIC_SYSTEM_TOTAL_MEMORY, + SERVICE_NAME, + SERVICE_NODE_NAME, +} from '../../../../common/elasticsearch_fieldnames'; +import { ProcessorEvent } from '../../../../common/processor_event'; +import { ServiceInstanceParams } from '.'; +import { getBucketSize } from '../../helpers/get_bucket_size'; +import { + percentCgroupMemoryUsedScript, + percentSystemMemoryUsedScript, +} from '../../metrics/by_agent/shared/memory'; + +export async function getServiceInstanceSystemMetricStats({ + setup, + serviceName, + size, + numBuckets, +}: ServiceInstanceParams) { + const { apmEventClient, start, end, esFilter } = setup; + + const { intervalString } = getBucketSize({ start, end, numBuckets }); + + const systemMemoryFilter = { + bool: { + filter: [ + { exists: { field: METRIC_SYSTEM_FREE_MEMORY } }, + { exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } }, + ], + }, + }; + + const cgroupMemoryFilter = { + exists: { field: METRIC_CGROUP_MEMORY_USAGE_BYTES }, + }; + + const cpuUsageFilter = { exists: { field: METRIC_PROCESS_CPU_PERCENT } }; + + function withTimeseries(agg: T) { + return { + avg: { avg: agg }, + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: intervalString, + min_doc_count: 0, + extended_bounds: { + min: start, + max: end, + }, + }, + aggs: { + avg: { avg: agg }, + }, + }, + }; + } + + const subAggs = { + memory_usage_cgroup: { + filter: cgroupMemoryFilter, + aggs: withTimeseries({ script: percentCgroupMemoryUsedScript }), + }, + memory_usage_system: { + filter: systemMemoryFilter, + aggs: withTimeseries({ script: percentSystemMemoryUsedScript }), + }, + cpu_usage: { + filter: cpuUsageFilter, + aggs: withTimeseries({ field: METRIC_PROCESS_CPU_PERCENT }), + }, + }; + + const response = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.metric], + }, + body: { + size: 0, + query: { + bool: { + filter: [ + { range: rangeFilter(start, end) }, + { term: { [SERVICE_NAME]: serviceName } }, + ...esFilter, + ], + should: [cgroupMemoryFilter, systemMemoryFilter, cpuUsageFilter], + minimum_should_match: 1, + }, + }, + aggs: { + [SERVICE_NODE_NAME]: { + terms: { + field: SERVICE_NODE_NAME, + missing: SERVICE_NODE_NAME_MISSING, + size, + }, + aggs: subAggs, + }, + }, + }, + }); + + return ( + response.aggregations?.[SERVICE_NODE_NAME].buckets.map( + (serviceNodeBucket) => { + const hasCGroupData = + serviceNodeBucket.memory_usage_cgroup.avg.value !== null; + + const memoryMetricsKey = hasCGroupData + ? 'memory_usage_cgroup' + : 'memory_usage_system'; + + return { + serviceNodeName: String(serviceNodeBucket.key), + cpuUsage: { + value: serviceNodeBucket.cpu_usage.avg.value, + timeseries: serviceNodeBucket.cpu_usage.timeseries.buckets.map( + (dateBucket) => ({ + x: dateBucket.key, + y: dateBucket.avg.value, + }) + ), + }, + memoryUsage: { + value: serviceNodeBucket[memoryMetricsKey].avg.value, + timeseries: serviceNodeBucket[ + memoryMetricsKey + ].timeseries.buckets.map((dateBucket) => ({ + x: dateBucket.key, + y: dateBucket.avg.value, + })), + }, + }; + } + ) ?? [] + ); +} diff --git a/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_transaction_stats.ts b/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_transaction_stats.ts new file mode 100644 index 0000000000000..4e3256f0fcf87 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_transaction_stats.ts @@ -0,0 +1,153 @@ +/* + * 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 { EventOutcome } from '../../../../common/event_outcome'; +import { rangeFilter } from '../../../../common/utils/range_filter'; +import { SERVICE_NODE_NAME_MISSING } from '../../../../common/service_nodes'; +import { + EVENT_OUTCOME, + SERVICE_NAME, + SERVICE_NODE_NAME, + TRANSACTION_TYPE, +} from '../../../../common/elasticsearch_fieldnames'; +import { ServiceInstanceParams } from '.'; +import { getBucketSize } from '../../helpers/get_bucket_size'; +import { + getProcessorEventForAggregatedTransactions, + getTransactionDurationFieldForAggregatedTransactions, +} from '../../helpers/aggregated_transactions'; + +export async function getServiceInstanceTransactionStats({ + setup, + transactionType, + serviceName, + size, + searchAggregatedTransactions, + numBuckets, +}: ServiceInstanceParams) { + const { apmEventClient, start, end, esFilter } = setup; + + const { intervalString } = getBucketSize({ start, end, numBuckets }); + + const field = getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ); + + const subAggs = { + count: { + value_count: { + field, + }, + }, + avg_transaction_duration: { + avg: { + field, + }, + }, + failures: { + filter: { + term: { + [EVENT_OUTCOME]: EventOutcome.failure, + }, + }, + aggs: { + count: { + value_count: { + field, + }, + }, + }, + }, + }; + + const response = await apmEventClient.search({ + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], + }, + body: { + size: 0, + query: { + bool: { + filter: [ + { range: rangeFilter(start, end) }, + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [TRANSACTION_TYPE]: transactionType } }, + ...esFilter, + ], + }, + }, + aggs: { + [SERVICE_NODE_NAME]: { + terms: { + field: SERVICE_NODE_NAME, + missing: SERVICE_NODE_NAME_MISSING, + size, + }, + aggs: { + ...subAggs, + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: intervalString, + min_doc_count: 0, + extended_bounds: { + min: start, + max: end, + }, + }, + aggs: { + ...subAggs, + }, + }, + }, + }, + }, + }, + }); + + return ( + response.aggregations?.[SERVICE_NODE_NAME].buckets.map( + (serviceNodeBucket) => { + const { + count, + avg_transaction_duration: avgTransactionDuration, + key, + failures, + timeseries, + } = serviceNodeBucket; + + return { + serviceNodeName: String(key), + errorRate: { + value: failures.count.value / count.value, + timeseries: timeseries.buckets.map((dateBucket) => ({ + x: dateBucket.key, + y: dateBucket.failures.count.value / dateBucket.count.value, + })), + }, + throughput: { + value: count.value, + timeseries: timeseries.buckets.map((dateBucket) => ({ + x: dateBucket.key, + y: dateBucket.count.value, + })), + }, + latency: { + value: avgTransactionDuration.value, + timeseries: timeseries.buckets.map((dateBucket) => ({ + x: dateBucket.key, + y: dateBucket.avg_transaction_duration.value, + })), + }, + }; + } + ) ?? [] + ); +} diff --git a/x-pack/plugins/apm/server/lib/services/get_service_instances/index.ts b/x-pack/plugins/apm/server/lib/services/get_service_instances/index.ts new file mode 100644 index 0000000000000..d627b968344f1 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/get_service_instances/index.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 { joinByKey } from '../../../../common/utils/join_by_key'; +import { Setup, SetupTimeRange } from '../../helpers/setup_request'; +import { getServiceInstanceSystemMetricStats } from './get_service_instance_system_metric_stats'; +import { getServiceInstanceTransactionStats } from './get_service_instance_transaction_stats'; + +export interface ServiceInstanceParams { + setup: Setup & SetupTimeRange; + serviceName: string; + transactionType: string; + searchAggregatedTransactions: boolean; + size: number; + numBuckets: number; +} + +export async function getServiceInstances( + params: Omit +) { + const paramsForSubQueries = { + ...params, + size: 50, + }; + + const [transactionStats, systemMetricStats] = await Promise.all([ + getServiceInstanceTransactionStats(paramsForSubQueries), + getServiceInstanceSystemMetricStats(paramsForSubQueries), + ]); + + const stats = joinByKey( + [...transactionStats, ...systemMetricStats], + 'serviceNodeName' + ); + + return stats; +} diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index b09175a6841f8..5e26371b043e8 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -24,6 +24,7 @@ import { serviceErrorGroupsRoute, serviceThroughputRoute, serviceDependenciesRoute, + serviceInstancesRoute, } from './services'; import { agentConfigurationRoute, @@ -129,6 +130,7 @@ const createApmApi = () => { .add(serviceErrorGroupsRoute) .add(serviceThroughputRoute) .add(serviceDependenciesRoute) + .add(serviceInstancesRoute) // Agent configuration .add(getSingleAgentConfigurationRoute) diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 40ad7fdd05248..bba6afc332242 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -21,6 +21,7 @@ import { getServiceErrorGroups } from '../lib/services/get_service_error_groups' import { getServiceDependencies } from '../lib/services/get_service_dependencies'; import { toNumberRt } from '../../common/runtime_types/to_number_rt'; import { getThroughput } from '../lib/services/get_throughput'; +import { getServiceInstances } from '../lib/services/get_service_instances'; export const servicesRoute = createRoute({ endpoint: 'GET /api/apm/services', @@ -277,6 +278,38 @@ export const serviceThroughputRoute = createRoute({ }, }); +export const serviceInstancesRoute = createRoute({ + endpoint: 'GET /api/apm/services/{serviceName}/service_overview_instances', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([ + t.type({ transactionType: t.string, numBuckets: toNumberRt }), + uiFiltersRt, + rangeRt, + ]), + }), + options: { tags: ['access:apm'] }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { serviceName } = context.params.path; + const { transactionType, numBuckets } = context.params.query; + + const searchAggregatedTransactions = await getSearchAggregatedTransactions( + setup + ); + + return getServiceInstances({ + serviceName, + setup, + transactionType, + searchAggregatedTransactions, + numBuckets, + }); + }, +}); + export const serviceDependenciesRoute = createRoute({ endpoint: 'GET /api/apm/services/{serviceName}/dependencies', params: t.type({ @@ -284,8 +317,11 @@ export const serviceDependenciesRoute = createRoute({ serviceName: t.string, }), query: t.intersection([ + t.type({ + environment: t.string, + numBuckets: toNumberRt, + }), rangeRt, - t.type({ environment: t.string, numBuckets: toNumberRt }), ]), }), options: { diff --git a/x-pack/plugins/apm/server/ui_settings.ts b/x-pack/plugins/apm/server/ui_settings.ts index 2a9c3d6b71ff2..80225de5195f8 100644 --- a/x-pack/plugins/apm/server/ui_settings.ts +++ b/x-pack/plugins/apm/server/ui_settings.ts @@ -19,14 +19,14 @@ export const uiSettings: Record> = { [enableCorrelations]: { category: ['observability'], name: i18n.translate('xpack.apm.enableCorrelationsExperimentName', { - defaultMessage: 'APM Correlations', + defaultMessage: 'APM Significant terms', }), value: false, description: i18n.translate( 'xpack.apm.enableCorrelationsExperimentDescription', { defaultMessage: - 'Enable the experimental correlations UI and API endpoint in APM.', + 'Enable the experimental Significant terms feature in APM', } ), schema: schema.boolean(), diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index a08e1fbca66ea..9b6945edfe729 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -10,10 +10,7 @@ import { NumberFromString } from '../saved_object'; import { UserRT } from '../user'; import { CommentResponseRt } from './comment'; import { CasesStatusResponseRt } from './status'; -import { CaseConnectorRt, ESCaseConnector, ConnectorPartialFieldsRt } from '../connectors'; - -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -export { ActionTypeExecutorResult } from '../../../../actions/server/types'; +import { CaseConnectorRt, ESCaseConnector } from '../connectors'; export enum CaseStatuses { open = 'open', @@ -128,66 +125,6 @@ export const CasePatchRequestRt = rt.intersection([ export const CasesPatchRequestRt = rt.type({ cases: rt.array(CasePatchRequestRt) }); export const CasesResponseRt = rt.array(CaseResponseRt); -/* - * This type are related to this file below - * x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts - * why because this schema is not share in a common folder - * so we redefine then so we can use/validate types - */ - -// TODO: Refactor to support multiple connectors with various fields - -const ServiceConnectorUserParams = rt.type({ - fullName: rt.union([rt.string, rt.null]), - username: rt.string, -}); - -export const ServiceConnectorCommentParamsRt = rt.type({ - commentId: rt.string, - comment: rt.string, - createdAt: rt.string, - createdBy: ServiceConnectorUserParams, - updatedAt: rt.union([rt.string, rt.null]), - updatedBy: rt.union([ServiceConnectorUserParams, rt.null]), -}); - -export const ServiceConnectorBasicCaseParamsRt = rt.type({ - comments: rt.union([rt.array(ServiceConnectorCommentParamsRt), rt.null]), - createdAt: rt.string, - createdBy: ServiceConnectorUserParams, - description: rt.union([rt.string, rt.null]), - externalId: rt.union([rt.string, rt.null]), - savedObjectId: rt.string, - title: rt.string, - updatedAt: rt.union([rt.string, rt.null]), - updatedBy: rt.union([ServiceConnectorUserParams, rt.null]), -}); - -export const ServiceConnectorCaseParamsRt = rt.intersection([ - ServiceConnectorBasicCaseParamsRt, - ConnectorPartialFieldsRt, -]); - -export const ServiceConnectorCaseResponseRt = rt.intersection([ - rt.type({ - title: rt.string, - id: rt.string, - pushedDate: rt.string, - url: rt.string, - }), - rt.partial({ - comments: rt.array( - rt.intersection([ - rt.type({ - commentId: rt.string, - pushedDate: rt.string, - }), - rt.partial({ externalCommentId: rt.string }), - ]) - ), - }), -]); - export type CaseAttributes = rt.TypeOf; export type CasePostRequest = rt.TypeOf; export type CaseResponse = rt.TypeOf; @@ -196,10 +133,7 @@ export type CasesFindResponse = rt.TypeOf; export type CasePatchRequest = rt.TypeOf; export type CasesPatchRequest = rt.TypeOf; export type CaseExternalServiceRequest = rt.TypeOf; -export type ServiceConnectorCaseParams = rt.TypeOf; -export type ServiceConnectorCaseResponse = rt.TypeOf; export type CaseFullExternalService = rt.TypeOf; -export type ServiceConnectorCommentParams = rt.TypeOf; export type ESCaseAttributes = Omit & { connector: ESCaseConnector }; export type ESCasePatchRequest = Omit & { diff --git a/x-pack/plugins/case/common/api/cases/configure.ts b/x-pack/plugins/case/common/api/cases/configure.ts index b0a2fa6576fd7..84f0e1fea6edf 100644 --- a/x-pack/plugins/case/common/api/cases/configure.ts +++ b/x-pack/plugins/case/common/api/cases/configure.ts @@ -8,60 +8,7 @@ import * as rt from 'io-ts'; import { ActionResult } from '../../../../actions/common'; import { UserRT } from '../user'; -import { JiraCaseFieldsRt } from '../connectors/jira'; -import { ServiceNowCaseFieldsRT } from '../connectors/servicenow'; -import { ResilientCaseFieldsRT } from '../connectors/resilient'; -import { CaseConnectorRt, ESCaseConnector } from '../connectors'; - -/* - * This types below are related to the service now configuration - * mapping between our case and [service-now, jira] - * - */ - -const ActionTypeRT = rt.union([ - rt.literal('append'), - rt.literal('nothing'), - rt.literal('overwrite'), -]); - -const CaseFieldRT = rt.union([ - rt.literal('title'), - rt.literal('description'), - rt.literal('comments'), -]); - -const ThirdPartyFieldRT = rt.union([ - JiraCaseFieldsRt, - ServiceNowCaseFieldsRT, - ResilientCaseFieldsRT, - rt.literal('not_mapped'), -]); - -export const CasesConfigurationMapsRT = rt.type({ - source: CaseFieldRT, - target: ThirdPartyFieldRT, - action_type: ActionTypeRT, -}); - -export const CasesConfigurationRT = rt.type({ - mapping: rt.array(CasesConfigurationMapsRT), -}); - -export const CasesConnectorConfigurationRT = rt.type({ - cases_configuration: CasesConfigurationRT, - // version: rt.string, -}); - -export type ActionType = rt.TypeOf; -export type CaseField = rt.TypeOf; -export type ThirdPartyField = rt.TypeOf; - -export type CasesConfigurationMaps = rt.TypeOf; -export type CasesConfiguration = rt.TypeOf; -export type CasesConnectorConfiguration = rt.TypeOf; - -/** ********************************************************************** */ +import { CaseConnectorRt, ConnectorMappingsRt, ESCaseConnector } from '../connectors'; export type ActionConnector = ActionResult; @@ -91,6 +38,7 @@ export const CaseConfigureAttributesRt = rt.intersection([ export const CaseConfigureResponseRt = rt.intersection([ CaseConfigureAttributesRt, + ConnectorMappingsRt, rt.type({ version: rt.string, }), diff --git a/x-pack/plugins/case/common/api/connectors/index.ts b/x-pack/plugins/case/common/api/connectors/index.ts index 0019afe7c6b74..f44e0f326a829 100644 --- a/x-pack/plugins/case/common/api/connectors/index.ts +++ b/x-pack/plugins/case/common/api/connectors/index.ts @@ -12,6 +12,7 @@ import { ServiceNowFieldsRT } from './servicenow'; export * from './jira'; export * from './servicenow'; export * from './resilient'; +export * from './mappings'; export const ConnectorFieldsRt = rt.union([ JiraFieldsRT, @@ -19,13 +20,6 @@ export const ConnectorFieldsRt = rt.union([ ServiceNowFieldsRT, rt.null, ]); - -export const ConnectorPartialFieldsRt = rt.partial({ - ...JiraFieldsRT.props, - ...ResilientFieldsRT.props, - ...ServiceNowFieldsRT.props, -}); - export enum ConnectorTypes { jira = '.jira', resilient = '.resilient', diff --git a/x-pack/plugins/case/common/api/connectors/jira.ts b/x-pack/plugins/case/common/api/connectors/jira.ts index f6a45d9872fcc..3ff6857a4fb97 100644 --- a/x-pack/plugins/case/common/api/connectors/jira.ts +++ b/x-pack/plugins/case/common/api/connectors/jira.ts @@ -6,12 +6,6 @@ import * as rt from 'io-ts'; -export const JiraCaseFieldsRt = rt.union([ - rt.literal('summary'), - rt.literal('description'), - rt.literal('comments'), -]); - export const JiraFieldsRT = rt.type({ issueType: rt.union([rt.string, rt.null]), priority: rt.union([rt.string, rt.null]), diff --git a/x-pack/plugins/case/common/api/connectors/mappings.ts b/x-pack/plugins/case/common/api/connectors/mappings.ts new file mode 100644 index 0000000000000..3e8baf0af2834 --- /dev/null +++ b/x-pack/plugins/case/common/api/connectors/mappings.ts @@ -0,0 +1,191 @@ +/* + * 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. + */ + +/* eslint-disable @kbn/eslint/no-restricted-paths */ + +import * as rt from 'io-ts'; +import { ElasticUser } from '../../../../security_solution/public/cases/containers/types'; +import { + PushToServiceApiParams as JiraPushToServiceApiParams, + Incident as JiraIncident, +} from '../../../../actions/server/builtin_action_types/jira/types'; +import { + PushToServiceApiParams as ResilientPushToServiceApiParams, + Incident as ResilientIncident, +} from '../../../../actions/server/builtin_action_types/resilient/types'; +import { + PushToServiceApiParams as ServiceNowPushToServiceApiParams, + Incident as ServiceNowIncident, +} from '../../../../actions/server/builtin_action_types/servicenow/types'; +import { ResilientFieldsRT } from './resilient'; +import { ServiceNowFieldsRT } from './servicenow'; +import { JiraFieldsRT } from './jira'; + +export { + JiraPushToServiceApiParams, + ResilientPushToServiceApiParams, + ServiceNowPushToServiceApiParams, +}; +export type Incident = JiraIncident | ResilientIncident | ServiceNowIncident; +export type PushToServiceApiParams = + | JiraPushToServiceApiParams + | ResilientPushToServiceApiParams + | ServiceNowPushToServiceApiParams; + +const ActionTypeRT = rt.union([ + rt.literal('append'), + rt.literal('nothing'), + rt.literal('overwrite'), +]); +const CaseFieldRT = rt.union([ + rt.literal('title'), + rt.literal('description'), + rt.literal('comments'), +]); +const ThirdPartyFieldRT = rt.union([rt.string, rt.literal('not_mapped')]); +export type ActionType = rt.TypeOf; +export type CaseField = rt.TypeOf; +export type ThirdPartyField = rt.TypeOf; + +export const ConnectorMappingsAttributesRT = rt.type({ + action_type: ActionTypeRT, + source: CaseFieldRT, + target: ThirdPartyFieldRT, +}); +export const ConnectorMappingsRt = rt.type({ + mappings: rt.array(ConnectorMappingsAttributesRT), +}); +export type ConnectorMappingsAttributes = rt.TypeOf; +export type ConnectorMappings = rt.TypeOf; + +const FieldTypeRT = rt.union([rt.literal('text'), rt.literal('textarea')]); + +const ConnectorFieldRt = rt.type({ + id: rt.string, + name: rt.string, + required: rt.boolean, + type: FieldTypeRT, +}); +export type ConnectorField = rt.TypeOf; +export const ConnectorRequestParamsRt = rt.type({ + connector_id: rt.string, +}); +export const GetFieldsRequestQueryRt = rt.type({ + connector_type: rt.string, +}); +const GetFieldsResponseRt = rt.type({ + defaultMappings: rt.array(ConnectorMappingsAttributesRT), + fields: rt.array(ConnectorFieldRt), +}); +export type GetFieldsResponse = rt.TypeOf; + +export type ExternalServiceParams = Record; + +export interface PipedField { + actionType: string; + key: string; + pipes: string[]; + value: string; +} +export interface PrepareFieldsForTransformArgs { + defaultPipes: string[]; + mappings: ConnectorMappingsAttributes[]; + params: ServiceConnectorCaseParams; +} +export interface EntityInformation { + createdAt: string; + createdBy: ElasticUser; + updatedAt: string | null; + updatedBy: ElasticUser | null; +} +export interface TransformerArgs { + date?: string; + previousValue?: string; + user?: string; + value: string; +} + +export type Transformer = (args: TransformerArgs) => TransformerArgs; +export interface TransformFieldsArgs { + currentIncident?: S; + fields: PipedField[]; + params: P; +} + +export const ServiceConnectorUserParams = rt.type({ + fullName: rt.union([rt.string, rt.null]), + username: rt.string, +}); + +export const ServiceConnectorCommentParamsRt = rt.type({ + commentId: rt.string, + comment: rt.string, + createdAt: rt.string, + createdBy: ServiceConnectorUserParams, + updatedAt: rt.union([rt.string, rt.null]), + updatedBy: rt.union([ServiceConnectorUserParams, rt.null]), +}); +export const ServiceConnectorBasicCaseParamsRt = rt.type({ + comments: rt.union([rt.array(ServiceConnectorCommentParamsRt), rt.null]), + createdAt: rt.string, + createdBy: ServiceConnectorUserParams, + description: rt.union([rt.string, rt.null]), + externalId: rt.union([rt.string, rt.null]), + savedObjectId: rt.string, + title: rt.string, + updatedAt: rt.union([rt.string, rt.null]), + updatedBy: rt.union([ServiceConnectorUserParams, rt.null]), +}); + +export const ConnectorPartialFieldsRt = rt.partial({ + ...JiraFieldsRT.props, + ...ResilientFieldsRT.props, + ...ServiceNowFieldsRT.props, +}); + +export const ServiceConnectorCaseParamsRt = rt.intersection([ + ServiceConnectorBasicCaseParamsRt, + ConnectorPartialFieldsRt, +]); + +export const ServiceConnectorCaseResponseRt = rt.intersection([ + rt.type({ + title: rt.string, + id: rt.string, + pushedDate: rt.string, + url: rt.string, + }), + rt.partial({ + comments: rt.array( + rt.intersection([ + rt.type({ + commentId: rt.string, + pushedDate: rt.string, + }), + rt.partial({ externalCommentId: rt.string }), + ]) + ), + }), +]); +export type ServiceConnectorBasicCaseParams = rt.TypeOf; +export type ServiceConnectorCaseParams = rt.TypeOf; +export type ServiceConnectorCaseResponse = rt.TypeOf; +export type ServiceConnectorCommentParams = rt.TypeOf; + +export const PostPushRequestRt = rt.type({ + connector_type: rt.string, + params: ServiceConnectorCaseParamsRt, +}); + +export interface SimpleComment { + comment: string; + commentId: string; +} + +export interface MapIncident { + incident: ExternalServiceParams; + comments: SimpleComment[]; +} diff --git a/x-pack/plugins/case/common/api/connectors/resilient.ts b/x-pack/plugins/case/common/api/connectors/resilient.ts index c2f7beb7626aa..5be14c1a930c3 100644 --- a/x-pack/plugins/case/common/api/connectors/resilient.ts +++ b/x-pack/plugins/case/common/api/connectors/resilient.ts @@ -6,12 +6,6 @@ import * as rt from 'io-ts'; -export const ResilientCaseFieldsRT = rt.union([ - rt.literal('name'), - rt.literal('description'), - rt.literal('comments'), -]); - export const ResilientFieldsRT = rt.type({ incidentTypes: rt.union([rt.array(rt.string), rt.null]), severityCode: rt.union([rt.string, rt.null]), diff --git a/x-pack/plugins/case/common/api/connectors/servicenow.ts b/x-pack/plugins/case/common/api/connectors/servicenow.ts index efcbeed714210..adb152981d902 100644 --- a/x-pack/plugins/case/common/api/connectors/servicenow.ts +++ b/x-pack/plugins/case/common/api/connectors/servicenow.ts @@ -6,12 +6,6 @@ import * as rt from 'io-ts'; -export const ServiceNowCaseFieldsRT = rt.union([ - rt.literal('short_description'), - rt.literal('description'), - rt.literal('comments'), -]); - export const ServiceNowFieldsRT = rt.type({ impact: rt.union([rt.string, rt.null]), severity: rt.union([rt.string, rt.null]), diff --git a/x-pack/plugins/case/common/api/helpers.ts b/x-pack/plugins/case/common/api/helpers.ts index 0efdcd3819659..3ed12ba9a68b0 100644 --- a/x-pack/plugins/case/common/api/helpers.ts +++ b/x-pack/plugins/case/common/api/helpers.ts @@ -9,6 +9,7 @@ import { CASE_COMMENTS_URL, CASE_USER_ACTIONS_URL, CASE_COMMENT_DETAILS_URL, + CASE_CONFIGURE_PUSH_URL, } from '../constants'; export const getCaseDetailsUrl = (id: string): string => { @@ -26,3 +27,6 @@ export const getCaseCommentDetailsUrl = (caseId: string, commentId: string): str export const getCaseUserActionUrl = (id: string): string => { return CASE_USER_ACTIONS_URL.replace('{case_id}', id); }; +export const getCaseConfigurePushUrl = (id: string): string => { + return CASE_CONFIGURE_PUSH_URL.replace('{connector_id}', id); +}; diff --git a/x-pack/plugins/case/common/api/saved_object.ts b/x-pack/plugins/case/common/api/saved_object.ts index 73fe767dd717e..58cbc676d2e6a 100644 --- a/x-pack/plugins/case/common/api/saved_object.ts +++ b/x-pack/plugins/case/common/api/saved_object.ts @@ -21,6 +21,7 @@ export const NumberFromString = new rt.Type( export const SavedObjectFindOptionsRt = rt.partial({ defaultSearchOperator: rt.union([rt.literal('AND'), rt.literal('OR')]), + hasReference: rt.type({ id: rt.string, type: rt.string }), fields: rt.array(rt.string), filter: rt.string, page: NumberFromString, diff --git a/x-pack/plugins/case/common/constants.ts b/x-pack/plugins/case/common/constants.ts index 15a318002390f..f4823e81a468b 100644 --- a/x-pack/plugins/case/common/constants.ts +++ b/x-pack/plugins/case/common/constants.ts @@ -14,6 +14,8 @@ export const CASES_URL = '/api/cases'; export const CASE_DETAILS_URL = `${CASES_URL}/{case_id}`; export const CASE_CONFIGURE_URL = `${CASES_URL}/configure`; export const CASE_CONFIGURE_CONNECTORS_URL = `${CASE_CONFIGURE_URL}/connectors`; +export const CASE_CONFIGURE_CONNECTOR_DETAILS_URL = `${CASE_CONFIGURE_CONNECTORS_URL}/{connector_id}`; +export const CASE_CONFIGURE_PUSH_URL = `${CASE_CONFIGURE_CONNECTOR_DETAILS_URL}/push`; export const CASE_COMMENTS_URL = `${CASE_DETAILS_URL}/comments`; export const CASE_COMMENT_DETAILS_URL = `${CASE_DETAILS_URL}/comments/{comment_id}`; export const CASE_REPORTERS_URL = `${CASES_URL}/reporters`; diff --git a/x-pack/plugins/case/server/client/configure/get_fields.ts b/x-pack/plugins/case/server/client/configure/get_fields.ts new file mode 100644 index 0000000000000..9f8988d6355ac --- /dev/null +++ b/x-pack/plugins/case/server/client/configure/get_fields.ts @@ -0,0 +1,31 @@ +/* + * 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 Boom from '@hapi/boom'; + +import { GetFieldsResponse } from '../../../common/api'; +import { ConfigureFields } from '../types'; +import { createDefaultMapping, formatFields } from './utils'; + +export const getFields = () => async ({ + actionsClient, + connectorType, + connectorId, +}: ConfigureFields): Promise => { + const results = await actionsClient.execute({ + actionId: connectorId, + params: { + subAction: 'getFields', + subActionParams: {}, + }, + }); + if (results.status === 'error') { + throw Boom.failedDependency(results.serviceMessage); + } + const fields = formatFields(results.data, connectorType); + + return { fields, defaultMappings: createDefaultMapping(fields, connectorType) }; +}; diff --git a/x-pack/plugins/case/server/client/configure/get_mappings.ts b/x-pack/plugins/case/server/client/configure/get_mappings.ts new file mode 100644 index 0000000000000..4b58bd085f391 --- /dev/null +++ b/x-pack/plugins/case/server/client/configure/get_mappings.ts @@ -0,0 +1,58 @@ +/* + * 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 { ConnectorMappingsAttributes, ConnectorTypes } from '../../../common/api'; +import { CaseClientFactoryArguments, MappingsClient } from '../types'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server/saved_objects'; + +export const getMappings = ({ + savedObjectsClient, + connectorMappingsService, +}: CaseClientFactoryArguments) => async ({ + actionsClient, + caseClient, + connectorType, + connectorId, +}: MappingsClient): Promise => { + if (connectorType === ConnectorTypes.none) { + return []; + } + const myConnectorMappings = await connectorMappingsService.find({ + client: savedObjectsClient, + options: { + hasReference: { + type: ACTION_SAVED_OBJECT_TYPE, + id: connectorId, + }, + }, + }); + let theMapping; + // Create connector mappings if there are none + if (myConnectorMappings.total === 0) { + const res = await caseClient.getFields({ + actionsClient, + connectorId, + connectorType, + }); + theMapping = await connectorMappingsService.post({ + client: savedObjectsClient, + attributes: { + mappings: res.defaultMappings, + }, + references: [ + { + type: ACTION_SAVED_OBJECT_TYPE, + name: `associated-${ACTION_SAVED_OBJECT_TYPE}`, + id: connectorId, + }, + ], + }); + } else { + theMapping = myConnectorMappings.saved_objects[0]; + } + return theMapping ? theMapping.attributes.mappings : []; +}; diff --git a/x-pack/plugins/case/server/client/configure/utils.test.ts b/x-pack/plugins/case/server/client/configure/utils.test.ts new file mode 100644 index 0000000000000..91c8259cb2c55 --- /dev/null +++ b/x-pack/plugins/case/server/client/configure/utils.test.ts @@ -0,0 +1,545 @@ +/* + * 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 { + JiraGetFieldsResponse, + ResilientGetFieldsResponse, + ServiceNowGetFieldsResponse, +} from '../../../../actions/server/types'; +import { formatFields } from './utils'; +import { ConnectorTypes } from '../../../common/api/connectors'; + +const jiraFields: JiraGetFieldsResponse = { + summary: { + required: true, + allowedValues: [], + defaultValue: {}, + schema: { + type: 'string', + }, + name: 'Summary', + }, + issuetype: { + required: true, + allowedValues: [ + { + self: 'https://siem-kibana.atlassian.net/rest/api/2/issuetype/10023', + id: '10023', + description: 'A problem or error.', + iconUrl: + 'https://siem-kibana.atlassian.net/secure/viewavatar?size=medium&avatarId=10303&avatarType=issuetype', + name: 'Bug', + subtask: false, + avatarId: 10303, + }, + ], + defaultValue: {}, + schema: { + type: 'issuetype', + }, + name: 'Issue Type', + }, + attachment: { + required: false, + allowedValues: [], + defaultValue: {}, + schema: { + type: 'array', + items: 'attachment', + }, + name: 'Attachment', + }, + duedate: { + required: false, + allowedValues: [], + defaultValue: {}, + schema: { + type: 'date', + }, + name: 'Due date', + }, + description: { + required: false, + allowedValues: [], + defaultValue: {}, + schema: { + type: 'string', + }, + name: 'Description', + }, + project: { + required: true, + allowedValues: [ + { + self: 'https://siem-kibana.atlassian.net/rest/api/2/project/10015', + id: '10015', + key: 'RJ2', + name: 'RJ2', + projectTypeKey: 'business', + simplified: false, + avatarUrls: { + '48x48': + 'https://siem-kibana.atlassian.net/secure/projectavatar?pid=10015&avatarId=10412', + '24x24': + 'https://siem-kibana.atlassian.net/secure/projectavatar?size=small&s=small&pid=10015&avatarId=10412', + '16x16': + 'https://siem-kibana.atlassian.net/secure/projectavatar?size=xsmall&s=xsmall&pid=10015&avatarId=10412', + '32x32': + 'https://siem-kibana.atlassian.net/secure/projectavatar?size=medium&s=medium&pid=10015&avatarId=10412', + }, + }, + ], + defaultValue: {}, + schema: { + type: 'project', + }, + name: 'Project', + }, + assignee: { + required: false, + allowedValues: [], + defaultValue: {}, + schema: { + type: 'user', + }, + name: 'Assignee', + }, + labels: { + required: false, + allowedValues: [], + defaultValue: {}, + schema: { + type: 'array', + items: 'string', + }, + name: 'Labels', + }, +}; +const resilientFields: ResilientGetFieldsResponse = [ + { input_type: 'text', name: 'addr', read_only: false, text: 'Address' }, + { + input_type: 'boolean', + name: 'alberta_health_risk_assessment', + read_only: false, + text: 'Alberta Health Risk Assessment', + }, + { input_type: 'number', name: 'hard_liability', read_only: true, text: 'Assessed Liability' }, + { input_type: 'text', name: 'city', read_only: false, text: 'City' }, + { input_type: 'select', name: 'country', read_only: false, text: 'Country/Region' }, + { input_type: 'select_owner', name: 'creator_id', read_only: true, text: 'Created By' }, + { input_type: 'select', name: 'crimestatus_id', read_only: false, text: 'Criminal Activity' }, + { input_type: 'boolean', name: 'data_encrypted', read_only: false, text: 'Data Encrypted' }, + { input_type: 'select', name: 'data_format', read_only: false, text: 'Data Format' }, + { input_type: 'datetimepicker', name: 'end_date', read_only: true, text: 'Date Closed' }, + { input_type: 'datetimepicker', name: 'create_date', read_only: true, text: 'Date Created' }, + { + input_type: 'datetimepicker', + name: 'determined_date', + read_only: false, + text: 'Date Determined', + }, + { + input_type: 'datetimepicker', + name: 'discovered_date', + read_only: false, + required: 'always', + text: 'Date Discovered', + }, + { input_type: 'datetimepicker', name: 'start_date', read_only: false, text: 'Date Occurred' }, + { input_type: 'select', name: 'exposure_dept_id', read_only: false, text: 'Department' }, + { input_type: 'textarea', name: 'description', read_only: false, text: 'Description' }, + { input_type: 'boolean', name: 'employee_involved', read_only: false, text: 'Employee Involved' }, + { input_type: 'boolean', name: 'data_contained', read_only: false, text: 'Exposure Resolved' }, + { input_type: 'select', name: 'exposure_type_id', read_only: false, text: 'Exposure Type' }, + { + input_type: 'multiselect', + name: 'gdpr_breach_circumstances', + read_only: false, + text: 'GDPR Breach Circumstances', + }, + { input_type: 'select', name: 'gdpr_breach_type', read_only: false, text: 'GDPR Breach Type' }, + { + input_type: 'textarea', + name: 'gdpr_breach_type_comment', + read_only: false, + text: 'GDPR Breach Type Comment', + }, + { input_type: 'select', name: 'gdpr_consequences', read_only: false, text: 'GDPR Consequences' }, + { + input_type: 'textarea', + name: 'gdpr_consequences_comment', + read_only: false, + text: 'GDPR Consequences Comment', + }, + { + input_type: 'select', + name: 'gdpr_final_assessment', + read_only: false, + text: 'GDPR Final Assessment', + }, + { + input_type: 'textarea', + name: 'gdpr_final_assessment_comment', + read_only: false, + text: 'GDPR Final Assessment Comment', + }, + { + input_type: 'select', + name: 'gdpr_identification', + read_only: false, + text: 'GDPR Identification', + }, + { + input_type: 'textarea', + name: 'gdpr_identification_comment', + read_only: false, + text: 'GDPR Identification Comment', + }, + { + input_type: 'select', + name: 'gdpr_personal_data', + read_only: false, + text: 'GDPR Personal Data', + }, + { + input_type: 'textarea', + name: 'gdpr_personal_data_comment', + read_only: false, + text: 'GDPR Personal Data Comment', + }, + { + input_type: 'boolean', + name: 'gdpr_subsequent_notification', + read_only: false, + text: 'GDPR Subsequent Notification', + }, + { input_type: 'number', name: 'id', read_only: true, text: 'ID' }, + { input_type: 'boolean', name: 'impact_likely', read_only: false, text: 'Impact Likely' }, + { + input_type: 'boolean', + name: 'ny_impact_likely', + read_only: false, + text: 'Impact Likely for New York', + }, + { + input_type: 'boolean', + name: 'or_impact_likely', + read_only: false, + text: 'Impact Likely for Oregon', + }, + { + input_type: 'boolean', + name: 'wa_impact_likely', + read_only: false, + text: 'Impact Likely for Washington', + }, + { input_type: 'boolean', name: 'confirmed', read_only: false, text: 'Incident Disposition' }, + { input_type: 'multiselect', name: 'incident_type_ids', read_only: false, text: 'Incident Type' }, + { + input_type: 'text', + name: 'exposure_individual_name', + read_only: false, + text: 'Individual Name', + }, + { + input_type: 'select', + name: 'harmstatus_id', + read_only: false, + text: 'Is harm/risk/misuse foreseeable?', + }, + { input_type: 'text', name: 'jurisdiction_name', read_only: false, text: 'Jurisdiction' }, + { + input_type: 'datetimepicker', + name: 'inc_last_modified_date', + read_only: true, + text: 'Last Modified', + }, + { + input_type: 'multiselect', + name: 'gdpr_lawful_data_processing_categories', + read_only: false, + text: 'Lawful Data Processing Categories', + }, + { input_type: 'multiselect_members', name: 'members', read_only: false, text: 'Members' }, + { input_type: 'text', name: 'name', read_only: false, required: 'always', text: 'Name' }, + { input_type: 'boolean', name: 'negative_pr_likely', read_only: false, text: 'Negative PR' }, + { input_type: 'datetimepicker', name: 'due_date', read_only: true, text: 'Next Due Date' }, + { + input_type: 'multiselect', + name: 'nist_attack_vectors', + read_only: false, + text: 'NIST Attack Vectors', + }, + { input_type: 'select', name: 'org_handle', read_only: true, text: 'Organization' }, + { input_type: 'select_owner', name: 'owner_id', read_only: false, text: 'Owner' }, + { input_type: 'select', name: 'phase_id', read_only: true, text: 'Phase' }, + { + input_type: 'select', + name: 'pipeda_other_factors', + read_only: false, + text: 'PIPEDA Other Factors', + }, + { + input_type: 'textarea', + name: 'pipeda_other_factors_comment', + read_only: false, + text: 'PIPEDA Other Factors Comment', + }, + { + input_type: 'select', + name: 'pipeda_overall_assessment', + read_only: false, + text: 'PIPEDA Overall Assessment', + }, + { + input_type: 'textarea', + name: 'pipeda_overall_assessment_comment', + read_only: false, + text: 'PIPEDA Overall Assessment Comment', + }, + { + input_type: 'select', + name: 'pipeda_probability_of_misuse', + read_only: false, + text: 'PIPEDA Probability of Misuse', + }, + { + input_type: 'textarea', + name: 'pipeda_probability_of_misuse_comment', + read_only: false, + text: 'PIPEDA Probability of Misuse Comment', + }, + { + input_type: 'select', + name: 'pipeda_sensitivity_of_pi', + read_only: false, + text: 'PIPEDA Sensitivity of PI', + }, + { + input_type: 'textarea', + name: 'pipeda_sensitivity_of_pi_comment', + read_only: false, + text: 'PIPEDA Sensitivity of PI Comment', + }, + { input_type: 'text', name: 'reporter', read_only: false, text: 'Reporting Individual' }, + { + input_type: 'select', + name: 'resolution_id', + read_only: false, + required: 'close', + text: 'Resolution', + }, + { + input_type: 'textarea', + name: 'resolution_summary', + read_only: false, + required: 'close', + text: 'Resolution Summary', + }, + { input_type: 'select', name: 'gdpr_harm_risk', read_only: false, text: 'Risk of Harm' }, + { input_type: 'select', name: 'severity_code', read_only: false, text: 'Severity' }, + { input_type: 'boolean', name: 'inc_training', read_only: true, text: 'Simulation' }, + { input_type: 'multiselect', name: 'data_source_ids', read_only: false, text: 'Source of Data' }, + { input_type: 'select', name: 'state', read_only: false, text: 'State' }, + { input_type: 'select', name: 'plan_status', read_only: false, text: 'Status' }, + { input_type: 'select', name: 'exposure_vendor_id', read_only: false, text: 'Vendor' }, + { + input_type: 'boolean', + name: 'data_compromised', + read_only: false, + text: 'Was personal information or personal data involved?', + }, + { + input_type: 'select', + name: 'workspace', + read_only: false, + required: 'always', + text: 'Workspace', + }, + { input_type: 'text', name: 'zip', read_only: false, text: 'Zip' }, +]; +const serviceNowFields: ServiceNowGetFieldsResponse = [ + { + column_label: 'Approval', + mandatory: 'false', + max_length: '40', + element: 'approval', + }, + { + column_label: 'Close notes', + mandatory: 'false', + max_length: '4000', + element: 'close_notes', + }, + { + column_label: 'Contact type', + mandatory: 'false', + max_length: '40', + element: 'contact_type', + }, + { + column_label: 'Correlation display', + mandatory: 'false', + max_length: '100', + element: 'correlation_display', + }, + { + column_label: 'Correlation ID', + mandatory: 'false', + max_length: '100', + element: 'correlation_id', + }, + { + column_label: 'Description', + mandatory: 'false', + max_length: '4000', + element: 'description', + }, + { + column_label: 'Number', + mandatory: 'false', + max_length: '40', + element: 'number', + }, + { + column_label: 'Short description', + mandatory: 'false', + max_length: '160', + element: 'short_description', + }, + { + column_label: 'Created by', + mandatory: 'false', + max_length: '40', + element: 'sys_created_by', + }, + { + column_label: 'Updated by', + mandatory: 'false', + max_length: '40', + element: 'sys_updated_by', + }, + { + column_label: 'Upon approval', + mandatory: 'false', + max_length: '40', + element: 'upon_approval', + }, + { + column_label: 'Upon reject', + mandatory: 'false', + max_length: '40', + element: 'upon_reject', + }, +]; + +const formatFieldsTestData = [ + { + expected: [ + { id: 'summary', name: 'Summary', required: true, type: 'text' }, + { id: 'description', name: 'Description', required: false, type: 'text' }, + ], + fields: jiraFields, + type: ConnectorTypes.jira, + }, + { + expected: [ + { id: 'addr', name: 'Address', required: false, type: 'text' }, + { id: 'city', name: 'City', required: false, type: 'text' }, + { id: 'description', name: 'Description', required: false, type: 'textarea' }, + { + id: 'gdpr_breach_type_comment', + name: 'GDPR Breach Type Comment', + required: false, + type: 'textarea', + }, + { + id: 'gdpr_consequences_comment', + name: 'GDPR Consequences Comment', + required: false, + type: 'textarea', + }, + { + id: 'gdpr_final_assessment_comment', + name: 'GDPR Final Assessment Comment', + required: false, + type: 'textarea', + }, + { + id: 'gdpr_identification_comment', + name: 'GDPR Identification Comment', + required: false, + type: 'textarea', + }, + { + id: 'gdpr_personal_data_comment', + name: 'GDPR Personal Data Comment', + required: false, + type: 'textarea', + }, + { id: 'exposure_individual_name', name: 'Individual Name', required: false, type: 'text' }, + { id: 'jurisdiction_name', name: 'Jurisdiction', required: false, type: 'text' }, + { id: 'name', name: 'Name', required: true, type: 'text' }, + { + id: 'pipeda_other_factors_comment', + name: 'PIPEDA Other Factors Comment', + required: false, + type: 'textarea', + }, + { + id: 'pipeda_overall_assessment_comment', + name: 'PIPEDA Overall Assessment Comment', + required: false, + type: 'textarea', + }, + { + id: 'pipeda_probability_of_misuse_comment', + name: 'PIPEDA Probability of Misuse Comment', + required: false, + type: 'textarea', + }, + { + id: 'pipeda_sensitivity_of_pi_comment', + name: 'PIPEDA Sensitivity of PI Comment', + required: false, + type: 'textarea', + }, + { id: 'reporter', name: 'Reporting Individual', required: false, type: 'text' }, + { id: 'resolution_summary', name: 'Resolution Summary', required: false, type: 'textarea' }, + { id: 'zip', name: 'Zip', required: false, type: 'text' }, + ], + fields: resilientFields, + type: ConnectorTypes.resilient, + }, + { + expected: [ + { id: 'approval', name: 'Approval', required: false, type: 'text' }, + { id: 'close_notes', name: 'Close notes', required: false, type: 'textarea' }, + { id: 'contact_type', name: 'Contact type', required: false, type: 'text' }, + { id: 'correlation_display', name: 'Correlation display', required: false, type: 'text' }, + { id: 'correlation_id', name: 'Correlation ID', required: false, type: 'text' }, + { id: 'description', name: 'Description', required: false, type: 'textarea' }, + { id: 'number', name: 'Number', required: false, type: 'text' }, + { id: 'short_description', name: 'Short description', required: false, type: 'text' }, + { id: 'sys_created_by', name: 'Created by', required: false, type: 'text' }, + { id: 'sys_updated_by', name: 'Updated by', required: false, type: 'text' }, + { id: 'upon_approval', name: 'Upon approval', required: false, type: 'text' }, + { id: 'upon_reject', name: 'Upon reject', required: false, type: 'text' }, + ], + fields: serviceNowFields, + type: ConnectorTypes.servicenow, + }, +]; +describe('client/configure/utils', () => { + describe('formatFields', () => { + formatFieldsTestData.forEach(({ expected, fields, type }) => { + it(`normalizes ${type} fields to common type ConnectorField`, () => { + const result = formatFields(fields, type); + expect(result).toEqual(expected); + }); + }); + }); +}); diff --git a/x-pack/plugins/case/server/client/configure/utils.ts b/x-pack/plugins/case/server/client/configure/utils.ts new file mode 100644 index 0000000000000..2fc6db8c86cbc --- /dev/null +++ b/x-pack/plugins/case/server/client/configure/utils.ts @@ -0,0 +1,169 @@ +/* + * 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 { + ConnectorField, + ConnectorMappingsAttributes, + ConnectorTypes, +} from '../../../common/api/connectors'; +import { + JiraGetFieldsResponse, + ResilientGetFieldsResponse, + ServiceNowGetFieldsResponse, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../actions/server/types'; + +const normalizeJiraFields = (jiraFields: JiraGetFieldsResponse): ConnectorField[] => + Object.keys(jiraFields).reduce( + (acc, data) => + jiraFields[data].schema.type === 'string' + ? [ + ...acc, + { + id: data, + name: jiraFields[data].name, + required: jiraFields[data].required, + type: 'text', + }, + ] + : acc, + [] + ); + +const normalizeResilientFields = (resilientFields: ResilientGetFieldsResponse): ConnectorField[] => + resilientFields.reduce( + (acc: ConnectorField[], data) => + (data.input_type === 'textarea' || data.input_type === 'text') && !data.read_only + ? [ + ...acc, + { + id: data.name, + name: data.text, + required: data.required === 'always', + type: data.input_type, + }, + ] + : acc, + [] + ); +const normalizeServiceNowFields = (snFields: ServiceNowGetFieldsResponse): ConnectorField[] => + snFields.reduce( + (acc, data) => [ + ...acc, + { + id: data.element, + name: data.column_label, + required: data.mandatory === 'true', + type: parseFloat(data.max_length) > 160 ? 'textarea' : 'text', + }, + ], + [] + ); + +export const formatFields = (theData: unknown, theType: string): ConnectorField[] => { + switch (theType) { + case ConnectorTypes.jira: + return normalizeJiraFields(theData as JiraGetFieldsResponse); + case ConnectorTypes.resilient: + return normalizeResilientFields(theData as ResilientGetFieldsResponse); + case ConnectorTypes.servicenow: + return normalizeServiceNowFields(theData as ServiceNowGetFieldsResponse); + default: + return []; + } +}; +const findTextField = (fields: ConnectorField[]): string => + ( + fields.find((field: ConnectorField) => field.type === 'text' && field.required) ?? + fields.find((field: ConnectorField) => field.type === 'text') + )?.id ?? ''; +const findTextAreaField = (fields: ConnectorField[]): string => + ( + fields.find((field: ConnectorField) => field.type === 'textarea' && field.required) ?? + fields.find((field: ConnectorField) => field.type === 'textarea') ?? + fields.find((field: ConnectorField) => field.type === 'text') + )?.id ?? ''; + +const getPreferredFields = (theType: string) => { + let title: string = ''; + let description: string = ''; + if (theType === ConnectorTypes.jira) { + title = 'summary'; + description = 'description'; + } else if (theType === ConnectorTypes.resilient) { + title = 'name'; + description = 'description'; + } else if (theType === ConnectorTypes.servicenow) { + title = 'short_description'; + description = 'description'; + } + return { title, description }; +}; + +const getRemainingFields = (fields: ConnectorField[], titleTarget: string) => + fields.filter((field: ConnectorField) => field.id !== titleTarget); + +const getDynamicFields = (fields: ConnectorField[], dynamicTitle = findTextField(fields)) => { + const remainingFields = getRemainingFields(fields, dynamicTitle); + const dynamicDescription = findTextAreaField(remainingFields); + return { + description: dynamicDescription, + title: dynamicTitle, + }; +}; + +const getField = (fields: ConnectorField[], fieldId: string) => + fields.find((field: ConnectorField) => field.id === fieldId); + +// if dynamic title is not required and preferred is, true +const shouldTargetBePreferred = ( + fields: ConnectorField[], + dynamic: string, + preferred: string +): boolean => { + if (dynamic !== preferred) { + const dynamicT = getField(fields, dynamic); + const preferredT = getField(fields, preferred); + return preferredT != null && !(dynamicT?.required && !preferredT.required); + } + return false; +}; +export const createDefaultMapping = ( + fields: ConnectorField[], + theType: string +): ConnectorMappingsAttributes[] => { + const { description: dynamicDescription, title: dynamicTitle } = getDynamicFields(fields); + const { description: preferredDescription, title: preferredTitle } = getPreferredFields(theType); + let titleTarget = dynamicTitle; + let descriptionTarget = dynamicDescription; + if (preferredTitle.length > 0 && preferredDescription.length > 0) { + if (shouldTargetBePreferred(fields, dynamicTitle, preferredTitle)) { + const { description: dynamicDescriptionOverwrite } = getDynamicFields(fields, preferredTitle); + titleTarget = preferredTitle; + descriptionTarget = dynamicDescriptionOverwrite; + } + if (shouldTargetBePreferred(fields, descriptionTarget, preferredDescription)) { + descriptionTarget = preferredDescription; + } + } + return [ + { + source: 'title', + target: titleTarget, + action_type: 'overwrite', + }, + { + source: 'description', + target: descriptionTarget, + action_type: 'overwrite', + }, + { + source: 'comments', + target: 'comments', + action_type: 'append', + }, + ]; +}; diff --git a/x-pack/plugins/case/server/client/index.test.ts b/x-pack/plugins/case/server/client/index.test.ts index ef4491204d9f5..0c54db11287d8 100644 --- a/x-pack/plugins/case/server/client/index.test.ts +++ b/x-pack/plugins/case/server/client/index.test.ts @@ -8,6 +8,7 @@ import { KibanaRequest, RequestHandlerContext } from 'kibana/server'; import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; import { createCaseClient } from '.'; import { + connectorMappingsServiceMock, createCaseServiceMock, createConfigureServiceMock, createUserActionServiceMock, @@ -24,12 +25,13 @@ jest.mock('./cases/update'); jest.mock('./comments/add'); jest.mock('./alerts/update_status'); -const caseService = createCaseServiceMock(); const caseConfigureService = createConfigureServiceMock(); -const userActionService = createUserActionServiceMock(); const alertsService = createAlertServiceMock(); -const savedObjectsClient = savedObjectsClientMock.create(); +const caseService = createCaseServiceMock(); +const connectorMappingsService = connectorMappingsServiceMock(); const request = {} as KibanaRequest; +const savedObjectsClient = savedObjectsClientMock.create(); +const userActionService = createUserActionServiceMock(); const context = {} as RequestHandlerContext; const createMock = create as jest.Mock; @@ -40,53 +42,58 @@ const updateAlertsStatusMock = updateAlertsStatus as jest.Mock; describe('createCaseClient()', () => { test('it creates the client correctly', async () => { createCaseClient({ - savedObjectsClient, - request, + alertsService, caseConfigureService, caseService, - userActionService, - alertsService, + connectorMappingsService, context, + request, + savedObjectsClient, + userActionService, }); expect(createMock).toHaveBeenCalledWith({ - savedObjectsClient, - request, + alertsService, caseConfigureService, caseService, - userActionService, - alertsService, + connectorMappingsService, context, + request, + savedObjectsClient, + userActionService, }); expect(updateMock).toHaveBeenCalledWith({ - savedObjectsClient, - request, + alertsService, caseConfigureService, caseService, - userActionService, - alertsService, + connectorMappingsService, context, + request, + savedObjectsClient, + userActionService, }); expect(addCommentMock).toHaveBeenCalledWith({ - savedObjectsClient, - request, + alertsService, caseConfigureService, caseService, - userActionService, - alertsService, + connectorMappingsService, context, + request, + savedObjectsClient, + userActionService, }); expect(updateAlertsStatusMock).toHaveBeenCalledWith({ - savedObjectsClient, - request, + alertsService, caseConfigureService, caseService, - userActionService, - alertsService, + connectorMappingsService, context, + request, + savedObjectsClient, + userActionService, }); }); }); diff --git a/x-pack/plugins/case/server/client/index.ts b/x-pack/plugins/case/server/client/index.ts index bf43921b46466..70eb3282dd243 100644 --- a/x-pack/plugins/case/server/client/index.ts +++ b/x-pack/plugins/case/server/client/index.ts @@ -8,55 +8,73 @@ import { CaseClientFactoryArguments, CaseClient } from './types'; import { create } from './cases/create'; import { update } from './cases/update'; import { addComment } from './comments/add'; +import { getFields } from './configure/get_fields'; +import { getMappings } from './configure/get_mappings'; import { updateAlertsStatus } from './alerts/update_status'; export { CaseClient } from './types'; export const createCaseClient = ({ - savedObjectsClient, - request, caseConfigureService, caseService, + connectorMappingsService, + request, + savedObjectsClient, userActionService, alertsService, context, }: CaseClientFactoryArguments): CaseClient => { return { create: create({ - savedObjectsClient, - request, + alertsService, caseConfigureService, caseService, - userActionService, - alertsService, + connectorMappingsService, context, + request, + savedObjectsClient, + userActionService, }), update: update({ - savedObjectsClient, - request, + alertsService, caseConfigureService, caseService, - userActionService, - alertsService, + connectorMappingsService, context, + request, + savedObjectsClient, + userActionService, }), addComment: addComment({ - savedObjectsClient, - request, + alertsService, caseConfigureService, caseService, + connectorMappingsService, + context, + request, + savedObjectsClient, userActionService, + }), + getFields: getFields(), + getMappings: getMappings({ alertsService, + caseConfigureService, + caseService, + connectorMappingsService, context, + request, + savedObjectsClient, + userActionService, }), updateAlertsStatus: updateAlertsStatus({ - savedObjectsClient, - request, + alertsService, caseConfigureService, caseService, - userActionService, - alertsService, + connectorMappingsService, context, + request, + savedObjectsClient, + userActionService, }), }; }; diff --git a/x-pack/plugins/case/server/client/mocks.ts b/x-pack/plugins/case/server/client/mocks.ts index dd4e8b52b4dc6..54af9bee2b316 100644 --- a/x-pack/plugins/case/server/client/mocks.ts +++ b/x-pack/plugins/case/server/client/mocks.ts @@ -8,10 +8,11 @@ import { KibanaRequest, RequestHandlerContext } from 'kibana/server'; import { loggingSystemMock, elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; import { actionsClientMock } from '../../../actions/server/mocks'; import { - CaseService, + AlertService, CaseConfigureService, + CaseService, CaseUserActionServiceSetup, - AlertService, + ConnectorMappingsService, } from '../services'; import { CaseClient } from './types'; import { authenticationMock } from '../routes/api/__fixtures__'; @@ -20,9 +21,11 @@ import { getActions } from '../routes/api/__mocks__/request_responses'; export type CaseClientMock = jest.Mocked; export const createCaseClientMock = (): CaseClientMock => ({ + addComment: jest.fn(), create: jest.fn(), + getFields: jest.fn(), + getMappings: jest.fn(), update: jest.fn(), - addComment: jest.fn(), updateAlertsStatus: jest.fn(), }); @@ -41,11 +44,14 @@ export const createCaseClientWithMockSavedObjectsClient = async ( const caseServicePlugin = new CaseService(log); const caseConfigureServicePlugin = new CaseConfigureService(log); + const connectorMappingsServicePlugin = new ConnectorMappingsService(log); const caseService = await caseServicePlugin.setup({ authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(), }); const caseConfigureService = await caseConfigureServicePlugin.setup(); + + const connectorMappingsService = await connectorMappingsServicePlugin.setup(); const userActionService = { postUserActions: jest.fn(), getUserActions: jest.fn(), @@ -75,11 +81,11 @@ export const createCaseClientWithMockSavedObjectsClient = async ( request, caseService, caseConfigureService, + connectorMappingsService, userActionService, alertsService, context, }); - return { client: caseClient, services: { userActionService }, diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index a9e8494c43dbc..ec83f1ec1ff7d 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -5,13 +5,16 @@ */ import { KibanaRequest, SavedObjectsClientContract, RequestHandlerContext } from 'kibana/server'; +import { ActionsClient } from '../../../actions/server'; import { CasePostRequest, - CasesPatchRequest, - CommentRequest, CaseResponse, + CasesPatchRequest, CasesResponse, CaseStatuses, + CommentRequest, + ConnectorMappingsAttributes, + GetFieldsResponse, } from '../../common/api'; import { CaseConfigureServiceSetup, @@ -19,7 +22,7 @@ import { CaseUserActionServiceSetup, AlertServiceContract, } from '../services'; - +import { ConnectorMappingsServiceSetup } from '../services/connector_mappings'; export interface CaseClientCreate { theCase: CasePostRequest; } @@ -43,18 +46,33 @@ export interface CaseClientUpdateAlertsStatus { type PartialExceptFor = Partial & Pick; export interface CaseClientFactoryArguments { - savedObjectsClient: SavedObjectsClientContract; - request: KibanaRequest; caseConfigureService: CaseConfigureServiceSetup; caseService: CaseServiceSetup; + connectorMappingsService: ConnectorMappingsServiceSetup; + request: KibanaRequest; + savedObjectsClient: SavedObjectsClientContract; userActionService: CaseUserActionServiceSetup; alertsService: AlertServiceContract; context?: PartialExceptFor; } +export interface ConfigureFields { + actionsClient: ActionsClient; + connectorId: string; + connectorType: string; +} export interface CaseClient { + addComment: (args: CaseClientAddComment) => Promise; create: (args: CaseClientCreate) => Promise; + getFields: (args: ConfigureFields) => Promise; + getMappings: (args: MappingsClient) => Promise; update: (args: CaseClientUpdate) => Promise; - addComment: (args: CaseClientAddComment) => Promise; updateAlertsStatus: (args: CaseClientUpdateAlertsStatus) => Promise; } + +export interface MappingsClient { + actionsClient: ActionsClient; + caseClient: CaseClient; + connectorId: string; + connectorType: string; +} diff --git a/x-pack/plugins/case/server/connectors/case/index.test.ts b/x-pack/plugins/case/server/connectors/case/index.test.ts index 9f5b186c0c687..442b23da87c96 100644 --- a/x-pack/plugins/case/server/connectors/case/index.test.ts +++ b/x-pack/plugins/case/server/connectors/case/index.test.ts @@ -11,6 +11,7 @@ import { actionsMock } from '../../../../actions/server/mocks'; import { validateParams } from '../../../../actions/server/lib'; import { ConnectorTypes, CommentType, CaseStatuses } from '../../../common/api'; import { + connectorMappingsServiceMock, createCaseServiceMock, createConfigureServiceMock, createUserActionServiceMock, @@ -35,12 +36,14 @@ describe('case connector', () => { const logger = loggingSystemMock.create().get() as jest.Mocked; const caseService = createCaseServiceMock(); const caseConfigureService = createConfigureServiceMock(); + const connectorMappingsService = connectorMappingsServiceMock(); const userActionService = createUserActionServiceMock(); const alertsService = createAlertServiceMock(); caseActionType = getActionType({ logger, caseService, caseConfigureService, + connectorMappingsService, userActionService, alertsService, }); diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts index 48124b8ae32eb..2195786f718ab 100644 --- a/x-pack/plugins/case/server/connectors/case/index.ts +++ b/x-pack/plugins/case/server/connectors/case/index.ts @@ -29,6 +29,7 @@ export function getActionType({ logger, caseService, caseConfigureService, + connectorMappingsService, userActionService, alertsService, }: GetActionTypeParams): CaseActionType { @@ -41,11 +42,12 @@ export function getActionType({ params: CaseExecutorParamsSchema, }, executor: curry(executor)({ - logger, - caseService, + alertsService, caseConfigureService, + caseService, + connectorMappingsService, + logger, userActionService, - alertsService, }), }; } @@ -53,11 +55,12 @@ export function getActionType({ // action executor async function executor( { - logger, - caseService, + alertsService, caseConfigureService, + caseService, + connectorMappingsService, + logger, userActionService, - alertsService, }: GetActionTypeParams, execOptions: CaseActionTypeExecutorOptions ): Promise> { @@ -71,6 +74,7 @@ async function executor( request: {} as KibanaRequest, caseService, caseConfigureService, + connectorMappingsService, userActionService, alertsService, // TODO: When case connector is enabled we should figure out how to pass the context. diff --git a/x-pack/plugins/case/server/connectors/index.ts b/x-pack/plugins/case/server/connectors/index.ts index f373445719164..7fd09e61f2144 100644 --- a/x-pack/plugins/case/server/connectors/index.ts +++ b/x-pack/plugins/case/server/connectors/index.ts @@ -16,6 +16,7 @@ import { CaseServiceSetup, CaseConfigureServiceSetup, CaseUserActionServiceSetup, + ConnectorMappingsServiceSetup, AlertServiceContract, } from '../services'; @@ -26,6 +27,7 @@ export interface GetActionTypeParams { logger: Logger; caseService: CaseServiceSetup; caseConfigureService: CaseConfigureServiceSetup; + connectorMappingsService: ConnectorMappingsServiceSetup; userActionService: CaseUserActionServiceSetup; alertsService: AlertServiceContract; } @@ -46,6 +48,7 @@ export const registerConnectors = ({ logger, caseService, caseConfigureService, + connectorMappingsService, userActionService, alertsService, }: RegisterConnectorsArgs) => { @@ -54,6 +57,7 @@ export const registerConnectors = ({ logger, caseService, caseConfigureService, + connectorMappingsService, userActionService, alertsService, }) diff --git a/x-pack/plugins/case/server/index.ts b/x-pack/plugins/case/server/index.ts index d4f06c8a2304c..19f3a15396729 100644 --- a/x-pack/plugins/case/server/index.ts +++ b/x-pack/plugins/case/server/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PluginInitializerContext } from '../../../../src/core/server'; +import { PluginInitializerContext } from 'kibana/server'; import { ConfigSchema } from './config'; import { CasePlugin } from './plugin'; diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index 8d508ce0b76b1..915656895e8c8 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -22,9 +22,10 @@ import { APP_ID } from '../common/constants'; import { ConfigType } from './config'; import { initCaseApi } from './routes/api'; import { - caseSavedObjectType, - caseConfigureSavedObjectType, caseCommentSavedObjectType, + caseConfigureSavedObjectType, + caseConnectorMappingsSavedObjectType, + caseSavedObjectType, caseUserActionSavedObjectType, } from './saved_object_types'; import { @@ -34,6 +35,8 @@ import { CaseServiceSetup, CaseUserActionService, CaseUserActionServiceSetup, + ConnectorMappingsService, + ConnectorMappingsServiceSetup, AlertService, AlertServiceContract, } from './services'; @@ -51,8 +54,9 @@ export interface PluginsSetup { export class CasePlugin { private readonly log: Logger; - private caseService?: CaseServiceSetup; private caseConfigureService?: CaseConfigureServiceSetup; + private caseService?: CaseServiceSetup; + private connectorMappingsService?: ConnectorMappingsServiceSetup; private userActionService?: CaseUserActionServiceSetup; private alertsService?: AlertService; @@ -67,9 +71,10 @@ export class CasePlugin { return; } - core.savedObjects.registerType(caseSavedObjectType); core.savedObjects.registerType(caseCommentSavedObjectType); core.savedObjects.registerType(caseConfigureSavedObjectType); + core.savedObjects.registerType(caseConnectorMappingsSavedObjectType); + core.savedObjects.registerType(caseSavedObjectType); core.savedObjects.registerType(caseUserActionSavedObjectType); this.log.debug( @@ -82,6 +87,7 @@ export class CasePlugin { authentication: plugins.security != null ? plugins.security.authc : null, }); this.caseConfigureService = await new CaseConfigureService(this.log).setup(); + this.connectorMappingsService = await new ConnectorMappingsService(this.log).setup(); this.userActionService = await new CaseUserActionService(this.log).setup(); this.alertsService = new AlertService(); @@ -91,6 +97,7 @@ export class CasePlugin { core, caseService: this.caseService, caseConfigureService: this.caseConfigureService, + connectorMappingsService: this.connectorMappingsService, userActionService: this.userActionService, alertsService: this.alertsService, }) @@ -100,6 +107,7 @@ export class CasePlugin { initCaseApi({ caseService: this.caseService, caseConfigureService: this.caseConfigureService, + connectorMappingsService: this.connectorMappingsService, userActionService: this.userActionService, router, }); @@ -109,6 +117,7 @@ export class CasePlugin { logger: this.log, caseService: this.caseService, caseConfigureService: this.caseConfigureService, + connectorMappingsService: this.connectorMappingsService, userActionService: this.userActionService, alertsService: this.alertsService, }); @@ -127,6 +136,7 @@ export class CasePlugin { request, caseService: this.caseService!, caseConfigureService: this.caseConfigureService!, + connectorMappingsService: this.connectorMappingsService!, userActionService: this.userActionService!, alertsService: this.alertsService!, context, @@ -146,12 +156,14 @@ export class CasePlugin { core, caseService, caseConfigureService, + connectorMappingsService, userActionService, alertsService, }: { core: CoreSetup; caseService: CaseServiceSetup; caseConfigureService: CaseConfigureServiceSetup; + connectorMappingsService: ConnectorMappingsServiceSetup; userActionService: CaseUserActionServiceSetup; alertsService: AlertServiceContract; }): IContextProvider, typeof APP_ID> => { @@ -163,6 +175,7 @@ export class CasePlugin { savedObjectsClient: savedObjects.getScopedClient(request), caseService, caseConfigureService, + connectorMappingsService, userActionService, alertsService, request, diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts index 8bbd419e6315b..1335d6107744c 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts @@ -15,16 +15,19 @@ import { CASE_COMMENT_SAVED_OBJECT, CASE_SAVED_OBJECT, CASE_CONFIGURE_SAVED_OBJECT, + CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, } from '../../../saved_object_types'; export const createMockSavedObjectsRepository = ({ caseSavedObject = [], caseCommentSavedObject = [], caseConfigureSavedObject = [], + caseMappingsSavedObject = [], }: { caseSavedObject?: any[]; caseCommentSavedObject?: any[]; caseConfigureSavedObject?: any[]; + caseMappingsSavedObject?: any[]; }) => { const mockSavedObjectsClientContract = ({ bulkGet: jest.fn((objects: SavedObjectsBulkGetObject[]) => { @@ -103,6 +106,14 @@ export const createMockSavedObjectsRepository = ({ ) { throw SavedObjectsErrorHelpers.createGenericNotFoundError('Error thrown for testing'); } + if (findArgs.type === CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT && caseMappingsSavedObject[0]) { + return { + page: 1, + per_page: 5, + total: 1, + saved_objects: caseMappingsSavedObject, + }; + } if (findArgs.type === CASE_CONFIGURE_SAVED_OBJECT) { return { diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts index 8fde66ea82019..7c28ebd24c668 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts @@ -5,7 +5,7 @@ */ import { loggingSystemMock, httpServiceMock } from '../../../../../../../src/core/server/mocks'; -import { CaseService, CaseConfigureService } from '../../../services'; +import { CaseService, CaseConfigureService, ConnectorMappingsService } from '../../../services'; import { authenticationMock } from '../__fixtures__'; import { RouteDeps } from '../types'; @@ -21,15 +21,18 @@ export const createRoute = async ( const caseServicePlugin = new CaseService(log); const caseConfigureServicePlugin = new CaseConfigureService(log); + const connectorMappingsServicePlugin = new ConnectorMappingsService(log); const caseService = await caseServicePlugin.setup({ authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(), }); const caseConfigureService = await caseConfigureServicePlugin.setup(); + const connectorMappingsService = await connectorMappingsServicePlugin.setup(); api({ caseConfigureService, caseService, + connectorMappingsService, router, userActionService: { postUserActions: jest.fn(), diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index 645673fdee756..45ccb4f2c539f 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -6,13 +6,16 @@ import { SavedObject, SavedObjectsFindResponse } from 'kibana/server'; import { - ESCasesConfigureAttributes, + CaseStatuses, CommentAttributes, - ESCaseAttributes, - ConnectorTypes, CommentType, - CaseStatuses, + ConnectorMappings, + ConnectorTypes, + ESCaseAttributes, + ESCasesConfigureAttributes, } from '../../../../common/api'; +import { mappings } from '../cases/configure/mock'; +import { CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT } from '../../../saved_object_types'; export const mockCases: Array> = [ { @@ -386,3 +389,23 @@ export const mockCaseConfigureFind: Array> = [ + { + type: CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, + id: 'mock-mappings-1', + attributes: { + mappings, + }, + references: [], + }, +]; + +export const mockCaseMappingsFind: Array> = [ + { + page: 1, + per_page: 5, + total: mockCaseConfigure.length, + saved_objects: [{ ...mockCaseMappings[0], score: 0 }], + }, +]; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts index dcae1c6083eb6..b2d232dbb7cca 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts @@ -8,7 +8,12 @@ import { RequestHandlerContext, KibanaRequest } from 'src/core/server'; import { loggingSystemMock, elasticsearchServiceMock } from 'src/core/server/mocks'; import { actionsClientMock } from '../../../../../actions/server/mocks'; import { createCaseClient } from '../../../client'; -import { CaseService, CaseConfigureService, AlertService } from '../../../services'; +import { + AlertService, + CaseService, + CaseConfigureService, + ConnectorMappingsService, +} from '../../../services'; import { getActions } from '../__mocks__/request_responses'; import { authenticationMock } from '../__fixtures__'; @@ -20,6 +25,7 @@ export const createRouteContext = async (client: any, badAuth = false) => { const caseServicePlugin = new CaseService(log); const caseConfigureServicePlugin = new CaseConfigureService(log); + const connectorMappingsServicePlugin = new ConnectorMappingsService(log); const caseService = await caseServicePlugin.setup({ authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(), @@ -45,11 +51,13 @@ export const createRouteContext = async (client: any, badAuth = false) => { }, } as unknown) as RequestHandlerContext; + const connectorMappingsService = await connectorMappingsServicePlugin.setup(); const caseClient = createCaseClient({ savedObjectsClient: client, request: {} as KibanaRequest, caseService, caseConfigureService, + connectorMappingsService, userActionService: { postUserActions: jest.fn(), getUserActions: jest.fn(), diff --git a/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts b/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts index 209fa11116c56..b6da21927e342 100644 --- a/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts +++ b/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts @@ -40,27 +40,7 @@ export const getActions = (): FindActionResult[] => [ actionTypeId: '.servicenow', name: 'ServiceNow', config: { - incidentConfiguration: { - mapping: [ - { - source: 'title', - target: 'short_description', - actionType: 'overwrite', - }, - { - source: 'description', - target: 'description', - actionType: 'overwrite', - }, - { - source: 'comments', - target: 'comments', - actionType: 'append', - }, - ], - }, apiUrl: 'https://dev102283.service-now.com', - isCaseOwned: true, }, isPreconfigured: false, referencedByCount: 0, @@ -70,25 +50,6 @@ export const getActions = (): FindActionResult[] => [ actionTypeId: '.jira', name: 'Connector without isCaseOwned', config: { - incidentConfiguration: { - mapping: [ - { - source: 'title', - target: 'short_description', - actionType: 'overwrite', - }, - { - source: 'description', - target: 'description', - actionType: 'overwrite', - }, - { - source: 'comments', - target: 'comments', - actionType: 'append', - }, - ], - }, apiUrl: 'https://elastic.jira.com', }, isPreconfigured: false, diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.test.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.test.ts index cc4f208758369..d75f42f6e486b 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.test.ts @@ -11,11 +11,13 @@ import { createMockSavedObjectsRepository, createRoute, createRouteContext, + mockCaseConfigure, + mockCaseMappings, } from '../../__fixtures__'; -import { mockCaseConfigure } from '../../__fixtures__/mock_saved_objects'; import { initGetCaseConfigure } from './get_configure'; import { CASE_CONFIGURE_URL } from '../../../../../common/constants'; +import { mappings } from './mock'; describe('GET configuration', () => { let routeHandler: RequestHandler; @@ -32,6 +34,7 @@ describe('GET configuration', () => { const context = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, + caseMappingsSavedObject: mockCaseMappings, }) ); @@ -39,6 +42,7 @@ describe('GET configuration', () => { expect(res.status).toEqual(200); expect(res.payload).toEqual({ ...mockCaseConfigure[0].attributes, + mappings, version: mockCaseConfigure[0].version, }); }); @@ -52,6 +56,7 @@ describe('GET configuration', () => { const context = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: [{ ...mockCaseConfigure[0], version: undefined }], + caseMappingsSavedObject: mockCaseMappings, }) ); @@ -71,6 +76,7 @@ describe('GET configuration', () => { email: 'testemail@elastic.co', username: 'elastic', }, + mappings, updated_at: '2020-04-09T09:43:51.778Z', updated_by: { full_name: 'elastic', diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts index 9b38524626290..615d4b0de17e8 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CaseConfigureResponseRt } from '../../../../../common/api'; +import Boom from '@hapi/boom'; +import { CaseConfigureResponseRt, ConnectorMappingsAttributes } from '../../../../../common/api'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; import { CASE_CONFIGURE_URL } from '../../../../../common/constants'; @@ -24,6 +25,23 @@ export function initGetCaseConfigure({ caseConfigureService, router }: RouteDeps const { connector, ...caseConfigureWithoutConnector } = myCaseConfigure.saved_objects[0] ?.attributes ?? { connector: null }; + let mappings: ConnectorMappingsAttributes[] = []; + if (connector != null) { + if (!context.case) { + throw Boom.badRequest('RouteHandlerContext is not registered for cases'); + } + const caseClient = context.case.getCaseClient(); + const actionsClient = await context.actions?.getActionsClient(); + if (actionsClient == null) { + throw Boom.notFound('Action client have not been found'); + } + mappings = await caseClient.getMappings({ + actionsClient, + caseClient, + connectorId: connector.id, + connectorType: connector.type, + }); + } return response.ok({ body: @@ -31,6 +49,7 @@ export function initGetCaseConfigure({ caseConfigureService, router }: RouteDeps ? CaseConfigureResponseRt.encode({ ...caseConfigureWithoutConnector, connector: transformESConnectorToCaseConnector(connector), + mappings, version: myCaseConfigure.saved_objects[0].version ?? '', }) : {}, diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts index 2eab4ac756361..c77d2bd45a795 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts @@ -11,11 +11,13 @@ import { createMockSavedObjectsRepository, createRoute, createRouteContext, + mockCaseConfigure, + mockCaseMappings, } from '../../__fixtures__'; -import { mockCaseConfigure } from '../../__fixtures__/mock_saved_objects'; import { initCaseConfigureGetActionConnector } from './get_connectors'; import { CASE_CONFIGURE_CONNECTORS_URL } from '../../../../../common/constants'; +import { getActions } from '../../__mocks__/request_responses'; describe('GET connectors', () => { let routeHandler: RequestHandler; @@ -32,72 +34,16 @@ describe('GET connectors', () => { const context = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, + caseMappingsSavedObject: mockCaseMappings, }) ); const res = await routeHandler(context, req, kibanaResponseFactory); expect(res.status).toEqual(200); - expect(res.payload).toEqual([ - { - id: '123', - actionTypeId: '.servicenow', - name: 'ServiceNow', - config: { - incidentConfiguration: { - mapping: [ - { - source: 'title', - target: 'short_description', - actionType: 'overwrite', - }, - { - source: 'description', - target: 'description', - actionType: 'overwrite', - }, - { - source: 'comments', - target: 'comments', - actionType: 'append', - }, - ], - }, - apiUrl: 'https://dev102283.service-now.com', - isCaseOwned: true, - }, - isPreconfigured: false, - referencedByCount: 0, - }, - { - id: '456', - actionTypeId: '.jira', - name: 'Connector without isCaseOwned', - config: { - incidentConfiguration: { - mapping: [ - { - source: 'title', - target: 'short_description', - actionType: 'overwrite', - }, - { - source: 'description', - target: 'description', - actionType: 'overwrite', - }, - { - source: 'comments', - target: 'comments', - actionType: 'append', - }, - ], - }, - apiUrl: 'https://elastic.jira.com', - }, - isPreconfigured: false, - referencedByCount: 0, - }, - ]); + + const expected = getActions(); + expected.shift(); + expect(res.payload).toEqual(expected); }); it('it throws an error when actions client is null', async () => { @@ -109,6 +55,7 @@ describe('GET connectors', () => { const context = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, + caseMappingsSavedObject: mockCaseMappings, }) ); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts index 547e67379ad6c..cb88f04a9b835 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts @@ -17,39 +17,16 @@ import { RESILIENT_ACTION_TYPE_ID, } from '../../../../../common/constants'; -/** - * We need to take into account connectors that have been created within cases and - * they do not have the isCaseOwned field. Checking for the existence of - * the mapping attribute ensures that the connector is indeed a case connector. - * Cases connector should always have a mapping. - */ - -interface CaseAction extends FindActionResult { - config?: { - isCaseOwned?: boolean; - incidentConfiguration?: Record; - }; -} - -const isCaseOwned = (action: CaseAction): boolean => { - if ( - [SERVICENOW_ACTION_TYPE_ID, JIRA_ACTION_TYPE_ID, RESILIENT_ACTION_TYPE_ID].includes( - action.actionTypeId - ) - ) { - if (action.config?.isCaseOwned === true || action.config?.incidentConfiguration?.mapping) { - return true; - } - } - - return false; -}; +const isConnectorSupported = (action: FindActionResult): boolean => + [SERVICENOW_ACTION_TYPE_ID, JIRA_ACTION_TYPE_ID, RESILIENT_ACTION_TYPE_ID].includes( + action.actionTypeId + ); /* * Be aware that this api will only return 20 connectors */ -export function initCaseConfigureGetActionConnector({ caseService, router }: RouteDeps) { +export function initCaseConfigureGetActionConnector({ router }: RouteDeps) { router.get( { path: `${CASE_CONFIGURE_CONNECTORS_URL}/_find`, @@ -63,7 +40,7 @@ export function initCaseConfigureGetActionConnector({ caseService, router }: Rou throw Boom.notFound('Action client have not been found'); } - const results = (await actionsClient.getAll()).filter(isCaseOwned); + const results = (await actionsClient.getAll()).filter(isConnectorSupported); return response.ok({ body: results }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_fields.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_fields.ts new file mode 100644 index 0000000000000..c9b8e671b7df8 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_fields.ts @@ -0,0 +1,70 @@ +/* + * 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 Boom from '@hapi/boom'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { RouteDeps } from '../../types'; +import { escapeHatch, wrapError } from '../../utils'; + +import { CASE_CONFIGURE_CONNECTOR_DETAILS_URL } from '../../../../../common/constants'; +import { + ConnectorRequestParamsRt, + GetFieldsRequestQueryRt, + throwErrors, +} from '../../../../../common/api'; + +export function initCaseConfigureGetFields({ router }: RouteDeps) { + router.get( + { + path: CASE_CONFIGURE_CONNECTOR_DETAILS_URL, + validate: { + params: escapeHatch, + query: escapeHatch, + }, + }, + async (context, request, response) => { + try { + if (!context.case) { + throw Boom.badRequest('RouteHandlerContext is not registered for cases'); + } + const query = pipe( + GetFieldsRequestQueryRt.decode(request.query), + fold(throwErrors(Boom.badRequest), identity) + ); + const params = pipe( + ConnectorRequestParamsRt.decode(request.params), + fold(throwErrors(Boom.badRequest), identity) + ); + + const caseClient = context.case.getCaseClient(); + + const connectorType = query.connector_type; + if (connectorType == null) { + throw Boom.illegal('no connectorType value provided'); + } + + const actionsClient = await context.actions?.getActionsClient(); + if (actionsClient == null) { + throw Boom.notFound('Action client have not been found'); + } + + const res = await caseClient.getFields({ + actionsClient, + connectorId: params.connector_id, + connectorType, + }); + + return response.ok({ + body: res.fields, + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/mock.ts b/x-pack/plugins/case/server/routes/api/cases/configure/mock.ts new file mode 100644 index 0000000000000..ed8b208864611 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/configure/mock.ts @@ -0,0 +1,53 @@ +/* + * 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 { + ServiceConnectorCaseParams, + ServiceConnectorCommentParams, + ConnectorMappingsAttributes, +} from '../../../../../common/api/connectors'; +export const updateUser = { + updatedAt: '2020-03-13T08:34:53.450Z', + updatedBy: { fullName: 'Another User', username: 'another' }, +}; +const entity = { + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, +}; +export const comment: ServiceConnectorCommentParams = { + comment: 'first comment', + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + ...entity, +}; +export const defaultPipes = ['informationCreated']; +export const params = { + comments: [comment], + description: 'a description', + impact: '3', + savedObjectId: '1231231231232', + severity: '1', + title: 'a title', + urgency: '2', + ...entity, +} as ServiceConnectorCaseParams; +export const mappings: ConnectorMappingsAttributes[] = [ + { + source: 'title', + target: 'short_description', + action_type: 'overwrite', + }, + { + source: 'description', + target: 'description', + action_type: 'append', + }, + { + source: 'comments', + target: 'comments', + action_type: 'append', + }, +]; diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.test.ts b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.test.ts index 261cd3e6b0884..fd213a514f339 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.test.ts @@ -11,6 +11,7 @@ import { createMockSavedObjectsRepository, createRoute, createRouteContext, + mockCaseMappings, } from '../../__fixtures__'; import { mockCaseConfigure } from '../../__fixtures__/mock_saved_objects'; @@ -42,6 +43,7 @@ describe('PATCH configuration', () => { const context = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, + caseMappingsSavedObject: mockCaseMappings, }) ); @@ -75,6 +77,7 @@ describe('PATCH configuration', () => { const context = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, + caseMappingsSavedObject: mockCaseMappings, }) ); @@ -113,6 +116,7 @@ describe('PATCH configuration', () => { const context = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, + caseMappingsSavedObject: mockCaseMappings, }) ); @@ -166,6 +170,7 @@ describe('PATCH configuration', () => { const context = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, + caseMappingsSavedObject: mockCaseMappings, }) ); @@ -193,6 +198,7 @@ describe('PATCH configuration', () => { const context = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, + caseMappingsSavedObject: mockCaseMappings, }) ); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts index b3305a2c0b8e4..08db2b3103422 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts @@ -13,6 +13,7 @@ import { CasesConfigurePatchRt, CaseConfigureResponseRt, throwErrors, + ConnectorMappingsAttributes, } from '../../../../../common/api'; import { RouteDeps } from '../../types'; import { wrapError, escapeHatch } from '../../utils'; @@ -56,6 +57,24 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout const { username, full_name, email } = await caseService.getUser({ request, response }); const updateDate = new Date().toISOString(); + + let mappings: ConnectorMappingsAttributes[] = []; + if (connector != null) { + if (!context.case) { + throw Boom.badRequest('RouteHandlerContext is not registered for cases'); + } + const caseClient = context.case.getCaseClient(); + const actionsClient = await context.actions?.getActionsClient(); + if (actionsClient == null) { + throw Boom.notFound('Action client have not been found'); + } + mappings = await caseClient.getMappings({ + actionsClient, + caseClient, + connectorId: connector.id, + connectorType: connector.type, + }); + } const patch = await caseConfigureService.patch({ client, caseConfigureId: myCaseConfigure.saved_objects[0].id, @@ -68,7 +87,6 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout updated_by: { email, full_name, username }, }, }); - return response.ok({ body: CaseConfigureResponseRt.encode({ ...myCaseConfigure.saved_objects[0].attributes, @@ -76,6 +94,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout connector: transformESConnectorToCaseConnector( patch.attributes.connector ?? myCaseConfigure.saved_objects[0].attributes.connector ), + mappings, version: patch.version ?? '', }), }); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.test.ts b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.test.ts index 7ef3bdb4a700a..5a5836f595eee 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.test.ts @@ -11,9 +11,10 @@ import { createMockSavedObjectsRepository, createRoute, createRouteContext, + mockCaseConfigure, + mockCaseMappings, } from '../../__fixtures__'; -import { mockCaseConfigure } from '../../__fixtures__/mock_saved_objects'; import { initPostCaseConfigure } from './post_configure'; import { newConfiguration } from '../../__mocks__/request_responses'; import { CASE_CONFIGURE_URL } from '../../../../../common/constants'; @@ -40,6 +41,7 @@ describe('POST configuration', () => { const context = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, + caseMappingsSavedObject: mockCaseMappings, }) ); @@ -75,6 +77,7 @@ describe('POST configuration', () => { const context = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, + caseMappingsSavedObject: mockCaseMappings, }) ); @@ -115,6 +118,7 @@ describe('POST configuration', () => { const context = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, + caseMappingsSavedObject: mockCaseMappings, }) ); @@ -140,6 +144,7 @@ describe('POST configuration', () => { const context = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, + caseMappingsSavedObject: mockCaseMappings, }) ); @@ -165,6 +170,7 @@ describe('POST configuration', () => { const context = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, + caseMappingsSavedObject: mockCaseMappings, }) ); @@ -190,6 +196,7 @@ describe('POST configuration', () => { const context = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, + caseMappingsSavedObject: mockCaseMappings, }) ); @@ -215,6 +222,7 @@ describe('POST configuration', () => { const context = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, + caseMappingsSavedObject: mockCaseMappings, }) ); @@ -232,6 +240,7 @@ describe('POST configuration', () => { const savedObjectRepository = createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, + caseMappingsSavedObject: mockCaseMappings, }); const context = await createRouteContext(savedObjectRepository); @@ -251,6 +260,7 @@ describe('POST configuration', () => { const savedObjectRepository = createMockSavedObjectsRepository({ caseConfigureSavedObject: [], + caseMappingsSavedObject: mockCaseMappings, }); const context = await createRouteContext(savedObjectRepository); @@ -273,6 +283,7 @@ describe('POST configuration', () => { mockCaseConfigure[0], { ...mockCaseConfigure[0], id: 'mock-configuration-2' }, ], + caseMappingsSavedObject: mockCaseMappings, }); const context = await createRouteContext(savedObjectRepository); @@ -337,6 +348,7 @@ describe('POST configuration', () => { const context = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, + caseMappingsSavedObject: mockCaseMappings, }) ); @@ -363,6 +375,7 @@ describe('POST configuration', () => { const context = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, + caseMappingsSavedObject: mockCaseMappings, }) ); @@ -388,6 +401,7 @@ describe('POST configuration', () => { const context = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, + caseMappingsSavedObject: mockCaseMappings, }) ); @@ -409,6 +423,7 @@ describe('POST configuration', () => { const context = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, + caseMappingsSavedObject: mockCaseMappings, }) ); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts index 97856c84d60fc..8ae4e1211f5f1 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts @@ -32,6 +32,14 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route }, async (context, request, response) => { try { + if (!context.case) { + throw Boom.badRequest('RouteHandlerContext is not registered for cases'); + } + const caseClient = context.case.getCaseClient(); + const actionsClient = await context.actions?.getActionsClient(); + if (actionsClient == null) { + throw Boom.notFound('Action client have not been found'); + } const client = context.core.savedObjects.client; const query = pipe( CasesConfigureRequestRt.decode(request.body), @@ -39,7 +47,6 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route ); const myCaseConfigure = await caseConfigureService.find({ client }); - if (myCaseConfigure.saved_objects.length > 0) { await Promise.all( myCaseConfigure.saved_objects.map((cc) => @@ -51,6 +58,12 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route const { email, full_name, username } = await caseService.getUser({ request, response }); const creationDate = new Date().toISOString(); + const mappings = await caseClient.getMappings({ + actionsClient, + caseClient, + connectorId: query.connector.id, + connectorType: query.connector.type, + }); const post = await caseConfigureService.post({ client, attributes: { @@ -68,6 +81,7 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route ...post.attributes, // Reserve for future implementations connector: transformESConnectorToCaseConnector(post.attributes.connector), + mappings, version: post.version ?? '', }), }); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/post_push_to_service.ts b/x-pack/plugins/case/server/routes/api/cases/configure/post_push_to_service.ts new file mode 100644 index 0000000000000..9c4c06c5f4e18 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/configure/post_push_to_service.ts @@ -0,0 +1,80 @@ +/* + * 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 { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import Boom from '@hapi/boom'; +import { RouteDeps } from '../../types'; +import { escapeHatch, wrapError } from '../../utils'; + +import { CASE_CONFIGURE_PUSH_URL } from '../../../../../common/constants'; +import { + ConnectorRequestParamsRt, + PostPushRequestRt, + throwErrors, +} from '../../../../../common/api'; +import { mapIncident } from './utils'; + +export function initPostPushToService({ router, connectorMappingsService }: RouteDeps) { + router.post( + { + path: CASE_CONFIGURE_PUSH_URL, + validate: { + params: escapeHatch, + body: escapeHatch, + }, + }, + async (context, request, response) => { + try { + if (!context.case) { + throw Boom.badRequest('RouteHandlerContext is not registered for cases'); + } + const caseClient = context.case.getCaseClient(); + const actionsClient = await context.actions?.getActionsClient(); + if (actionsClient == null) { + throw Boom.notFound('Action client have not been found'); + } + const params = pipe( + ConnectorRequestParamsRt.decode(request.params), + fold(throwErrors(Boom.badRequest), identity) + ); + const body = pipe( + PostPushRequestRt.decode(request.body), + fold(throwErrors(Boom.badRequest), identity) + ); + + const myConnectorMappings = await caseClient.getMappings({ + actionsClient, + caseClient, + connectorId: params.connector_id, + connectorType: body.connector_type, + }); + + const res = await mapIncident( + actionsClient, + params.connector_id, + body.connector_type, + myConnectorMappings, + body.params + ); + const pushRes = await actionsClient.execute({ + actionId: params.connector_id, + params: { + subAction: 'pushToService', + subActionParams: res, + }, + }); + + return response.ok({ + body: pushRes, + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/utils.test.ts b/x-pack/plugins/case/server/routes/api/cases/configure/utils.test.ts new file mode 100644 index 0000000000000..d2ecdf61c882d --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/configure/utils.test.ts @@ -0,0 +1,385 @@ +/* + * 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 { + prepareFieldsForTransformation, + transformFields, + transformComments, + transformers, +} from './utils'; + +import { comment as commentObj, defaultPipes, mappings, params, updateUser } from './mock'; +import { + ServiceConnectorCaseParams, + ExternalServiceParams, + Incident, +} from '../../../../../common/api/connectors'; +const formatComment = { commentId: commentObj.commentId, comment: commentObj.comment }; +describe('api/cases/configure/utils', () => { + describe('prepareFieldsForTransformation', () => { + test('prepare fields with defaults', () => { + const res = prepareFieldsForTransformation({ + defaultPipes, + params, + mappings, + }); + expect(res).toEqual([ + { + actionType: 'overwrite', + key: 'short_description', + pipes: ['informationCreated'], + value: 'a title', + }, + { + actionType: 'append', + key: 'description', + pipes: ['informationCreated', 'append'], + value: 'a description', + }, + ]); + }); + + test('prepare fields with default pipes', () => { + const res = prepareFieldsForTransformation({ + defaultPipes: ['myTestPipe'], + mappings, + params, + }); + expect(res).toEqual([ + { + actionType: 'overwrite', + key: 'short_description', + pipes: ['myTestPipe'], + value: 'a title', + }, + { + actionType: 'append', + key: 'description', + pipes: ['myTestPipe', 'append'], + value: 'a description', + }, + ]); + }); + }); + describe('transformFields', () => { + test('transform fields for creation correctly', () => { + const fields = prepareFieldsForTransformation({ + defaultPipes, + mappings, + params, + }); + + const res = transformFields({ + params, + fields, + }); + + expect(res).toEqual({ + short_description: 'a title (created at 2020-03-13T08:34:53.450Z by Elastic User)', + description: 'a description (created at 2020-03-13T08:34:53.450Z by Elastic User)', + }); + }); + + test('transform fields for update correctly', () => { + const fields = prepareFieldsForTransformation({ + params, + mappings, + defaultPipes: ['informationUpdated'], + }); + + const res = transformFields({ + params: { + ...params, + updatedAt: '2020-03-15T08:34:53.450Z', + updatedBy: { + username: 'anotherUser', + fullName: 'Another User', + }, + }, + fields, + currentIncident: { + short_description: 'first title (created at 2020-03-13T08:34:53.450Z by Elastic User)', + description: 'first description (created at 2020-03-13T08:34:53.450Z by Elastic User)', + }, + }); + expect(res).toEqual({ + short_description: 'a title (updated at 2020-03-15T08:34:53.450Z by Another User)', + description: + 'first description (created at 2020-03-13T08:34:53.450Z by Elastic User) \r\na description (updated at 2020-03-15T08:34:53.450Z by Another User)', + }); + }); + + test('add newline character to description', () => { + const fields = prepareFieldsForTransformation({ + params, + mappings, + defaultPipes: ['informationUpdated'], + }); + + const res = transformFields({ + params, + fields, + currentIncident: { + short_description: 'first title', + description: 'first description', + }, + }); + expect(res.description?.includes('\r\n')).toBe(true); + }); + + test('append username if fullname is undefined when create', () => { + const fields = prepareFieldsForTransformation({ + defaultPipes, + mappings, + params, + }); + + const res = transformFields({ + params: { + ...params, + createdBy: { fullName: '', username: 'elastic' }, + }, + fields, + }); + + expect(res).toEqual({ + short_description: 'a title (created at 2020-03-13T08:34:53.450Z by elastic)', + description: 'a description (created at 2020-03-13T08:34:53.450Z by elastic)', + }); + }); + + test('append username if fullname is undefined when update', () => { + const fields = prepareFieldsForTransformation({ + defaultPipes: ['informationUpdated'], + mappings, + params, + }); + + const res = transformFields({ + params: { + ...params, + updatedAt: '2020-03-15T08:34:53.450Z', + updatedBy: { username: 'anotherUser', fullName: '' }, + }, + fields, + }); + + expect(res).toEqual({ + short_description: 'a title (updated at 2020-03-15T08:34:53.450Z by anotherUser)', + description: 'a description (updated at 2020-03-15T08:34:53.450Z by anotherUser)', + }); + }); + }); + describe('transformComments', () => { + test('transform creation comments', () => { + const comments = [commentObj]; + const res = transformComments(comments, ['informationCreated']); + expect(res).toEqual([ + { + ...formatComment, + comment: `${formatComment.comment} (created at ${comments[0].createdAt} by ${comments[0].createdBy.fullName})`, + }, + ]); + }); + + test('transform update comments', () => { + const comments = [ + { + ...commentObj, + ...updateUser, + }, + ]; + const res = transformComments(comments, ['informationUpdated']); + expect(res).toEqual([ + { + ...formatComment, + comment: `${formatComment.comment} (updated at ${updateUser.updatedAt} by ${updateUser.updatedBy.fullName})`, + }, + ]); + }); + + test('transform added comments', () => { + const comments = [commentObj]; + const res = transformComments(comments, ['informationAdded']); + expect(res).toEqual([ + { + ...formatComment, + comment: `${formatComment.comment} (added at ${comments[0].createdAt} by ${comments[0].createdBy.fullName})`, + }, + ]); + }); + + test('transform comments without fullname', () => { + const comments = [{ ...commentObj, createdBy: { username: commentObj.createdBy.username } }]; + // @ts-ignore testing no fullName + const res = transformComments(comments, ['informationAdded']); + expect(res).toEqual([ + { + ...formatComment, + comment: `${formatComment.comment} (added at ${comments[0].createdAt} by ${comments[0].createdBy.username})`, + }, + ]); + }); + + test('adds update user correctly', () => { + const comments = [ + { + ...commentObj, + updatedAt: '2020-04-13T08:34:53.450Z', + updatedBy: { fullName: 'Elastic2', username: 'elastic' }, + }, + ]; + const res = transformComments(comments, ['informationAdded']); + expect(res).toEqual([ + { + ...formatComment, + comment: `${formatComment.comment} (added at ${comments[0].updatedAt} by ${comments[0].updatedBy.fullName})`, + }, + ]); + }); + + test('adds update user with empty fullname correctly', () => { + const comments = [ + { + ...commentObj, + updatedAt: '2020-04-13T08:34:53.450Z', + updatedBy: { fullName: '', username: 'elastic2' }, + }, + ]; + const res = transformComments(comments, ['informationAdded']); + expect(res).toEqual([ + { + ...formatComment, + comment: `${formatComment.comment} (added at ${comments[0].updatedAt} by ${comments[0].updatedBy.username})`, + }, + ]); + }); + }); + describe('transformers', () => { + const { informationCreated, informationUpdated, informationAdded, append } = transformers; + describe('informationCreated', () => { + test('transforms correctly', () => { + const res = informationCreated({ + value: 'a value', + date: '2020-04-15T08:19:27.400Z', + user: 'elastic', + }); + expect(res).toEqual({ value: 'a value (created at 2020-04-15T08:19:27.400Z by elastic)' }); + }); + + test('transforms correctly without optional fields', () => { + const res = informationCreated({ + value: 'a value', + }); + expect(res).toEqual({ value: 'a value (created at by )' }); + }); + + test('returns correctly rest fields', () => { + const res = informationCreated({ + value: 'a value', + date: '2020-04-15T08:19:27.400Z', + user: 'elastic', + previousValue: 'previous value', + }); + expect(res).toEqual({ + value: 'a value (created at 2020-04-15T08:19:27.400Z by elastic)', + previousValue: 'previous value', + }); + }); + }); + + describe('informationUpdated', () => { + test('transforms correctly', () => { + const res = informationUpdated({ + value: 'a value', + date: '2020-04-15T08:19:27.400Z', + user: 'elastic', + }); + expect(res).toEqual({ value: 'a value (updated at 2020-04-15T08:19:27.400Z by elastic)' }); + }); + + test('transforms correctly without optional fields', () => { + const res = informationUpdated({ + value: 'a value', + }); + expect(res).toEqual({ value: 'a value (updated at by )' }); + }); + + test('returns correctly rest fields', () => { + const res = informationUpdated({ + value: 'a value', + date: '2020-04-15T08:19:27.400Z', + user: 'elastic', + previousValue: 'previous value', + }); + expect(res).toEqual({ + value: 'a value (updated at 2020-04-15T08:19:27.400Z by elastic)', + previousValue: 'previous value', + }); + }); + }); + + describe('informationAdded', () => { + test('transforms correctly', () => { + const res = informationAdded({ + value: 'a value', + date: '2020-04-15T08:19:27.400Z', + user: 'elastic', + }); + expect(res).toEqual({ value: 'a value (added at 2020-04-15T08:19:27.400Z by elastic)' }); + }); + + test('transforms correctly without optional fields', () => { + const res = informationAdded({ + value: 'a value', + }); + expect(res).toEqual({ value: 'a value (added at by )' }); + }); + + test('returns correctly rest fields', () => { + const res = informationAdded({ + value: 'a value', + date: '2020-04-15T08:19:27.400Z', + user: 'elastic', + previousValue: 'previous value', + }); + expect(res).toEqual({ + value: 'a value (added at 2020-04-15T08:19:27.400Z by elastic)', + previousValue: 'previous value', + }); + }); + }); + + describe('append', () => { + test('transforms correctly', () => { + const res = append({ + value: 'a value', + previousValue: 'previous value', + }); + expect(res).toEqual({ value: 'previous value \r\na value' }); + }); + + test('transforms correctly without optional fields', () => { + const res = append({ + value: 'a value', + }); + expect(res).toEqual({ value: 'a value' }); + }); + + test('returns correctly rest fields', () => { + const res = append({ + value: 'a value', + user: 'elastic', + previousValue: 'previous value', + }); + expect(res).toEqual({ + value: 'previous value \r\na value', + user: 'elastic', + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/utils.ts b/x-pack/plugins/case/server/routes/api/cases/configure/utils.ts new file mode 100644 index 0000000000000..b8a37661fe9f7 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/configure/utils.ts @@ -0,0 +1,237 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { flow } from 'lodash'; +import { + ServiceConnectorCaseParams, + ServiceConnectorCommentParams, + ConnectorMappingsAttributes, + ConnectorTypes, + EntityInformation, + ExternalServiceParams, + Incident, + JiraPushToServiceApiParams, + MapIncident, + PipedField, + PrepareFieldsForTransformArgs, + PushToServiceApiParams, + ResilientPushToServiceApiParams, + ServiceNowPushToServiceApiParams, + SimpleComment, + Transformer, + TransformerArgs, + TransformFieldsArgs, +} from '../../../../../common/api'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ActionsClient } from '../../../../../../actions/server/actions_client'; + +export const mapIncident = async ( + actionsClient: ActionsClient, + connectorId: string, + connectorType: string, + mappings: ConnectorMappingsAttributes[], + params: ServiceConnectorCaseParams +): Promise => { + const { comments: caseComments, externalId } = params; + const defaultPipes = externalId ? ['informationUpdated'] : ['informationCreated']; + let currentIncident: ExternalServiceParams | undefined; + const service = serviceFormatter(connectorType, params); + if (service == null) { + throw new Error(`Invalid service`); + } + const thirdPartyName = service.thirdPartyName; + let incident: Partial = service.incident; + if (externalId) { + try { + currentIncident = ((await actionsClient.execute({ + actionId: connectorId, + params: { + subAction: 'getIncident', + subActionParams: { externalId }, + }, + })) as unknown) as ExternalServiceParams | undefined; + } catch (ex) { + throw new Error( + `Retrieving Incident by id ${externalId} from ${thirdPartyName} failed with exception: ${ex}` + ); + } + } + + const fields = prepareFieldsForTransformation({ + defaultPipes, + mappings, + params, + }); + + const transformedFields = transformFields< + ServiceConnectorCaseParams, + ExternalServiceParams, + Incident + >({ + params, + fields, + currentIncident, + }); + incident = { ...incident, ...transformedFields, externalId }; + let comments: SimpleComment[] = []; + if (caseComments && Array.isArray(caseComments) && caseComments.length > 0) { + const commentsMapping = mappings.find((m) => m.source === 'comments'); + if (commentsMapping?.action_type !== 'nothing') { + comments = transformComments(caseComments, ['informationAdded']); + } + } + return { incident, comments }; +}; + +export const serviceFormatter = ( + connectorType: string, + params: unknown +): { thirdPartyName: string; incident: Partial } | null => { + switch (connectorType) { + case ConnectorTypes.jira: + const { + priority, + labels, + issueType, + parent, + } = params as JiraPushToServiceApiParams['incident']; + return { + incident: { priority, labels, issueType, parent }, + thirdPartyName: 'Jira', + }; + case ConnectorTypes.resilient: + const { incidentTypes, severityCode } = params as ResilientPushToServiceApiParams['incident']; + return { + incident: { incidentTypes, severityCode }, + thirdPartyName: 'Resilient', + }; + case ConnectorTypes.servicenow: + const { severity, urgency, impact } = params as ServiceNowPushToServiceApiParams['incident']; + return { + incident: { severity, urgency, impact }, + thirdPartyName: 'ServiceNow', + }; + default: + return null; + } +}; + +export const getEntity = (entity: EntityInformation): string => + (entity.updatedBy != null + ? entity.updatedBy.fullName + ? entity.updatedBy.fullName + : entity.updatedBy.username + : entity.createdBy != null + ? entity.createdBy.fullName + ? entity.createdBy.fullName + : entity.createdBy.username + : '') ?? ''; + +export const FIELD_INFORMATION = ( + mode: string, + date: string | undefined, + user: string | undefined +) => { + switch (mode) { + case 'create': + return i18n.translate('xpack.case.connectors.case.externalIncidentCreated', { + values: { date, user }, + defaultMessage: '(created at {date} by {user})', + }); + case 'update': + return i18n.translate('xpack.case.connectors.case.externalIncidentUpdated', { + values: { date, user }, + defaultMessage: '(updated at {date} by {user})', + }); + case 'add': + return i18n.translate('xpack.case.connectors.case.externalIncidentAdded', { + values: { date, user }, + defaultMessage: '(added at {date} by {user})', + }); + default: + return i18n.translate('xpack.case.connectors.case.externalIncidentDefault', { + values: { date, user }, + defaultMessage: '(created at {date} by {user})', + }); + } +}; +export const transformers: Record = { + informationCreated: ({ value, date, user, ...rest }: TransformerArgs): TransformerArgs => ({ + value: `${value} ${FIELD_INFORMATION('create', date, user)}`, + ...rest, + }), + informationUpdated: ({ value, date, user, ...rest }: TransformerArgs): TransformerArgs => ({ + value: `${value} ${FIELD_INFORMATION('update', date, user)}`, + ...rest, + }), + informationAdded: ({ value, date, user, ...rest }: TransformerArgs): TransformerArgs => ({ + value: `${value} ${FIELD_INFORMATION('add', date, user)}`, + ...rest, + }), + append: ({ value, previousValue, ...rest }: TransformerArgs): TransformerArgs => ({ + value: previousValue ? `${previousValue} \r\n${value}` : `${value}`, + ...rest, + }), +}; +export const prepareFieldsForTransformation = ({ + defaultPipes, + mappings, + params, +}: PrepareFieldsForTransformArgs): PipedField[] => + mappings.reduce( + (acc: PipedField[], mapping) => + mapping != null && + mapping.target !== 'not_mapped' && + mapping.action_type !== 'nothing' && + mapping.source !== 'comments' + ? [ + ...acc, + { + key: mapping.target, + value: params[mapping.source] ?? '', + actionType: mapping.action_type, + pipes: mapping.action_type === 'append' ? [...defaultPipes, 'append'] : defaultPipes, + }, + ] + : acc, + [] + ); + +export const transformFields = < + P extends EntityInformation, + S extends Record, + R extends {} +>({ + params, + fields, + currentIncident, +}: TransformFieldsArgs): R => { + return fields.reduce((prev, cur) => { + const transform = flow(...cur.pipes.map((p) => transformers[p])); + return { + ...prev, + [cur.key]: transform({ + value: cur.value, + date: params.updatedAt ?? params.createdAt, + user: getEntity(params), + previousValue: currentIncident ? currentIncident[cur.key] : '', + }).value, + }; + }, {} as R); +}; + +export const transformComments = ( + comments: ServiceConnectorCommentParams[], + pipes: string[] +): SimpleComment[] => + comments.map((c) => ({ + comment: flow(...pipes.map((p) => transformers[p]))({ + value: c.comment, + date: c.updatedAt ?? c.createdAt, + user: getEntity(c), + }).value, + commentId: c.commentId, + })); diff --git a/x-pack/plugins/case/server/routes/api/index.ts b/x-pack/plugins/case/server/routes/api/index.ts index ced88fabf3160..587e43b218f44 100644 --- a/x-pack/plugins/case/server/routes/api/index.ts +++ b/x-pack/plugins/case/server/routes/api/index.ts @@ -25,8 +25,10 @@ import { initPostCommentApi } from './cases/comments/post_comment'; import { initCaseConfigureGetActionConnector } from './cases/configure/get_connectors'; import { initGetCaseConfigure } from './cases/configure/get_configure'; +import { initCaseConfigureGetFields } from './cases/configure/get_fields'; import { initPatchCaseConfigure } from './cases/configure/patch_configure'; import { initPostCaseConfigure } from './cases/configure/post_configure'; +import { initPostPushToService } from './cases/configure/post_push_to_service'; import { RouteDeps } from './types'; @@ -52,6 +54,8 @@ export function initCaseApi(deps: RouteDeps) { initGetCaseConfigure(deps); initPatchCaseConfigure(deps); initPostCaseConfigure(deps); + initCaseConfigureGetFields(deps); + initPostPushToService(deps); // Reporters initGetReportersApi(deps); // Status diff --git a/x-pack/plugins/case/server/routes/api/types.ts b/x-pack/plugins/case/server/routes/api/types.ts index e532a7b618b5c..0b93d844fe9ab 100644 --- a/x-pack/plugins/case/server/routes/api/types.ts +++ b/x-pack/plugins/case/server/routes/api/types.ts @@ -9,13 +9,15 @@ import { CaseConfigureServiceSetup, CaseServiceSetup, CaseUserActionServiceSetup, + ConnectorMappingsServiceSetup, } from '../../services'; export interface RouteDeps { caseConfigureService: CaseConfigureServiceSetup; caseService: CaseServiceSetup; - userActionService: CaseUserActionServiceSetup; + connectorMappingsService: ConnectorMappingsServiceSetup; router: IRouter; + userActionService: CaseUserActionServiceSetup; } export enum SortFieldCase { diff --git a/x-pack/plugins/case/server/saved_object_types/connector_mappings.ts b/x-pack/plugins/case/server/saved_object_types/connector_mappings.ts new file mode 100644 index 0000000000000..b928d8b5c577c --- /dev/null +++ b/x-pack/plugins/case/server/saved_object_types/connector_mappings.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 { SavedObjectsType } from 'src/core/server'; + +export const CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT = 'cases-connector-mappings'; + +export const caseConnectorMappingsSavedObjectType: SavedObjectsType = { + name: CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, + hidden: false, + namespaceType: 'single', + mappings: { + properties: { + mappings: { + properties: { + source: { + type: 'keyword', + }, + target: { + type: 'keyword', + }, + action_type: { + type: 'keyword', + }, + }, + }, + }, + }, +}; diff --git a/x-pack/plugins/case/server/saved_object_types/index.ts b/x-pack/plugins/case/server/saved_object_types/index.ts index 0e4b9fa3e2eee..36d38cad797b6 100644 --- a/x-pack/plugins/case/server/saved_object_types/index.ts +++ b/x-pack/plugins/case/server/saved_object_types/index.ts @@ -8,3 +8,7 @@ export { caseSavedObjectType, CASE_SAVED_OBJECT } from './cases'; export { caseConfigureSavedObjectType, CASE_CONFIGURE_SAVED_OBJECT } from './configure'; export { caseCommentSavedObjectType, CASE_COMMENT_SAVED_OBJECT } from './comments'; export { caseUserActionSavedObjectType, CASE_USER_ACTION_SAVED_OBJECT } from './user_actions'; +export { + caseConnectorMappingsSavedObjectType, + CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, +} from './connector_mappings'; diff --git a/x-pack/plugins/case/server/services/connector_mappings/index.ts b/x-pack/plugins/case/server/services/connector_mappings/index.ts new file mode 100644 index 0000000000000..32afbebfa6215 --- /dev/null +++ b/x-pack/plugins/case/server/services/connector_mappings/index.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 { + Logger, + SavedObject, + SavedObjectReference, + SavedObjectsClientContract, + SavedObjectsFindResponse, +} from 'kibana/server'; + +import { ConnectorMappings, SavedObjectFindOptions } from '../../../common/api'; +import { CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT } from '../../saved_object_types'; + +interface ClientArgs { + client: SavedObjectsClientContract; +} +interface FindConnectorMappingsArgs extends ClientArgs { + options?: SavedObjectFindOptions; +} + +interface PostConnectorMappingsArgs extends ClientArgs { + attributes: ConnectorMappings; + references: SavedObjectReference[]; +} + +export interface ConnectorMappingsServiceSetup { + find(args: FindConnectorMappingsArgs): Promise>; + post(args: PostConnectorMappingsArgs): Promise>; +} + +export class ConnectorMappingsService { + constructor(private readonly log: Logger) {} + public setup = async (): Promise => ({ + find: async ({ client, options }: FindConnectorMappingsArgs) => { + try { + this.log.debug(`Attempting to find all connector mappings`); + return await client.find({ ...options, type: CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT }); + } catch (error) { + this.log.debug(`Attempting to find all connector mappings`); + throw error; + } + }, + post: async ({ client, attributes, references }: PostConnectorMappingsArgs) => { + try { + this.log.debug(`Attempting to POST a new connector mappings`); + return await client.create(CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, attributes, { + references, + }); + } catch (error) { + this.log.debug(`Error on POST a new connector mappings: ${error}`); + throw error; + } + }, + }); +} diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index 95bcf87361e07..e75b597fa7af2 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -31,6 +31,7 @@ import { readTags } from './tags/read_tags'; export { CaseConfigureService, CaseConfigureServiceSetup } from './configure'; export { CaseUserActionService, CaseUserActionServiceSetup } from './user_actions'; +export { ConnectorMappingsService, ConnectorMappingsServiceSetup } from './connector_mappings'; export { AlertService, AlertServiceContract } from './alerts'; export interface ClientArgs { diff --git a/x-pack/plugins/case/server/services/mocks.ts b/x-pack/plugins/case/server/services/mocks.ts index 01a8cb09ac2d5..65f2c845bb400 100644 --- a/x-pack/plugins/case/server/services/mocks.ts +++ b/x-pack/plugins/case/server/services/mocks.ts @@ -5,14 +5,16 @@ */ import { + AlertServiceContract, CaseConfigureServiceSetup, CaseServiceSetup, CaseUserActionServiceSetup, - AlertServiceContract, + ConnectorMappingsServiceSetup, } from '.'; export type CaseServiceMock = jest.Mocked; export type CaseConfigureServiceMock = jest.Mocked; +export type ConnectorMappingsServiceMock = jest.Mocked; export type CaseUserActionServiceMock = jest.Mocked; export type AlertServiceMock = jest.Mocked; @@ -43,6 +45,11 @@ export const createConfigureServiceMock = (): CaseConfigureServiceMock => ({ post: jest.fn(), }); +export const connectorMappingsServiceMock = (): ConnectorMappingsServiceMock => ({ + find: jest.fn(), + post: jest.fn(), +}); + export const createUserActionServiceMock = (): CaseUserActionServiceMock => ({ getUserActions: jest.fn(), postUserActions: jest.fn(), diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts index d9f63f233e1c2..24406cefce7a2 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts @@ -4,9 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +import { IExternalUrl } from 'src/core/public'; import { UrlDrilldown, ActionContext, Config } from './url_drilldown'; import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public/lib/embeddables'; import { DatatableColumnType } from '../../../../../../src/plugins/expressions/common'; +import { of } from '../../../../../../src/plugins/kibana_utils'; import { createPoint, rowClickData, TestEmbeddable } from './test/data'; import { VALUE_CLICK_TRIGGER, @@ -59,13 +61,27 @@ const mockEmbeddable = ({ const mockNavigateToUrl = jest.fn(() => Promise.resolve()); -describe('UrlDrilldown', () => { - const urlDrilldown = new UrlDrilldown({ +class TextExternalUrl implements IExternalUrl { + constructor(private readonly isCorrect: boolean = true) {} + + public validateUrl(url: string): URL | null { + return this.isCorrect ? new URL(url) : null; + } +} + +const createDrilldown = (isExternalUrlValid: boolean = true) => { + const drilldown = new UrlDrilldown({ + externalUrl: new TextExternalUrl(isExternalUrlValid), getGlobalScope: () => ({ kibanaUrl: 'http://localhost:5601/' }), getSyntaxHelpDocsLink: () => 'http://localhost:5601/docs', getVariablesHelpDocsLink: () => 'http://localhost:5601/docs', navigateToUrl: mockNavigateToUrl, }); + return drilldown; +}; + +describe('UrlDrilldown', () => { + const urlDrilldown = createDrilldown(); test('license', () => { expect(urlDrilldown.minimalLicense).toBe('gold'); @@ -125,6 +141,30 @@ describe('UrlDrilldown', () => { await expect(urlDrilldown.isCompatible(config, context)).resolves.toBe(false); }); + + test('not compatible if external URL is denied', async () => { + const drilldown1 = createDrilldown(true); + const drilldown2 = createDrilldown(false); + const config: Config = { + url: { + template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`, + }, + openInNewTab: false, + }; + + const context: ActionContext = { + data: { + data: mockDataPoints, + }, + embeddable: mockEmbeddable, + }; + + const result1 = await drilldown1.isCompatible(config, context); + const result2 = await drilldown2.isCompatible(config, context); + + expect(result1).toBe(true); + expect(result2).toBe(false); + }); }); describe('getHref & execute', () => { @@ -173,6 +213,42 @@ describe('UrlDrilldown', () => { await expect(urlDrilldown.execute(config, context)).rejects.toThrowError(); expect(mockNavigateToUrl).not.toBeCalled(); }); + + test('should throw on denied external URL', async () => { + const drilldown1 = createDrilldown(true); + const drilldown2 = createDrilldown(false); + const config: Config = { + url: { + template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`, + }, + openInNewTab: false, + }; + + const context: ActionContext = { + data: { + data: mockDataPoints, + }, + embeddable: mockEmbeddable, + }; + + const url = await drilldown1.getHref(config, context); + await drilldown1.execute(config, context); + + expect(url).toMatchInlineSnapshot(`"https://elasti.co/?test&(language:kuery,query:test)"`); + expect(mockNavigateToUrl).toBeCalledWith(url); + + const [, error1] = await of(drilldown2.getHref(config, context)); + const [, error2] = await of(drilldown2.execute(config, context)); + + expect(error1).toBeInstanceOf(Error); + expect(error1.message).toMatchInlineSnapshot( + `"External URL [https://elasti.co/?test&(language:kuery,query:test)] was denied by ExternalUrl service. You can configure external URL policies using \\"externalUrl.policy\\" setting in kibana.yml."` + ); + expect(error2).toBeInstanceOf(Error); + expect(error2.message).toMatchInlineSnapshot( + `"External URL [https://elasti.co/?test&(language:kuery,query:test)] was denied by ExternalUrl service. You can configure external URL policies using \\"externalUrl.policy\\" setting in kibana.yml."` + ); + }); }); describe('variables', () => { diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx index 3a989c1b0b4cd..8dfb2c54c5ab0 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { getFlattenedObject } from '@kbn/std'; +import { IExternalUrl } from 'src/core/public'; import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public'; import { ChartActionContext, @@ -31,6 +32,7 @@ import { getPanelVariables, getEventScope, getEventVariableList } from './url_dr import { txtUrlDrilldownDisplayName } from './i18n'; interface UrlDrilldownDeps { + externalUrl: IExternalUrl; getGlobalScope: () => UrlDrilldownGlobalScope; navigateToUrl: (url: string) => Promise; getSyntaxHelpDocsLink: () => string; @@ -55,7 +57,7 @@ const URL_DRILLDOWN = 'URL_DRILLDOWN'; export class UrlDrilldown implements Drilldown { public readonly id = URL_DRILLDOWN; - constructor(private deps: UrlDrilldownDeps) {} + constructor(private readonly deps: UrlDrilldownDeps) {} public readonly order = 8; @@ -109,18 +111,37 @@ export class UrlDrilldown implements Drilldown { - const scope = this.getRuntimeVariables(context); - return urlDrilldownCompileUrl(config.url.template, scope); + private buildUrl(config: Config, context: ActionContext): string { + const url = urlDrilldownCompileUrl(config.url.template, this.getRuntimeVariables(context)); + return url; + } + + public readonly getHref = async (config: Config, context: ActionContext): Promise => { + const url = this.buildUrl(config, context); + const validUrl = this.deps.externalUrl.validateUrl(url); + if (!validUrl) { + throw new Error( + `External URL [${url}] was denied by ExternalUrl service. ` + + `You can configure external URL policies using "externalUrl.policy" setting in kibana.yml.` + ); + } + return url; }; public readonly execute = async (config: Config, context: ActionContext) => { - const url = urlDrilldownCompileUrl(config.url.template, this.getRuntimeVariables(context)); + const url = await this.getHref(config, context); if (config.openInNewTab) { window.open(url, '_blank', 'noopener'); } else { diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/plugin.ts b/x-pack/plugins/drilldowns/url_drilldown/public/plugin.ts index 82ce7a129f497..58bb90e9ff845 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/public/plugin.ts +++ b/x-pack/plugins/drilldowns/url_drilldown/public/plugin.ts @@ -38,6 +38,7 @@ export class UrlDrilldownPlugin const startServices = createStartServicesGetter(core.getStartServices); plugins.uiActionsEnhanced.registerDrilldown( new UrlDrilldown({ + externalUrl: core.http.externalUrl, getGlobalScope: urlDrilldownGlobalScopeProvider({ core }), navigateToUrl: (url: string) => core.getStartServices().then(([{ application }]) => application.navigateToUrl(url)), diff --git a/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts b/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts index 57a6b1d3bb932..2b1d89f12be56 100644 --- a/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts +++ b/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts @@ -394,7 +394,7 @@ describe('queryEventsBySavedObject', () => { }, }, "index": "index-name", - "rest_total_hits_as_int": true, + "track_total_hits": true, } `); }); @@ -475,7 +475,7 @@ describe('queryEventsBySavedObject', () => { }, }, "index": "index-name", - "rest_total_hits_as_int": true, + "track_total_hits": true, } `); }); @@ -589,7 +589,7 @@ describe('queryEventsBySavedObject', () => { }, }, "index": "index-name", - "rest_total_hits_as_int": true, + "track_total_hits": true, } `); }); @@ -686,7 +686,7 @@ describe('queryEventsBySavedObject', () => { }, }, "index": "index-name", - "rest_total_hits_as_int": true, + "track_total_hits": true, } `); }); diff --git a/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts b/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts index d1dcf621150a6..0ac1193998cef 100644 --- a/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts +++ b/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts @@ -7,9 +7,10 @@ import { Subject } from 'rxjs'; import { bufferTime, filter, switchMap } from 'rxjs/operators'; import { reject, isUndefined } from 'lodash'; -import { SearchResponse, Client } from 'elasticsearch'; +import { Client } from 'elasticsearch'; import type { PublicMethodsOf } from '@kbn/utility-types'; import { Logger, LegacyClusterClient } from 'src/core/server'; +import { ESSearchResponse } from '../../../../typings/elasticsearch'; import { EsContext } from '.'; import { IEvent, IValidatedEvent, SAVED_OBJECT_REL_PRIMARY } from '../types'; import { FindOptionsType } from '../event_log_client'; @@ -284,17 +285,15 @@ export class ClusterClientAdapter { try { const { hits: { hits, total }, - }: SearchResponse = await this.callEs('search', { + }: ESSearchResponse = await this.callEs('search', { index, - // The SearchResponse type only supports total as an int, - // so we're forced to explicitly request that it return as an int - rest_total_hits_as_int: true, + track_total_hits: true, body, }); return { page, per_page: perPage, - total, + total: total.value, data: hits.map((hit) => hit._source) as IValidatedEvent[], }; } catch (err) { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/agent_logs.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/agent_logs.tsx index 00deeff89503f..95c630e3b3686 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/agent_logs.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/agent_logs.tsx @@ -16,6 +16,7 @@ import { EuiPanel, EuiButtonEmpty, } from '@elastic/eui'; +import useMeasure from 'react-use/lib/useMeasure'; import { FormattedMessage } from '@kbn/i18n/react'; import semverGte from 'semver/functions/gte'; import semverCoerce from 'semver/functions/coerce'; @@ -180,6 +181,8 @@ export const AgentLogsUI: React.FunctionComponent = memo(({ agen [http.basePath, state.start, state.end, logStreamQuery] ); + const [logsPanelRef, { height: logPanelHeight }] = useMeasure(); + const agentVersion = agent.local_metadata?.elastic?.agent?.version; const isLogLevelSelectionAvailable = useMemo(() => { if (!agentVersion) { @@ -259,9 +262,9 @@ export const AgentLogsUI: React.FunctionComponent = memo(({ agen - + setVisible(false), [setVisible]); const AddAlertFlyout = useMemo( () => triggersActionsUI && triggersActionsUI.getAddAlertFlyout({ consumer: 'infrastructure', - addFlyoutVisible: visible!, - setAddFlyoutVisibility: setVisible, + onClose: onCloseFlyout, canChangeTrigger: false, alertTypeId: METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, metadata: { @@ -47,5 +46,5 @@ export const AlertFlyout = ({ options, nodeType, filter, visible, setVisible }: [triggersActionsUI, visible] ); - return <>{AddAlertFlyout}; + return <>{visible && AddAlertFlyout}; }; diff --git a/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_flyout.tsx b/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_flyout.tsx index 206621c4d4dc8..fe8493ccd0fbf 100644 --- a/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_flyout.tsx +++ b/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_flyout.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useContext, useMemo } from 'react'; +import React, { useCallback, useContext, useMemo } from 'react'; import { TriggerActionsContext } from '../../../utils/triggers_actions_context'; import { LOG_DOCUMENT_COUNT_ALERT_TYPE_ID } from '../../../../common/alerting/logs/log_threshold/types'; @@ -14,24 +14,23 @@ interface Props { } export const AlertFlyout = (props: Props) => { + const { visible, setVisible } = props; const { triggersActionsUI } = useContext(TriggerActionsContext); - + const onCloseFlyout = useCallback(() => setVisible(false), [setVisible]); const AddAlertFlyout = useMemo( () => triggersActionsUI && triggersActionsUI.getAddAlertFlyout({ consumer: 'logs', - addFlyoutVisible: props.visible!, - setAddFlyoutVisibility: props.setVisible, + onClose: onCloseFlyout, canChangeTrigger: false, alertTypeId: LOG_DOCUMENT_COUNT_ALERT_TYPE_ID, metadata: { isInternal: true, }, }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [triggersActionsUI, props.visible] + [triggersActionsUI, onCloseFlyout] ); - return <>{AddAlertFlyout}; + return <>{visible && AddAlertFlyout}; }; diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_flyout.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_flyout.tsx index 779478a313b71..72782f555d9ca 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_flyout.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_flyout.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useContext, useMemo } from 'react'; +import React, { useCallback, useContext, useMemo } from 'react'; import { TriggerActionsContext } from '../../../utils/triggers_actions_context'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { METRIC_THRESHOLD_ALERT_TYPE_ID } from '../../../../server/lib/alerting/metric_threshold/types'; @@ -19,15 +19,15 @@ interface Props { } export const AlertFlyout = (props: Props) => { + const { visible, setVisible } = props; const { triggersActionsUI } = useContext(TriggerActionsContext); - + const onCloseFlyout = useCallback(() => setVisible(false), [setVisible]); const AddAlertFlyout = useMemo( () => triggersActionsUI && triggersActionsUI.getAddAlertFlyout({ consumer: 'infrastructure', - addFlyoutVisible: props.visible!, - setAddFlyoutVisibility: props.setVisible, + onClose: onCloseFlyout, canChangeTrigger: false, alertTypeId: METRIC_THRESHOLD_ALERT_TYPE_ID, metadata: { @@ -36,8 +36,8 @@ export const AlertFlyout = (props: Props) => { }, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [triggersActionsUI, props.visible] + [triggersActionsUI, onCloseFlyout] ); - return <>{AddAlertFlyout}; + return <>{visible && AddAlertFlyout}; }; diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts index 14d1acf0e4a9f..6ec6210ecb344 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { schema } from '@kbn/config-schema'; +import { AlertType } from '../../../../../alerts/server'; import { createInventoryMetricThresholdExecutor, FIRED_ACTIONS, @@ -38,7 +39,7 @@ const condition = schema.object({ ), }); -export const registerMetricInventoryThresholdAlertType = (libs: InfraBackendLibs) => ({ +export const registerMetricInventoryThresholdAlertType = (libs: InfraBackendLibs): AlertType => ({ id: METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, name: 'Inventory', validate: { @@ -58,6 +59,7 @@ export const registerMetricInventoryThresholdAlertType = (libs: InfraBackendLibs defaultActionGroupId: FIRED_ACTIONS.id, actionGroups: [FIRED_ACTIONS], producer: 'infrastructure', + minimumLicenseRequired: 'basic', executor: createInventoryMetricThresholdExecutor(libs), actionVariables: { context: [ diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts index 34afddc2a4d48..64bfad92a8458 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts @@ -89,6 +89,7 @@ export async function registerLogThresholdAlertType( }, defaultActionGroupId: FIRED_ACTIONS.id, actionGroups: [FIRED_ACTIONS], + minimumLicenseRequired: 'basic', executor: createLogThresholdExecutor(libs), actionVariables: { context: [ diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts index 45b1df2f03ea1..1a10765eaf734 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { schema } from '@kbn/config-schema'; +import { AlertType } from '../../../../../alerts/server'; import { METRIC_EXPLORER_AGGREGATIONS } from '../../../../common/http_api/metrics_explorer'; import { createMetricThresholdExecutor, FIRED_ACTIONS } from './metric_threshold_executor'; import { METRIC_THRESHOLD_ALERT_TYPE_ID, Comparator } from './types'; @@ -19,7 +20,7 @@ import { thresholdActionVariableDescription, } from '../common/messages'; -export function registerMetricThresholdAlertType(libs: InfraBackendLibs) { +export function registerMetricThresholdAlertType(libs: InfraBackendLibs): AlertType { const baseCriterion = { threshold: schema.arrayOf(schema.number()), comparator: oneOfLiterals(Object.values(Comparator)), @@ -60,6 +61,7 @@ export function registerMetricThresholdAlertType(libs: InfraBackendLibs) { }, defaultActionGroupId: FIRED_ACTIONS.id, actionGroups: [FIRED_ACTIONS], + minimumLicenseRequired: 'basic', executor: createMetricThresholdExecutor(libs), actionVariables: { context: [ diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/script.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/script.tsx index 8685738b39273..a62d9a79adb99 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/script.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/script.tsx @@ -129,7 +129,7 @@ const fieldsConfig: FieldsConfig = { export const Script: FormFieldsComponent = ({ initialFieldValues }) => { const [showId, setShowId] = useState(() => !!initialFieldValues?.id); - const [scriptLanguage, setScriptLanguage] = useState('plaintext'); + const [scriptLanguage, setScriptLanguage] = useState(PainlessLang.ID); const [{ fields }] = useFormData({ watch: 'fields.lang' }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/__snapshots__/expression.test.tsx.snap b/x-pack/plugins/lens/public/datatable_visualization/__snapshots__/expression.test.tsx.snap index d340d002b242b..23460d442cfa8 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/__snapshots__/expression.test.tsx.snap +++ b/x-pack/plugins/lens/public/datatable_visualization/__snapshots__/expression.test.tsx.snap @@ -12,16 +12,19 @@ exports[`datatable_expression DatatableComponent it renders actions column when "field": "a", "name": "a", "render": [Function], + "sortable": true, }, Object { "field": "b", "name": "b", "render": [Function], + "sortable": true, }, Object { "field": "c", "name": "c", "render": [Function], + "sortable": true, }, Object { "actions": Array [ @@ -49,7 +52,14 @@ exports[`datatable_expression DatatableComponent it renders actions column when ] } noItemsMessage="No items found" + onChange={[Function]} responsive={true} + sorting={ + Object { + "allowNeutralSort": true, + "sort": undefined, + } + } tableLayout="auto" /> @@ -67,16 +77,19 @@ exports[`datatable_expression DatatableComponent it renders the title and value "field": "a", "name": "a", "render": [Function], + "sortable": true, }, Object { "field": "b", "name": "b", "render": [Function], + "sortable": true, }, Object { "field": "c", "name": "c", "render": [Function], + "sortable": true, }, ] } @@ -92,7 +105,14 @@ exports[`datatable_expression DatatableComponent it renders the title and value ] } noItemsMessage="No items found" + onChange={[Function]} responsive={true} + sorting={ + Object { + "allowNeutralSort": true, + "sort": undefined, + } + } tableLayout="auto" /> diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx index 131e4200ae240..d0811e0ad05a6 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { mountWithIntl } from '@kbn/test/jest'; -import { datatable, DatatableComponent } from './expression'; +import { getDatatable, DatatableComponent } from './expression'; import { LensMultiTable } from '../types'; import { DatatableProps } from './expression'; import { createMockExecutionContext } from '../../../../../src/plugins/expressions/common/mocks'; @@ -15,6 +15,7 @@ import { IFieldFormat } from '../../../../../src/plugins/data/public'; import { IAggType } from 'src/plugins/data/public'; import { EmptyPlaceholder } from '../shared_components'; import { LensIconChartDatatable } from '../assets/chart_datatable'; +import { EuiBasicTable } from '@elastic/eui'; function sampleArgs() { const indexPatternId = 'indexPatternId'; @@ -67,6 +68,8 @@ function sampleArgs() { title: 'My fanci metric chart', columns: { columnIds: ['a', 'b', 'c'], + sortBy: '', + sortDirection: 'none', type: 'lens_datatable_columns', }, }; @@ -76,14 +79,21 @@ function sampleArgs() { describe('datatable_expression', () => { let onClickValue: jest.Mock; + let onEditAction: jest.Mock; + beforeEach(() => { onClickValue = jest.fn(); + onEditAction = jest.fn(); }); describe('datatable renders', () => { test('it renders with the specified data and args', () => { const { data, args } = sampleArgs(); - const result = datatable.fn(data, args, createMockExecutionContext()); + const result = getDatatable({ formatFactory: (x) => x as IFieldFormat }).fn( + data, + args, + createMockExecutionContext() + ); expect(result).toEqual({ type: 'render', @@ -105,6 +115,7 @@ describe('datatable_expression', () => { formatFactory={(x) => x as IFieldFormat} onClickValue={onClickValue} getType={jest.fn()} + renderMode="edit" /> ) ).toMatchSnapshot(); @@ -123,6 +134,7 @@ describe('datatable_expression', () => { getType={jest.fn()} onRowContextMenuClick={() => undefined} rowHasRowClickTriggerActions={[true, true, true]} + renderMode="edit" /> ) ).toMatchSnapshot(); @@ -144,6 +156,7 @@ describe('datatable_expression', () => { formatFactory={(x) => x as IFieldFormat} onClickValue={onClickValue} getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} + renderMode="edit" /> ); @@ -179,6 +192,7 @@ describe('datatable_expression', () => { formatFactory={(x) => x as IFieldFormat} onClickValue={onClickValue} getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} + renderMode="edit" /> ); @@ -232,7 +246,12 @@ describe('datatable_expression', () => { const args: DatatableProps['args'] = { title: '', - columns: { columnIds: ['a', 'b'], type: 'lens_datatable_columns' }, + columns: { + columnIds: ['a', 'b'], + sortBy: '', + sortDirection: 'none', + type: 'lens_datatable_columns', + }, }; const wrapper = mountWithIntl( @@ -248,6 +267,7 @@ describe('datatable_expression', () => { formatFactory={(x) => x as IFieldFormat} onClickValue={onClickValue} getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} + renderMode="edit" /> ); @@ -288,9 +308,90 @@ describe('datatable_expression', () => { getType={jest.fn((type) => type === 'count' ? ({ type: 'metrics' } as IAggType) : ({ type: 'buckets' } as IAggType) )} + renderMode="edit" /> ); expect(component.find(EmptyPlaceholder).prop('icon')).toEqual(LensIconChartDatatable); }); + + test('it renders the table with the given sorting', () => { + const { data, args } = sampleArgs(); + + const wrapper = mountWithIntl( + x as IFieldFormat} + onClickValue={onClickValue} + onEditAction={onEditAction} + getType={jest.fn()} + renderMode="edit" + /> + ); + + // there's currently no way to detect the sorting column via DOM + expect( + wrapper.exists('[className*="isSorted"][data-test-subj="tableHeaderSortButton"]') + ).toBe(true); + // check that the sorting is passing the right next state for the same column + wrapper + .find('[className*="isSorted"][data-test-subj="tableHeaderSortButton"]') + .first() + .simulate('click'); + + expect(onEditAction).toHaveBeenCalledWith({ + action: 'sort', + columnId: undefined, + direction: 'none', + }); + + // check that the sorting is passing the right next state for another column + wrapper + .find('[data-test-subj="tableHeaderSortButton"]') + .not('[className*="isSorted"]') + .first() + .simulate('click'); + + expect(onEditAction).toHaveBeenCalledWith({ + action: 'sort', + columnId: 'a', + direction: 'asc', + }); + }); + + test('it renders the table with the given sorting in readOnly mode', () => { + const { data, args } = sampleArgs(); + + const wrapper = mountWithIntl( + x as IFieldFormat} + onClickValue={onClickValue} + onEditAction={onEditAction} + getType={jest.fn()} + renderMode="display" + /> + ); + + expect(wrapper.find(EuiBasicTable).prop('sorting')).toMatchObject({ + sort: undefined, + allowNeutralSort: true, + }); + }); }); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx index f1eaab908717a..4d1df5b519ba9 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx @@ -16,13 +16,19 @@ import { EuiButtonIcon, EuiFlexItem, EuiToolTip, + Direction, + EuiScreenReaderOnly, + EuiIcon, EuiBasicTableColumn, EuiTableActionsColumnType, } from '@elastic/eui'; +import { orderBy } from 'lodash'; import { IAggType } from 'src/plugins/data/public'; +import { Datatable, DatatableColumnMeta, RenderMode } from 'src/plugins/expressions'; import { FormatFactory, ILensInterpreterRenderHandlers, + LensEditEvent, LensFilterEvent, LensMultiTable, LensTableRowContextMenuEvent, @@ -36,8 +42,22 @@ import { EmptyPlaceholder } from '../shared_components'; import { desanitizeFilterContext } from '../utils'; import { LensIconChartDatatable } from '../assets/chart_datatable'; +export const LENS_EDIT_SORT_ACTION = 'sort'; + +export interface LensSortActionData { + columnId: string | undefined; + direction: 'asc' | 'desc' | 'none'; +} + +type LensSortAction = LensEditEvent; + +// This is a way to circumvent the explicit "any" forbidden type +type TableRowField = Datatable['rows'][number] & { rowIndex: number }; + export interface DatatableColumns { columnIds: string[]; + sortBy: string; + sortDirection: string; } interface Args { @@ -54,8 +74,10 @@ export interface DatatableProps { type DatatableRenderProps = DatatableProps & { formatFactory: FormatFactory; onClickValue: (data: LensFilterEvent['data']) => void; - onRowContextMenuClick?: (data: LensTableRowContextMenuEvent['data']) => void; + onEditAction?: (data: LensSortAction['data']) => void; getType: (name: string) => IAggType; + renderMode: RenderMode; + onRowContextMenuClick?: (data: LensTableRowContextMenuEvent['data']) => void; /** * A boolean for each table row, which is true if the row active @@ -70,12 +92,11 @@ export interface DatatableRender { value: DatatableProps; } -export const datatable: ExpressionFunctionDefinition< - 'lens_datatable', - LensMultiTable, - Args, - DatatableRender -> = { +export const getDatatable = ({ + formatFactory, +}: { + formatFactory: FormatFactory; +}): ExpressionFunctionDefinition<'lens_datatable', LensMultiTable, Args, DatatableRender> => ({ name: 'lens_datatable', type: 'render', inputTypes: ['lens_multitable'], @@ -98,7 +119,40 @@ export const datatable: ExpressionFunctionDefinition< help: '', }, }, - fn(data, args) { + fn(data, args, context) { + // do the sorting at this level to propagate it also at CSV download + const [firstTable] = Object.values(data.tables); + const [layerId] = Object.keys(context.inspectorAdapters.tables || {}); + const formatters: Record> = {}; + + firstTable.columns.forEach((column) => { + formatters[column.id] = formatFactory(column.meta?.params); + }); + const { sortBy, sortDirection } = args.columns; + + const columnsReverseLookup = firstTable.columns.reduce< + Record + >((memo, { id, name, meta }, i) => { + memo[id] = { name, index: i, meta }; + return memo; + }, {}); + + if (sortBy && sortDirection !== 'none') { + // Sort on raw values for these types, while use the formatted value for the rest + const sortingCriteria = ['number', 'date'].includes( + columnsReverseLookup[sortBy]?.meta?.type || '' + ) + ? sortBy + : (row: Record) => formatters[sortBy]?.convert(row[sortBy]); + // replace the table here + context.inspectorAdapters.tables[layerId].rows = orderBy( + firstTable.rows || [], + [sortingCriteria], + sortDirection as Direction + ); + // replace also the local copy + firstTable.rows = context.inspectorAdapters.tables[layerId].rows; + } return { type: 'render', as: 'lens_datatable_renderer', @@ -108,7 +162,7 @@ export const datatable: ExpressionFunctionDefinition< }, }; }, -}; +}); type DatatableColumnsResult = DatatableColumns & { type: 'lens_datatable_columns' }; @@ -124,6 +178,8 @@ export const datatableColumns: ExpressionFunctionDefinition< help: '', inputTypes: ['null'], args: { + sortBy: { types: ['string'], help: '' }, + sortDirection: { types: ['string'], help: '' }, columnIds: { types: ['string'], multi: true, @@ -139,7 +195,7 @@ export const datatableColumns: ExpressionFunctionDefinition< }; export const getDatatableRenderer = (dependencies: { - formatFactory: Promise; + formatFactory: FormatFactory; getType: Promise<(name: string) => IAggType>; }): ExpressionRenderDefinition => ({ name: 'lens_datatable_renderer', @@ -154,11 +210,16 @@ export const getDatatableRenderer = (dependencies: { config: DatatableProps, handlers: ILensInterpreterRenderHandlers ) => { - const resolvedFormatFactory = await dependencies.formatFactory; const resolvedGetType = await dependencies.getType; const onClickValue = (data: LensFilterEvent['data']) => { handlers.event({ name: 'filter', data }); }; + + const onEditAction = (data: LensSortAction['data']) => { + if (handlers.getRenderMode() === 'edit') { + handlers.event({ name: 'edit', data }); + } + }; const onRowContextMenuClick = (data: LensTableRowContextMenuEvent['data']) => { handlers.event({ name: 'tableRowContextMenuClick', data }); }; @@ -195,8 +256,10 @@ export const getDatatableRenderer = (dependencies: { = ['asc', 'desc', 'none']; + const newStateIndex = (1 + states.findIndex((state) => state === currentValue)) % states.length; + return states[newStateIndex]; +} + +function getDirectionLongLabel(sortDirection: LensSortAction['data']['direction']) { + if (sortDirection === 'none') { + return sortDirection; + } + return sortDirection === 'asc' ? 'ascending' : 'descending'; +} + +function getHeaderSortingCell( + name: string, + columnId: string, + sorting: Omit, + sortingLabel: string +) { + if (columnId !== sorting.columnId || sorting.direction === 'none') { + return name || ''; + } + // This is a workaround to hijack the title value of the header cell + return ( + + {name || ''} + + {sortingLabel} + + + + ); +} + export function DatatableComponent(props: DatatableRenderProps) { const [firstTable] = Object.values(props.data.tables); const formatters: Record> = {}; @@ -219,7 +321,7 @@ export function DatatableComponent(props: DatatableRenderProps) { formatters[column.id] = props.formatFactory(column.meta?.params); }); - const { onClickValue, onRowContextMenuClick } = props; + const { onClickValue, onEditAction, onRowContextMenuClick } = props; const handleFilterClick = useMemo( () => (field: string, value: unknown, colIndex: number, negate: boolean = false) => { const col = firstTable.columns[colIndex]; @@ -264,90 +366,118 @@ export function DatatableComponent(props: DatatableRenderProps) { return ; } - const tableColumns: Array< - EuiBasicTableColumn<{ rowIndex: number; [key: string]: unknown }> - > = props.args.columns.columnIds - .map((field) => { - const col = firstTable.columns.find((c) => c.id === field); - const filterable = bucketColumns.includes(field); - const colIndex = firstTable.columns.findIndex((c) => c.id === field); - return { - field, - name: (col && col.name) || '', - render: (value: unknown) => { - const formattedValue = formatters[field]?.convert(value); - const fieldName = col?.meta?.field; - - if (filterable) { - return ( - - {formattedValue} - - !!field); + const columnsReverseLookup = firstTable.columns.reduce< + Record + >((memo, { id, name, meta }, i) => { + memo[id] = { name, index: i, meta }; + return memo; + }, {}); + + const { sortBy, sortDirection } = props.args.columns; + + const sortedRows: TableRowField[] = + firstTable?.rows.map((row, rowIndex) => ({ ...row, rowIndex })) || []; + const isReadOnlySorted = props.renderMode !== 'edit'; + + const sortedInLabel = i18n.translate('xpack.lens.datatableSortedInReadOnlyMode', { + defaultMessage: 'Sorted in {sortValue} order', + values: { + sortValue: sortDirection === 'asc' ? 'ascending' : 'descending', + }, + }); + + const tableColumns: Array> = visibleColumns.map((field) => { + const filterable = bucketColumns.includes(field); + const { name, index: colIndex, meta } = columnsReverseLookup[field]; + const fieldName = meta?.field; + const nameContent = !isReadOnlySorted + ? name + : getHeaderSortingCell( + name, + field, + { + columnId: sortBy, + direction: sortDirection as LensSortAction['data']['direction'], + }, + sortedInLabel + ); + return { + field, + name: nameContent, + sortable: !isReadOnlySorted, + render: (value: unknown) => { + const formattedValue = formatters[field]?.convert(value); + + if (filterable) { + return ( + + {formattedValue} + + + + handleFilterClick(field, value, colIndex)} + /> + + handleFilterClick(field, value, colIndex)} + data-test-subj="lensDatatableFilterOut" + onClick={() => handleFilterClick(field, value, colIndex, true)} /> - - - handleFilterClick(field, value, colIndex, true)} - /> - - - - - - ); - } - return {formattedValue}; - }, - }; - }) - .filter(({ field }) => !!field); + + + + + ); + } + return {formattedValue}; + }, + }; + }); if (!!props.rowHasRowClickTriggerActions && !!onRowContextMenuClick) { const hasAtLeastOneRowClickAction = props.rowHasRowClickTriggerActions.find((x) => x); if (hasAtLeastOneRowClickAction) { - const actions: EuiTableActionsColumnType<{ rowIndex: number; [key: string]: unknown }> = { + const actions: EuiTableActionsColumnType = { name: i18n.translate('xpack.lens.datatable.actionsColumnName', { defaultMessage: 'Actions', }), @@ -391,8 +521,32 @@ export function DatatableComponent(props: DatatableRenderProps) { className="lnsDataTable" data-test-subj="lnsDataTable" tableLayout="auto" + sorting={{ + sort: + !sortBy || sortDirection === 'none' || isReadOnlySorted + ? undefined + : { + field: sortBy, + direction: sortDirection as Direction, + }, + allowNeutralSort: true, // this flag enables the 3rd Neutral state on the column header + }} + onChange={(event: { sort?: { field: string } }) => { + if (event.sort && onEditAction) { + const isNewColumn = sortBy !== event.sort.field; + // unfortunately the neutral state is not propagated and we need to manually handle it + const nextDirection = getNextOrderValue( + (isNewColumn ? 'none' : sortDirection) as LensSortAction['data']['direction'] + ); + return onEditAction({ + action: 'sort', + columnId: nextDirection !== 'none' || isNewColumn ? event.sort.field : undefined, + direction: nextDirection, + }); + } + }} columns={tableColumns} - items={firstTable ? firstTable.rows.map((row, rowIndex) => ({ ...row, rowIndex })) : []} + items={sortedRows} /> ); diff --git a/x-pack/plugins/lens/public/datatable_visualization/index.ts b/x-pack/plugins/lens/public/datatable_visualization/index.ts index 9c7d7ae1f2d43..42d2ff6a220c0 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/index.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/index.ts @@ -27,17 +27,18 @@ export class DatatableVisualization { ) { editorFrame.registerVisualization(async () => { const { - datatable, + getDatatable, datatableColumns, getDatatableRenderer, datatableVisualization, } = await import('../async_services'); + const resolvedFormatFactory = await formatFactory; expressions.registerFunction(() => datatableColumns); - expressions.registerFunction(() => datatable); + expressions.registerFunction(() => getDatatable({ formatFactory: resolvedFormatFactory })); expressions.registerRenderer(() => getDatatableRenderer({ - formatFactory, + formatFactory: resolvedFormatFactory, getType: core .getStartServices() .then(([_, { data: dataStart }]) => dataStart.search.aggs.types.get), diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx index cf3752e649600..088246ccf4b9c 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx @@ -306,6 +306,41 @@ describe('Datatable Visualization', () => { ], }); }); + + it('should handle correctly the sorting state on removing dimension', () => { + const layer = { layerId: 'layer1', columns: ['b', 'c'] }; + expect( + datatableVisualization.removeDimension({ + prevState: { layers: [layer], sorting: { columnId: 'b', direction: 'asc' } }, + layerId: 'layer1', + columnId: 'b', + }) + ).toEqual({ + sorting: undefined, + layers: [ + { + layerId: 'layer1', + columns: ['c'], + }, + ], + }); + + expect( + datatableVisualization.removeDimension({ + prevState: { layers: [layer], sorting: { columnId: 'c', direction: 'asc' } }, + layerId: 'layer1', + columnId: 'b', + }) + ).toEqual({ + sorting: { columnId: 'c', direction: 'asc' }, + layers: [ + { + layerId: 'layer1', + columns: ['c'], + }, + ], + }); + }); }); describe('#setDimension', () => { @@ -371,6 +406,8 @@ describe('Datatable Visualization', () => { expect(tableArgs).toHaveLength(1); expect(tableArgs[0].arguments).toEqual({ columnIds: ['c', 'b'], + sortBy: [''], + sortDirection: ['none'], }); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 8b5d2d7d73348..e4f787a265186 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -22,6 +22,10 @@ export interface LayerState { export interface DatatableVisualizationState { layers: LayerState[]; + sorting?: { + columnId: string | undefined; + direction: 'asc' | 'desc' | 'none'; + }; } function newLayerState(layerId: string): LayerState { @@ -196,6 +200,7 @@ export const datatableVisualization: Visualization } : l ), + sorting: prevState.sorting?.columnId === columnId ? undefined : prevState.sorting, }; }, @@ -232,6 +237,8 @@ export const datatableVisualization: Visualization function: 'lens_datatable_columns', arguments: { columnIds: operations.map((o) => o.columnId), + sortBy: [state.sorting?.columnId || ''], + sortDirection: [state.sorting?.direction || 'none'], }, }, ], @@ -246,6 +253,19 @@ export const datatableVisualization: Visualization getErrorMessages(state, frame) { return undefined; }, + + onEditAction(state, event) { + if (event.data.action !== 'sort') { + return state; + } + return { + ...state, + sorting: { + columnId: event.data.columnId, + direction: event.data.direction, + }, + }; + }, }; function getDataSourceAndSortedColumns( diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx index c39c46c1f4152..608a7b110b7d9 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx @@ -42,7 +42,7 @@ function LayerPanels( dispatch({ type: 'UPDATE_VISUALIZATION_STATE', visualizationId: activeVisualization.id, - newState, + updater: newState, clearStagedPreview: false, }); }, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx index fea9723aa700d..977947b5afbeb 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx @@ -126,7 +126,7 @@ export function EditorFrame(props: EditorFrameProps) { dispatch({ type: 'UPDATE_VISUALIZATION_STATE', visualizationId: activeVisualization.id, - newState: layerIds.reduce( + updater: layerIds.reduce( (acc, layerId) => activeVisualization.removeLayer ? activeVisualization.removeLayer(acc, layerId) : acc, state.visualization.state @@ -187,7 +187,7 @@ export function EditorFrame(props: EditorFrameProps) { dispatch({ type: 'UPDATE_VISUALIZATION_STATE', visualizationId: activeVisualization.id, - newState: initialVisualizationState, + updater: initialVisualizationState, }); } }, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts index 6d0e1ad48dc21..792fdc6d1ace7 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts @@ -129,7 +129,7 @@ describe('editor_frame state management', () => { { type: 'UPDATE_VISUALIZATION_STATE', visualizationId: 'testVis', - newState: newVisState, + updater: newVisState, } ); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.ts index 55a4cb567fda1..3e6c079ea3b6c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.ts @@ -54,7 +54,7 @@ export type Action = | { type: 'UPDATE_VISUALIZATION_STATE'; visualizationId: string; - newState: unknown; + updater: unknown | ((state: unknown) => unknown); clearStagedPreview?: boolean; } | { @@ -282,7 +282,10 @@ export const reducer = (state: EditorFrameState, action: Action): EditorFrameSta ...state, visualization: { ...state.visualization, - state: action.newState, + state: + typeof action.updater === 'function' + ? action.updater(state.visualization.state) + : action.updater, }, stagedPreview: action.clearStagedPreview ? undefined : state.stagedPreview, }; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index 6c2c01d944cd9..52d1b2729c7ab 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -37,6 +37,7 @@ import { FramePublicAPI, isLensBrushEvent, isLensFilterEvent, + isLensEditEvent, } from '../../../types'; import { DragDrop, DragContext } from '../../../drag_drop'; import { getSuggestions, switchToSuggestion } from '../suggestion_helpers'; @@ -217,8 +218,15 @@ export function WorkspacePanel({ data: event.data, }); } + if (isLensEditEvent(event) && activeVisualization?.onEditAction) { + dispatch({ + type: 'UPDATE_VISUALIZATION_STATE', + visualizationId: activeVisualization.id, + updater: (oldState: unknown) => activeVisualization.onEditAction!(oldState, event), + }); + } }, - [plugins.uiActions] + [plugins.uiActions, dispatch, activeVisualization] ); useEffect(() => { @@ -472,6 +480,7 @@ export const InnerVisualizationWrapper = ({ reload$={autoRefreshFetch$} onEvent={onEvent} onData$={onData$} + renderMode="edit" renderError={(errorMessage?: string | null, error?: ExpressionRenderError | null) => { const visibleErrorMessage = getOriginalRequestErrorMessage(error) || errorMessage; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx index 97a842f9e0243..33d96460e9953 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx @@ -59,7 +59,7 @@ export function WorkspacePanelWrapper({ dispatch({ type: 'UPDATE_VISUALIZATION_STATE', visualizationId: activeVisualization.id, - newState, + updater: newState, clearStagedPreview: false, }); }, diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 23d026bf2b443..d5283822107e4 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -26,6 +26,10 @@ import { VALUE_CLICK_TRIGGER, VisualizeFieldContext, } from '../../../../src/plugins/ui_actions/public'; +import type { + LensSortActionData, + LENS_EDIT_SORT_ACTION, +} from './datatable_visualization/expression'; export type ErrorCallback = (e: { message: string }) => void; @@ -609,6 +613,11 @@ export interface Visualization { * The frame calls this function to display warnings about visualization */ getWarningMessages?: (state: T, frame: FramePublicAPI) => React.ReactNode[] | undefined; + + /** + * On Edit events the frame will call this to know what's going to be the next visualization state + */ + onEditAction?: (state: T, event: LensEditEvent) => T; } export interface LensFilterEvent { @@ -621,6 +630,22 @@ export interface LensBrushEvent { data: TriggerContext['data']; } +// Use same technique as TriggerContext +interface LensEditContextMapping { + [LENS_EDIT_SORT_ACTION]: LensSortActionData; +} +type LensEditSupportedActions = keyof LensEditContextMapping; + +export type LensEditPayload = { + action: T; +} & LensEditContextMapping[T]; + +type EditPayloadContext = T extends LensEditSupportedActions ? LensEditPayload : never; + +export interface LensEditEvent { + name: 'edit'; + data: EditPayloadContext; +} export interface LensTableRowContextMenuEvent { name: 'tableRowContextMenuClick'; data: TriggerContext['data']; @@ -634,6 +659,12 @@ export function isLensBrushEvent(event: ExpressionRendererEvent): event is LensB return event.name === 'brush'; } +export function isLensEditEvent( + event: ExpressionRendererEvent +): event is LensEditEvent { + return event.name === 'edit'; +} + export function isLensTableRowContextMenuClickEvent( event: ExpressionRendererEvent ): event is LensBrushEvent { @@ -646,5 +677,11 @@ export function isLensTableRowContextMenuClickEvent( * used, dispatched events will be handled correctly. */ export interface ILensInterpreterRenderHandlers extends IInterpreterRenderHandlers { - event: (event: LensFilterEvent | LensBrushEvent | LensTableRowContextMenuEvent) => void; + event: ( + event: + | LensFilterEvent + | LensBrushEvent + | LensEditEvent + | LensTableRowContextMenuEvent + ) => void; } diff --git a/x-pack/plugins/licensing/server/plugin.test.ts b/x-pack/plugins/licensing/server/plugin.test.ts index b1669db00f227..7aec81f84f978 100644 --- a/x-pack/plugins/licensing/server/plugin.test.ts +++ b/x-pack/plugins/licensing/server/plugin.test.ts @@ -81,6 +81,25 @@ describe('licensing plugin', () => { expect(license.isAvailable).toBe(true); }); + it('calls `callAsInternalUser` with the correct parameters', async () => { + const esClient = elasticsearchServiceMock.createLegacyClusterClient(); + esClient.callAsInternalUser.mockResolvedValue({ + license: buildRawLicense(), + features: {}, + }); + + const coreSetup = createCoreSetupWith(esClient); + await plugin.setup(coreSetup); + const { license$ } = await plugin.start(); + await license$.pipe(take(1)).toPromise(); + + expect(esClient.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(esClient.callAsInternalUser).toHaveBeenCalledWith('transport.request', { + method: 'GET', + path: '/_xpack?accept_enterprise=true', + }); + }); + it('observable receives updated licenses', async () => { const types: LicenseType[] = ['basic', 'gold', 'platinum']; diff --git a/x-pack/plugins/licensing/server/plugin.ts b/x-pack/plugins/licensing/server/plugin.ts index 2ee8d26419571..3823f251fb7fb 100644 --- a/x-pack/plugins/licensing/server/plugin.ts +++ b/x-pack/plugins/licensing/server/plugin.ts @@ -182,7 +182,7 @@ export class LicensingPlugin implements Plugin tryCatch(() => importList({ http, signal, ...payload }), toError)), chain((response) => fromEither(validateEither(listSchema, response))), - flow(toPromise) + toPromise ); export { importListWithValidation as importList }; diff --git a/x-pack/plugins/monitoring/server/alerts/base_alert.ts b/x-pack/plugins/monitoring/server/alerts/base_alert.ts index 4f989b37421ef..ebff72a255777 100644 --- a/x-pack/plugins/monitoring/server/alerts/base_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/base_alert.ts @@ -107,6 +107,7 @@ export class BaseAlert { }, ], defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', executor: (options: AlertExecutorOptions & { state: ExecutedState }): Promise => this.execute(options), producer: 'monitoring', diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 686c05e2fda51..6b0b7c62f5bfb 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -42,6 +42,7 @@ export const ENDPOINT_METADATA_INDEX = 'metrics-endpoint.metadata-*'; export const DEFAULT_RULE_REFRESH_INTERVAL_ON = true; export const DEFAULT_RULE_REFRESH_INTERVAL_VALUE = 60000; // ms export const DEFAULT_RULE_REFRESH_IDLE_VALUE = 2700000; // ms +export const DEFAULT_RULE_NOTIFICATION_QUERY_SIZE = 100; export enum SecurityPageName { detections = 'detections', diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/delete_signals_migration_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/delete_signals_migration_schema.ts new file mode 100644 index 0000000000000..995301a47ebeb --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/delete_signals_migration_schema.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 * as t from 'io-ts'; + +export const deleteSignalsMigrationSchema = t.exact( + t.type({ + migration_ids: t.array(t.string), + }) +); + +export type DeleteSignalsMigrationSchema = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/finalize_signals_migration_schema.mock.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/finalize_signals_migration_schema.mock.ts index d0387586a2527..4bfb080c17f8b 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/finalize_signals_migration_schema.mock.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/finalize_signals_migration_schema.mock.ts @@ -7,6 +7,5 @@ import { FinalizeSignalsMigrationSchema } from './finalize_signals_migration_schema'; export const getFinalizeSignalsMigrationSchemaMock = (): FinalizeSignalsMigrationSchema => ({ - migration_token: - 'eyJkZXN0aW5hdGlvbkluZGV4IjoiZGVzdGluYXRpb25JbmRleCIsInNvdXJjZUluZGV4Ijoic291cmNlSW5kZXgiLCJ0YXNrSWQiOiJteS10YXNrLWlkIn0=', + migration_ids: ['migrationSOIdentifier'], }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/finalize_signals_migration_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/finalize_signals_migration_schema.ts index 7ab2ee3810258..c6617642a6557 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/finalize_signals_migration_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/finalize_signals_migration_schema.ts @@ -6,13 +6,9 @@ import * as t from 'io-ts'; -import { NonEmptyString } from '../types'; - -const migrationToken = NonEmptyString; - export const finalizeSignalsMigrationSchema = t.exact( t.type({ - migration_token: migrationToken, + migration_ids: t.array(t.string), }) ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/get_signals_migration_status_schema.mock.ts similarity index 54% rename from x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/index.ts rename to x-pack/plugins/security_solution/common/detection_engine/schemas/request/get_signals_migration_status_schema.mock.ts index 2de9b87ead3fe..8cf133cca822e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/index.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/get_signals_migration_status_schema.mock.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './types'; -export * from './field_mapping'; -export * from './field_mapping_row'; -export * from './utils'; +import { GetSignalsMigrationStatusSchema } from './get_signals_migration_status_schema'; + +export const getSignalsMigrationStatusSchemaMock = (): GetSignalsMigrationStatusSchema => ({ + from: 'now-30d', +}); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/get_migration_status_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/get_signals_migration_status_schema.ts similarity index 69% rename from x-pack/plugins/security_solution/common/detection_engine/schemas/request/get_migration_status_schema.ts rename to x-pack/plugins/security_solution/common/detection_engine/schemas/request/get_signals_migration_status_schema.ts index dfa230fc21d71..7bc488718a950 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/get_migration_status_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/get_signals_migration_status_schema.ts @@ -8,10 +8,10 @@ import * as t from 'io-ts'; import { from } from '../common/schemas'; -export const getMigrationStatusSchema = t.exact( +export const getSignalsMigrationStatusSchema = t.exact( t.type({ from, }) ); -export type GetMigrationStatusSchema = t.TypeOf; +export type GetSignalsMigrationStatusSchema = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/validate.ts b/x-pack/plugins/security_solution/common/validate.ts index 9745c21a191f0..a236828f9bfb4 100644 --- a/x-pack/plugins/security_solution/common/validate.ts +++ b/x-pack/plugins/security_solution/common/validate.ts @@ -6,6 +6,7 @@ import { fold, Either, mapLeft } from 'fp-ts/lib/Either'; import { pipe } from 'fp-ts/lib/pipeable'; +import { fromEither, TaskEither } from 'fp-ts/lib/TaskEither'; import * as t from 'io-ts'; import { exactCheck } from './exact_check'; import { formatErrors } from './format_errors'; @@ -33,3 +34,8 @@ export const validateEither = ( (a) => schema.validate(a, t.getDefaultContext(schema.asDecoder())), mapLeft((errors) => new Error(formatErrors(errors).join(','))) ); + +export const validateTaskEither = ( + schema: T, + obj: A +): TaskEither => fromEither(validateEither(schema, obj)); diff --git a/x-pack/plugins/security_solution/cypress/integration/cases_connectors.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases_connectors.spec.ts index f664c61df298a..acb56d1f24668 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases_connectors.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases_connectors.spec.ts @@ -5,7 +5,7 @@ */ import { serviceNowConnector } from '../objects/case'; -import { TOASTER } from '../screens/configure_cases'; +import { SERVICE_NOW_MAPPING, TOASTER } from '../screens/configure_cases'; import { goToEditExternalConnection } from '../tasks/all_cases'; import { @@ -18,9 +18,33 @@ import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; import { CASES_URL } from '../urls/navigation'; describe('Cases connectors', () => { + const configureResult = { + connector: { + id: 'e271c3b8-f702-4fbc-98e0-db942b573bbd', + name: 'SN', + type: '.servicenow', + fields: null, + }, + closure_type: 'close-by-user', + created_at: '2020-12-01T16:28:09.219Z', + created_by: { email: null, full_name: null, username: 'elastic' }, + updated_at: null, + updated_by: null, + mappings: [ + { source: 'title', target: 'short_description', action_type: 'overwrite' }, + { source: 'description', target: 'description', action_type: 'overwrite' }, + { source: 'comments', target: 'comments', action_type: 'append' }, + ], + version: 'WzEwNCwxXQ==', + }; before(() => { cy.intercept('POST', '/api/actions/action').as('createConnector'); - cy.intercept('POST', '/api/cases/configure').as('saveConnector'); + cy.intercept('POST', '/api/cases/configure', (req) => { + const connector = req.body.connector; + req.reply((res) => { + res.send(200, { ...configureResult, connector }); + }); + }).as('saveConnector'); }); it('Configures a new connector', () => { @@ -37,6 +61,7 @@ describe('Cases connectors', () => { selectLastConnectorCreated(response!.body.id); cy.wait('@saveConnector', { timeout: 10000 }).its('response.statusCode').should('eql', 200); + cy.get(SERVICE_NOW_MAPPING).first().should('have.text', 'short_description'); cy.get(TOASTER).should('have.text', 'Saved external connection settings'); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_creation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_creation.spec.ts index 4009ac13ab120..f087c0348f6af 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_creation.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_creation.spec.ts @@ -8,12 +8,13 @@ import { timeline } from '../objects/timeline'; import { FAVORITE_TIMELINE, LOCKED_ICON, - NOTES, NOTES_TAB_BUTTON, // NOTES_COUNT, NOTES_TEXT_AREA, + NOTE_BY_NOTE_ID, PIN_EVENT, TIMELINE_DESCRIPTION, + TIMELINE_FILTER, // TIMELINE_FILTER, TIMELINE_QUERY, TIMELINE_TITLE, @@ -24,7 +25,7 @@ import { TIMELINES_NOTES_COUNT, TIMELINES_FAVORITE, } from '../screens/timelines'; -import { deleteTimeline } from '../tasks/api_calls/timelines'; +import { deleteTimeline, getTimelineById } from '../tasks/api_calls/timelines'; import { loginAndWaitForPage } from '../tasks/login'; import { openTimelineUsingToggle } from '../tasks/security_main'; @@ -46,11 +47,11 @@ import { openTimeline } from '../tasks/timelines'; import { OVERVIEW_URL } from '../urls/navigation'; // FLAKY: https://github.com/elastic/kibana/issues/79389 -describe.skip('Timelines', () => { +describe('Timelines', () => { let timelineId: string; after(() => { - deleteTimeline(timelineId); + if (timelineId) deleteTimeline(timelineId); }); it('Creates a timeline', () => { @@ -90,13 +91,16 @@ describe.skip('Timelines', () => { cy.get(TIMELINE_TITLE).should('have.text', timeline.title); cy.get(TIMELINE_DESCRIPTION).should('have.text', timeline.description); cy.get(TIMELINE_QUERY).should('have.text', `${timeline.query} `); - // Comments this assertion until we agreed what to do with the filters. - // cy.get(TIMELINE_FILTER(timeline.filter)).should('exist'); - // cy.get(NOTES_COUNT).should('have.text', '1'); + cy.get(TIMELINE_FILTER(timeline.filter)).should('exist'); cy.get(PIN_EVENT).should('have.attr', 'aria-label', 'Pinned event'); cy.get(NOTES_TAB_BUTTON).click(); cy.get(NOTES_TEXT_AREA).should('exist'); - cy.get(NOTES).should('have.text', timeline.notes); + + getTimelineById(timelineId).then((singleTimeline) => { + const noteId = singleTimeline!.body.data.getOneTimeline.notes[0].noteId; + + cy.get(`${NOTE_BY_NOTE_ID(noteId)} p`).should('have.text', timeline.notes); + }); }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/objects/case.ts b/x-pack/plugins/security_solution/cypress/objects/case.ts index 64d331dc66e38..b7a2b21b63402 100644 --- a/x-pack/plugins/security_solution/cypress/objects/case.ts +++ b/x-pack/plugins/security_solution/cypress/objects/case.ts @@ -83,14 +83,6 @@ export const mockConnectorsResponse = [ actionTypeId: '.jira', name: 'Jira', config: { - incidentConfiguration: { - mapping: [ - { source: 'title', target: 'summary', actionType: 'overwrite' }, - { source: 'description', target: 'description', actionType: 'overwrite' }, - { source: 'comments', target: 'comments', actionType: 'append' }, - ], - }, - isCaseOwned: true, apiUrl: 'https://siem-kibana.atlassian.net', projectKey: 'RJ', }, @@ -102,14 +94,6 @@ export const mockConnectorsResponse = [ actionTypeId: '.resilient', name: 'Resilient', config: { - incidentConfiguration: { - mapping: [ - { source: 'title', target: 'name', actionType: 'overwrite' }, - { source: 'description', target: 'description', actionType: 'overwrite' }, - { source: 'comments', target: 'comments', actionType: 'append' }, - ], - }, - isCaseOwned: true, apiUrl: 'https://ibm-resilient.siem.estc.dev', orgId: '201', }, @@ -121,14 +105,6 @@ export const mockConnectorsResponse = [ actionTypeId: '.servicenow', name: 'ServiceNow', config: { - incidentConfiguration: { - mapping: [ - { source: 'title', target: 'short_description', actionType: 'overwrite' }, - { source: 'description', target: 'description', actionType: 'overwrite' }, - { source: 'comments', target: 'comments', actionType: 'append' }, - ], - }, - isCaseOwned: true, apiUrl: 'https://dev65287.service-now.com', }, isPreconfigured: false, diff --git a/x-pack/plugins/security_solution/cypress/objects/timeline.ts b/x-pack/plugins/security_solution/cypress/objects/timeline.ts index 1cfb0afd445bd..61c98f6186038 100644 --- a/x-pack/plugins/security_solution/cypress/objects/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/objects/timeline.ts @@ -25,6 +25,7 @@ export interface TimelineFilter { export const filter: TimelineFilter = { field: 'host.name', operator: 'exists', + value: 'exists', }; export const timeline: CompleteTimeline = { diff --git a/x-pack/plugins/security_solution/cypress/screens/configure_cases.ts b/x-pack/plugins/security_solution/cypress/screens/configure_cases.ts index 006c524a38acb..72dd78363b6d0 100644 --- a/x-pack/plugins/security_solution/cypress/screens/configure_cases.ts +++ b/x-pack/plugins/security_solution/cypress/screens/configure_cases.ts @@ -28,3 +28,5 @@ export const TOASTER = '[data-test-subj="euiToastHeader"]'; export const URL = '[data-test-subj="apiUrlFromInput"]'; export const USERNAME = '[data-test-subj="connector-servicenow-username-form-input"]'; + +export const SERVICE_NOW_MAPPING = 'code[data-test-subj="field-mapping-target"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/timeline.ts b/x-pack/plugins/security_solution/cypress/screens/timeline.ts index 0f5e8c133f0d0..4972cba937584 100644 --- a/x-pack/plugins/security_solution/cypress/screens/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/screens/timeline.ts @@ -53,6 +53,8 @@ export const LOCKED_ICON = '[data-test-subj="timeline-date-picker-lock-button"]' export const NOTES = '[data-test-subj="note-card-body"]'; +export const NOTE_BY_NOTE_ID = (noteId: string) => `[data-test-subj="note-preview-${noteId}"]`; + export const NOTES_TEXT_AREA = '[data-test-subj="add-a-note"] textarea'; export const NOTES_TAB_BUTTON = '[data-test-subj="timelineTabs-notes"]'; @@ -103,7 +105,8 @@ export const TIMELINE_FIELDS_BUTTON = '[data-test-subj="timeline"] [data-test-subj="show-field-browser"]'; export const TIMELINE_FILTER = (filter: TimelineFilter) => { - return `[data-test-subj="filter filter-enabled filter-key-${filter.field} filter-value-${filter.value} filter-unpinned"]`; + // The space at the end of the line is required. We want to keep it until it is updated. + return `[data-test-subj="filter filter-enabled filter-key-${filter.field} filter-value-${filter.value} filter-unpinned "]`; }; export const TIMELINE_FILTER_FIELD = '[data-test-subj="filterFieldSuggestionList"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/api_calls/timelines.ts b/x-pack/plugins/security_solution/cypress/tasks/api_calls/timelines.ts index f7d1a1967e197..32c2af1a1866b 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/api_calls/timelines.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/api_calls/timelines.ts @@ -108,3 +108,18 @@ export const deleteTimeline = (timelineId: string) => { headers: { 'kbn-xsrf': 'delete-signals' }, }); }; + +export const getTimelineById = (timelineId: string) => + cy.request({ + method: 'POST', + url: 'api/solutions/security/graphql', + body: { + operationName: 'GetOneTimeline', + variables: { + id: timelineId, + }, + query: + 'query GetOneTimeline($id: ID!, $timelineType: TimelineType) {\n getOneTimeline(id: $id, timelineType: $timelineType) {\n savedObjectId\n columns {\n aggregatable\n category\n columnHeaderType\n description\n example\n indexes\n id\n name\n searchable\n type\n __typename\n }\n dataProviders {\n id\n name\n enabled\n excluded\n kqlQuery\n type\n queryMatch {\n field\n displayField\n value\n displayValue\n operator\n __typename\n }\n and {\n id\n name\n enabled\n excluded\n kqlQuery\n type\n queryMatch {\n field\n displayField\n value\n displayValue\n operator\n __typename\n }\n __typename\n }\n __typename\n }\n dateRange {\n start\n end\n __typename\n }\n description\n eventType\n eventIdToNoteIds {\n eventId\n note\n timelineId\n noteId\n created\n createdBy\n timelineVersion\n updated\n updatedBy\n version\n __typename\n }\n excludedRowRendererIds\n favorite {\n fullName\n userName\n favoriteDate\n __typename\n }\n filters {\n meta {\n alias\n controlledBy\n disabled\n field\n formattedValue\n index\n key\n negate\n params\n type\n value\n __typename\n }\n query\n exists\n match_all\n missing\n range\n script\n __typename\n }\n kqlMode\n kqlQuery {\n filterQuery {\n kuery {\n kind\n expression\n __typename\n }\n serializedQuery\n __typename\n }\n __typename\n }\n indexNames\n notes {\n eventId\n note\n timelineId\n timelineVersion\n noteId\n created\n createdBy\n updated\n updatedBy\n version\n __typename\n }\n noteIds\n pinnedEventIds\n pinnedEventsSaveObject {\n pinnedEventId\n eventId\n timelineId\n created\n createdBy\n updated\n updatedBy\n version\n __typename\n }\n status\n title\n timelineType\n templateTimelineId\n templateTimelineVersion\n savedQueryId\n sort\n created\n createdBy\n updated\n updatedBy\n version\n __typename\n }\n}\n', + }, + headers: { 'kbn-xsrf': '' }, + }); diff --git a/x-pack/plugins/security_solution/cypress/tasks/timelines.ts b/x-pack/plugins/security_solution/cypress/tasks/timelines.ts index 1c5ce246a35b3..a04ecb1f9ccaa 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/timelines.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/timelines.ts @@ -19,7 +19,7 @@ export const exportTimeline = (timelineId: string) => { }; export const openTimeline = (id: string) => { - cy.get(TIMELINE(id)).click(); + cy.get(TIMELINE(id), { timeout: 500 }).click(); }; export const waitForTimelinesPanelToBeLoaded = () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx index f3b0b2b840ab4..b1b5f2b087eee 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx @@ -8,9 +8,8 @@ import { ActionConnector } from '../../../containers/configure/types'; import { UseConnectorsResponse } from '../../../containers/configure/use_connectors'; import { connectorsMock } from '../../../containers/configure/mock'; import { ReturnUseCaseConfigure } from '../../../containers/configure/use_configure'; -export { mapping } from '../../../containers/configure/mock'; import { ConnectorTypes } from '../../../../../../case/common/api'; - +export { mappings } from '../../../containers/configure/mock'; export const connectors: ActionConnector[] = connectorsMock; // x - pack / plugins / triggers_actions_ui; @@ -36,14 +35,14 @@ export const useCaseConfigureResponse: ReturnUseCaseConfigure = { }, firstLoad: false, loading: false, - mapping: null, + mappings: [], persistCaseConfigure: jest.fn(), persistLoading: false, refetchCaseConfigure: jest.fn(), setClosureType: jest.fn(), setConnector: jest.fn(), setCurrentConfiguration: jest.fn(), - setMapping: jest.fn(), + setMappings: jest.fn(), version: '', }; diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.test.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.test.tsx index 5272d13043fc4..149775215df60 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.test.tsx @@ -11,6 +11,7 @@ import { Connectors, Props } from './connectors'; import { TestProviders } from '../../../common/mock'; import { ConnectorsDropdown } from './connectors_dropdown'; import { connectors } from './__mock__'; +import { ConnectorTypes } from '../../../../../case/common/api/connectors'; describe('Connectors', () => { let wrapper: ReactWrapper; @@ -18,13 +19,14 @@ describe('Connectors', () => { const handleShowEditFlyout = jest.fn(); const props: Props = { - disabled: false, - updateConnectorDisabled: false, connectors, - selectedConnector: 'none', + disabled: false, + handleShowEditFlyout, isLoading: false, + mappings: [], onChangeConnector, - handleShowEditFlyout, + selectedConnector: { id: 'none', type: ConnectorTypes.none }, + updateConnectorDisabled: false, }; beforeAll(() => { @@ -66,9 +68,15 @@ describe('Connectors', () => { test('the connector is changed successfully to none', () => { onChangeConnector.mockClear(); - const newWrapper = mount(, { - wrappingComponent: TestProviders, - }); + const newWrapper = mount( + , + { + wrappingComponent: TestProviders, + } + ); newWrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); newWrapper.find('button[data-test-subj="dropdown-connector-no-connector"]').simulate('click'); @@ -87,9 +95,15 @@ describe('Connectors', () => { }); test('the text of the update button is shown correctly', () => { - const newWrapper = mount(, { - wrappingComponent: TestProviders, - }); + const newWrapper = mount( + , + { + wrappingComponent: TestProviders, + } + ); expect( newWrapper diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.tsx index fd7510a1c4713..f937796496fc7 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.tsx @@ -18,7 +18,9 @@ import styled from 'styled-components'; import { ConnectorsDropdown } from './connectors_dropdown'; import * as i18n from './translations'; -import { ActionConnector } from '../../containers/configure/types'; +import { ActionConnector, CaseConnectorMapping } from '../../containers/configure/types'; +import { Mapping } from './mapping'; +import { ConnectorTypes } from '../../../../../case/common/api/connectors'; const EuiFormRowExtended = styled(EuiFormRow)` .euiFormRow__labelWrapper { @@ -31,24 +33,26 @@ const EuiFormRowExtended = styled(EuiFormRow)` export interface Props { connectors: ActionConnector[]; disabled: boolean; + handleShowEditFlyout: () => void; isLoading: boolean; - updateConnectorDisabled: boolean; + mappings: CaseConnectorMapping[]; onChangeConnector: (id: string) => void; - selectedConnector: string; - handleShowEditFlyout: () => void; + selectedConnector: { id: string; type: string }; + updateConnectorDisabled: boolean; } const ConnectorsComponent: React.FC = ({ connectors, - isLoading, disabled, - updateConnectorDisabled, + handleShowEditFlyout, + isLoading, + mappings, onChangeConnector, selectedConnector, - handleShowEditFlyout, + updateConnectorDisabled, }) => { const connectorsName = useMemo( - () => connectors.find((c) => c.id === selectedConnector)?.name ?? 'none', - [connectors, selectedConnector] + () => connectors.find((c) => c.id === selectedConnector.id)?.name ?? 'none', + [connectors, selectedConnector.id] ); const dropDownLabel = useMemo( @@ -68,10 +72,8 @@ const ConnectorsComponent: React.FC = ({ ), - // eslint-disable-next-line react-hooks/exhaustive-deps - [connectorsName, updateConnectorDisabled] + [connectorsName, handleShowEditFlyout, updateConnectorDisabled] ); - return ( <> = ({ label={dropDownLabel} data-test-subj="case-connectors-form-row" > - + + + + + {selectedConnector.type !== ConnectorTypes.none ? ( + + + + ) : null} + diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/field_mapping.test.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/field_mapping.test.tsx index e79c031c7002c..937946fa14253 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/field_mapping.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/field_mapping.test.tsx @@ -7,77 +7,48 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; -import { connectorsConfiguration, createDefaultMapping } from '../connectors'; - import { FieldMapping, FieldMappingProps } from './field_mapping'; -import { mapping } from './__mock__'; -import { FieldMappingRow } from './field_mapping_row'; +import { mappings } from './__mock__'; import { TestProviders } from '../../../common/mock'; +import { FieldMappingRowStatic } from './field_mapping_row_static'; describe('FieldMappingRow', () => { let wrapper: ReactWrapper; - const onChangeMapping = jest.fn(); const props: FieldMappingProps = { - disabled: false, - mapping, - onChangeMapping, + isLoading: false, + mappings, connectorActionTypeId: '.servicenow', }; beforeAll(() => { wrapper = mount(, { wrappingComponent: TestProviders }); }); - test('it renders', () => { expect( - wrapper.find('[data-test-subj="case-configure-field-mapping-cols"]').first().exists() - ).toBe(true); - - expect( - wrapper.find('[data-test-subj="case-configure-field-mapping-row-wrapper"]').first().exists() + wrapper.find('[data-test-subj="case-configure-field-mappings-row-wrapper"]').first().exists() ).toBe(true); - expect(wrapper.find(FieldMappingRow).length).toEqual(3); + expect(wrapper.find(FieldMappingRowStatic).length).toEqual(3); }); - test('it shows the correct number of FieldMappingRow with default mapping', () => { - const newWrapper = mount(, { + test('it does not render without mappings', () => { + const newWrapper = mount(, { wrappingComponent: TestProviders, }); - - expect(newWrapper.find(FieldMappingRow).length).toEqual(3); + expect( + newWrapper + .find('[data-test-subj="case-configure-field-mappings-row-wrapper"]') + .first() + .exists() + ).toBe(false); }); test('it pass the corrects props to mapping row', () => { - const rows = wrapper.find(FieldMappingRow); - rows.forEach((row, index) => { - expect(row.prop('securitySolutionField')).toEqual(mapping[index].source); - expect(row.prop('selectedActionType')).toEqual(mapping[index].actionType); - expect(row.prop('selectedThirdParty')).toEqual(mapping[index].target); - }); - }); - - test('it pass the default mapping when mapping is null', () => { - const newWrapper = mount(, { - wrappingComponent: TestProviders, - }); - - const selectedConnector = connectorsConfiguration['.servicenow']; - const defaultMapping = createDefaultMapping(selectedConnector.fields); - - const rows = newWrapper.find(FieldMappingRow); + const rows = wrapper.find(FieldMappingRowStatic); rows.forEach((row, index) => { - expect(row.prop('securitySolutionField')).toEqual(defaultMapping[index].source); - expect(row.prop('selectedActionType')).toEqual(defaultMapping[index].actionType); - expect(row.prop('selectedThirdParty')).toEqual(defaultMapping[index].target); + expect(row.prop('securitySolutionField')).toEqual(mappings[index].source); + expect(row.prop('selectedActionType')).toEqual(mappings[index].actionType); + expect(row.prop('selectedThirdParty')).toEqual(mappings[index].target); }); }); - - test('it should show zero rows on empty array', () => { - const newWrapper = mount(, { - wrappingComponent: TestProviders, - }); - - expect(newWrapper.find(FieldMappingRow).length).toEqual(0); - }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/field_mapping.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/field_mapping.tsx index b15c82f254aea..e930c00b8e173 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/field_mapping.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/field_mapping.tsx @@ -4,148 +4,69 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useMemo } from 'react'; -import { EuiFormRow, EuiFlexItem, EuiFlexGroup, EuiSuperSelectOption } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; import styled from 'styled-components'; -import { - CasesConfigurationMapping, - CaseField, - ActionType, - ThirdPartyField, -} from '../../containers/configure/types'; -import { - ThirdPartyField as ConnectorConfigurationThirdPartyField, - AllThirdPartyFields, - createDefaultMapping, - connectorsConfiguration, -} from '../connectors'; - -import { FieldMappingRow } from './field_mapping_row'; +import { FieldMappingRowStatic } from './field_mapping_row_static'; import * as i18n from './translations'; -import { setActionTypeToMapping, setThirdPartyToMapping } from './utils'; + +import { CaseConnectorMapping } from '../../containers/configure/types'; +import { connectorsConfiguration } from '../connectors'; const FieldRowWrapper = styled.div` - margin-top: 8px; + margin: 10px 0; font-size: 14px; `; -const actionTypeOptions: Array> = [ - { - value: 'nothing', - inputDisplay: <>{i18n.FIELD_MAPPING_EDIT_NOTHING}, - 'data-test-subj': 'edit-update-option-nothing', - }, - { - value: 'overwrite', - inputDisplay: <>{i18n.FIELD_MAPPING_EDIT_OVERWRITE}, - 'data-test-subj': 'edit-update-option-overwrite', - }, - { - value: 'append', - inputDisplay: <>{i18n.FIELD_MAPPING_EDIT_APPEND}, - 'data-test-subj': 'edit-update-option-append', - }, -]; - -const getThirdPartyOptions = ( - caseField: CaseField, - thirdPartyFields: Record -): Array> => - (Object.keys(thirdPartyFields) as AllThirdPartyFields[]).reduce< - Array> - >( - (acc, key) => { - if (thirdPartyFields[key].validSourceFields.includes(caseField)) { - return [ - ...acc, - { - value: key, - inputDisplay: {thirdPartyFields[key].label}, - 'data-test-subj': `dropdown-mapping-${key}`, - }, - ]; - } - return acc; - }, - [ - { - value: 'not_mapped', - inputDisplay: i18n.MAPPING_FIELD_NOT_MAPPED, - 'data-test-subj': 'dropdown-mapping-not_mapped', - }, - ] - ); - export interface FieldMappingProps { - disabled: boolean; - mapping: CasesConfigurationMapping[] | null; connectorActionTypeId: string; - onChangeMapping: (newMapping: CasesConfigurationMapping[]) => void; + isLoading: boolean; + mappings: CaseConnectorMapping[]; } const FieldMappingComponent: React.FC = ({ - disabled, - mapping, - onChangeMapping, connectorActionTypeId, + isLoading, + mappings, }) => { - const onChangeActionType = useCallback( - (caseField: CaseField, newActionType: ActionType) => { - const myMapping = mapping ?? defaultMapping; - onChangeMapping(setActionTypeToMapping(caseField, newActionType, myMapping)); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [mapping] + const selectedConnector = useMemo( + () => connectorsConfiguration[connectorActionTypeId] ?? { fields: {} }, + [connectorActionTypeId] ); - - const onChangeThirdParty = useCallback( - (caseField: CaseField, newThirdPartyField: ThirdPartyField) => { - const myMapping = mapping ?? defaultMapping; - onChangeMapping(setThirdPartyToMapping(caseField, newThirdPartyField, myMapping)); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [mapping] - ); - - const selectedConnector = connectorsConfiguration[connectorActionTypeId] ?? { fields: {} }; - const defaultMapping = useMemo(() => createDefaultMapping(selectedConnector.fields), [ - selectedConnector.fields, - ]); - - return ( - <> - + return mappings.length ? ( + + + {' '} {i18n.FIELD_MAPPING_FIRST_COL} - {i18n.FIELD_MAPPING_SECOND_COL} + + {i18n.FIELD_MAPPING_SECOND_COL(selectedConnector.name)} + {i18n.FIELD_MAPPING_THIRD_COL} - - - {(mapping ?? defaultMapping).map((item) => ( - - ))} - - - ); + + + + {mappings.map((item) => ( + + ))} + + + + ) : null; }; export const FieldMapping = React.memo(FieldMappingComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/field_mapping_row.test.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/field_mapping_row.test.tsx deleted file mode 100644 index a2acd0e20b6ad..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/field_mapping_row.test.tsx +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { mount, ReactWrapper } from 'enzyme'; -import { EuiSuperSelectOption, EuiSuperSelect } from '@elastic/eui'; - -import { FieldMappingRow, RowProps } from './field_mapping_row'; -import { TestProviders } from '../../../common/mock'; -import { ThirdPartyField, ActionType } from '../../containers/configure/types'; - -const thirdPartyOptions: Array> = [ - { - value: 'short_description', - inputDisplay: {'Short Description'}, - 'data-test-subj': 'third-party-short-desc', - }, - { - value: 'description', - inputDisplay: {'Description'}, - 'data-test-subj': 'third-party-desc', - }, -]; - -const actionTypeOptions: Array> = [ - { - value: 'nothing', - inputDisplay: <>{'Nothing'}, - 'data-test-subj': 'edit-update-option-nothing', - }, - { - value: 'overwrite', - inputDisplay: <>{'Overwrite'}, - 'data-test-subj': 'edit-update-option-overwrite', - }, - { - value: 'append', - inputDisplay: <>{'Append'}, - 'data-test-subj': 'edit-update-option-append', - }, -]; - -describe('FieldMappingRow', () => { - let wrapper: ReactWrapper; - const onChangeActionType = jest.fn(); - const onChangeThirdParty = jest.fn(); - - const props: RowProps = { - id: 'title', - disabled: false, - securitySolutionField: 'title', - thirdPartyOptions, - actionTypeOptions, - onChangeActionType, - onChangeThirdParty, - selectedActionType: 'nothing', - selectedThirdParty: 'short_description', - }; - - beforeAll(() => { - wrapper = mount(, { wrappingComponent: TestProviders }); - }); - - test('it renders', () => { - expect( - wrapper.find('[data-test-subj="case-configure-third-party-select-title"]').first().exists() - ).toBe(true); - - expect( - wrapper.find('[data-test-subj="case-configure-action-type-select-title"]').first().exists() - ).toBe(true); - }); - - test('it passes thirdPartyOptions correctly', () => { - const selectProps = wrapper.find(EuiSuperSelect).first().props(); - - expect(selectProps.options).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - value: 'short_description', - 'data-test-subj': 'third-party-short-desc', - }), - expect.objectContaining({ - value: 'description', - 'data-test-subj': 'third-party-desc', - }), - ]) - ); - }); - - test('it passes the correct actionTypeOptions', () => { - const selectProps = wrapper.find(EuiSuperSelect).at(1).props(); - - expect(selectProps.options).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - value: 'nothing', - 'data-test-subj': 'edit-update-option-nothing', - }), - expect.objectContaining({ - value: 'overwrite', - 'data-test-subj': 'edit-update-option-overwrite', - }), - expect.objectContaining({ - value: 'append', - 'data-test-subj': 'edit-update-option-append', - }), - ]) - ); - }); -}); diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/field_mapping_row.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/field_mapping_row.tsx deleted file mode 100644 index b924cad1475a8..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/field_mapping_row.tsx +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { useMemo } from 'react'; -import { - EuiFlexItem, - EuiFlexGroup, - EuiSuperSelect, - EuiIcon, - EuiSuperSelectOption, -} from '@elastic/eui'; - -import { capitalize } from 'lodash/fp'; -import { CaseField, ActionType, ThirdPartyField } from '../../containers/configure/types'; -import { AllThirdPartyFields } from '../connectors'; - -export interface RowProps { - id: string; - disabled: boolean; - securitySolutionField: CaseField; - thirdPartyOptions: Array>; - actionTypeOptions: Array>; - onChangeActionType: (caseField: CaseField, newActionType: ActionType) => void; - onChangeThirdParty: (caseField: CaseField, newThirdPartyField: ThirdPartyField) => void; - selectedActionType: ActionType; - selectedThirdParty: ThirdPartyField; -} - -const FieldMappingRowComponent: React.FC = ({ - id, - disabled, - securitySolutionField, - thirdPartyOptions, - actionTypeOptions, - onChangeActionType, - onChangeThirdParty, - selectedActionType, - selectedThirdParty, -}) => { - const securitySolutionFieldCapitalized = useMemo(() => capitalize(securitySolutionField), [ - securitySolutionField, - ]); - return ( - - - - - {securitySolutionFieldCapitalized} - - - - - - - - - - - - - - ); -}; - -export const FieldMappingRow = React.memo(FieldMappingRowComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/field_mapping_row_static.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/field_mapping_row_static.tsx new file mode 100644 index 0000000000000..e68ee3d69a7db --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/field_mapping_row_static.tsx @@ -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 React, { useMemo } from 'react'; +import { EuiCode, EuiFlexItem, EuiFlexGroup, EuiIcon, EuiLoadingSpinner } from '@elastic/eui'; + +import { capitalize } from 'lodash/fp'; +import { CaseField, ActionType, ThirdPartyField } from '../../containers/configure/types'; + +export interface RowProps { + isLoading: boolean; + securitySolutionField: CaseField; + selectedActionType: ActionType; + selectedThirdParty: ThirdPartyField; +} + +const FieldMappingRowComponent: React.FC = ({ + isLoading, + securitySolutionField, + selectedActionType, + selectedThirdParty, +}) => { + const selectedActionTypeCapitalized = useMemo(() => capitalize(selectedActionType), [ + selectedActionType, + ]); + return ( + + + + + {securitySolutionField} + + + + + + + + + + {isLoading ? ( + + ) : ( + {selectedThirdParty} + )} + + + + + {isLoading ? : selectedActionTypeCapitalized} + + + ); +}; + +export const FieldMappingRowStatic = React.memo(FieldMappingRowComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx index 5150a907ae712..2656e2496c2fc 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx @@ -169,7 +169,7 @@ describe('ConfigureCases', () => { beforeEach(() => { useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - mapping: connectors[0].config.incidentConfiguration.mapping, + mappings: [], closureType: 'close-by-user', connector: { id: 'servicenow-1', @@ -198,7 +198,7 @@ describe('ConfigureCases', () => { expect(wrapper.find(Connectors).prop('connectors')).toEqual(connectors); expect(wrapper.find(Connectors).prop('disabled')).toBe(false); expect(wrapper.find(Connectors).prop('isLoading')).toBe(false); - expect(wrapper.find(Connectors).prop('selectedConnector')).toBe('servicenow-1'); + expect(wrapper.find(Connectors).prop('selectedConnector').id).toBe('servicenow-1'); // ClosureOptions expect(wrapper.find(ClosureOptions).prop('disabled')).toBe(false); @@ -247,7 +247,7 @@ describe('ConfigureCases', () => { beforeEach(() => { useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - mapping: connectors[1].config.incidentConfiguration.mapping, + mapping: null, closureType: 'close-by-user', connector: { id: 'resilient-2', @@ -374,7 +374,7 @@ describe('ConfigureCases', () => { persistCaseConfigure = jest.fn(); useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - mapping: connectors[0].config.incidentConfiguration.mapping, + mapping: null, closureType: 'close-by-user', connector: { id: 'resilient-2', @@ -462,7 +462,7 @@ describe('closure options', () => { persistCaseConfigure = jest.fn(); useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - mapping: connectors[0].config.incidentConfiguration.mapping, + mapping: null, closureType: 'close-by-user', connector: { id: 'servicenow-1', @@ -508,7 +508,7 @@ describe('user interactions', () => { beforeEach(() => { useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - mapping: connectors[1].config.incidentConfiguration.mapping, + mapping: null, closureType: 'close-by-user', connector: { id: 'resilient-2', diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx index e2dd405b7804d..6176b679c3a03 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx @@ -69,6 +69,7 @@ const ConfigureCasesComponent: React.FC = ({ userC connector, closureType, loading: loadingCaseConfigure, + mappings, persistLoading, persistCaseConfigure, setConnector, @@ -83,7 +84,6 @@ const ConfigureCasesComponent: React.FC = ({ userC const reloadConnectors = useCallback(async () => refetchConnectors(), []); const isLoadingAny = isLoadingConnectors || persistLoading || loadingCaseConfigure; const updateConnectorDisabled = isLoadingAny || !connectorIsValid || connector.id === 'none'; - const onClickUpdateConnector = useCallback(() => { setEditFlyoutVisibility(true); }, []); @@ -201,11 +201,12 @@ const ConfigureCasesComponent: React.FC = ({ userC {addFlyoutVisible && ConnectorAddFlyout} diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/mapping.test.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/mapping.test.tsx index 0bbee42a4f2ef..5c31f4256f888 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/mapping.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/mapping.test.tsx @@ -9,19 +9,15 @@ import { mount, ReactWrapper } from 'enzyme'; import { TestProviders } from '../../../common/mock'; import { Mapping, MappingProps } from './mapping'; -import { mapping } from './__mock__'; +import { mappings } from './__mock__'; describe('Mapping', () => { let wrapper: ReactWrapper; - const onChangeMapping = jest.fn(); const setEditFlyoutVisibility = jest.fn(); const props: MappingProps = { - disabled: false, - mapping, - updateConnectorDisabled: false, - onChangeMapping, - setEditFlyoutVisibility, connectorActionTypeId: '.servicenow', + isLoading: false, + mappings, }; beforeEach(() => { @@ -32,186 +28,33 @@ describe('Mapping', () => { afterEach(() => { wrapper.unmount(); }); - describe('Common', () => { test('it shows mapping form group', () => { - expect(wrapper.find('[data-test-subj="case-mapping-form-group"]').first().exists()).toBe( - true - ); + expect(wrapper.find('[data-test-subj="static-mappings"]').first().exists()).toBe(true); }); - test('it shows mapping form row', () => { - expect(wrapper.find('[data-test-subj="case-mapping-form-row"]').first().exists()).toBe(true); + test('correctly maps fields', () => { + expect(wrapper.find('[data-test-subj="field-mapping-source"] code').first().text()).toBe( + 'title' + ); + expect(wrapper.find('[data-test-subj="field-mapping-target"] code').first().text()).toBe( + 'short_description' + ); }); - - test('it shows the update button', () => { + // skipping until next PR + test.skip('it shows the update button', () => { expect( - wrapper.find('[data-test-subj="case-mapping-update-connector-button"]').first().exists() + wrapper.find('[data-test-subj="case-mappings-update-connector-button"]').first().exists() ).toBe(true); }); - test('it shows the field mapping', () => { - expect(wrapper.find('[data-test-subj="case-mapping-field"]').first().exists()).toBe(true); - }); - - test('it updates thirdParty correctly', () => { - wrapper - .find('button[data-test-subj="case-configure-third-party-select-title"]') - .simulate('click'); - wrapper.update(); - wrapper.find('button[data-test-subj="dropdown-mapping-description"]').simulate('click'); - wrapper.update(); - - expect(onChangeMapping).toHaveBeenCalledWith([ - { source: 'title', target: 'description', actionType: 'overwrite' }, - { source: 'description', target: 'not_mapped', actionType: 'append' }, - { source: 'comments', target: 'comments', actionType: 'append' }, - ]); - }); - - test('it updates actionType correctly', () => { + test.skip('it triggers update flyout', () => { + expect(setEditFlyoutVisibility).not.toHaveBeenCalled(); wrapper - .find('button[data-test-subj="case-configure-action-type-select-title"]') + .find('button[data-test-subj="case-mappings-update-connector-button"]') + .first() .simulate('click'); - wrapper.update(); - wrapper.find('button[data-test-subj="edit-update-option-nothing"]').simulate('click'); - wrapper.update(); - - expect(onChangeMapping).toHaveBeenCalledWith([ - { source: 'title', target: 'short_description', actionType: 'nothing' }, - { source: 'description', target: 'description', actionType: 'append' }, - { source: 'comments', target: 'comments', actionType: 'append' }, - ]); - }); - - test('it shows the correct action types', () => { - wrapper - .find('button[data-test-subj="case-configure-action-type-select-title"]') - .simulate('click'); - wrapper.update(); - expect( - wrapper.find('button[data-test-subj="edit-update-option-nothing"]').first().exists() - ).toBeTruthy(); - expect( - wrapper.find('button[data-test-subj="edit-update-option-overwrite"]').first().exists() - ).toBeTruthy(); - expect( - wrapper.find('button[data-test-subj="edit-update-option-append"]').first().exists() - ).toBeTruthy(); - }); - }); - - describe('Connectors', () => { - describe('ServiceNow', () => { - test('it shows the correct thirdParty fields for title', () => { - wrapper - .find('button[data-test-subj="case-configure-third-party-select-title"]') - .simulate('click'); - wrapper.update(); - - expect( - wrapper - .find('button[data-test-subj="dropdown-mapping-short_description"]') - .first() - .exists() - ).toBeTruthy(); - expect( - wrapper.find('button[data-test-subj="dropdown-mapping-description"]').first().exists() - ).toBeTruthy(); - expect( - wrapper.find('button[data-test-subj="dropdown-mapping-not_mapped"]').first().exists() - ).toBeTruthy(); - }); - - test('it shows the correct thirdParty fields for description', () => { - wrapper - .find('button[data-test-subj="case-configure-third-party-select-description"]') - .simulate('click'); - wrapper.update(); - - expect( - wrapper - .find('button[data-test-subj="dropdown-mapping-short_description"]') - .first() - .exists() - ).toBeTruthy(); - expect( - wrapper.find('button[data-test-subj="dropdown-mapping-description"]').first().exists() - ).toBeTruthy(); - expect( - wrapper.find('button[data-test-subj="dropdown-mapping-not_mapped"]').first().exists() - ).toBeTruthy(); - }); - - test('it shows the correct thirdParty fields for comments', () => { - wrapper - .find('button[data-test-subj="case-configure-third-party-select-comments"]') - .simulate('click'); - wrapper.update(); - - expect( - wrapper.find('button[data-test-subj="dropdown-mapping-comments"]').first().exists() - ).toBeTruthy(); - expect( - wrapper.find('button[data-test-subj="dropdown-mapping-not_mapped"]').first().exists() - ).toBeTruthy(); - }); - }); - - describe('Jira', () => { - beforeEach(() => { - wrapper = mount(, { - wrappingComponent: TestProviders, - }); - }); - - test('it shows the correct thirdParty fields for title', () => { - wrapper - .find('button[data-test-subj="case-configure-third-party-select-title"]') - .simulate('click'); - wrapper.update(); - - expect( - wrapper.find('button[data-test-subj="dropdown-mapping-summary"]').first().exists() - ).toBeTruthy(); - expect( - wrapper.find('button[data-test-subj="dropdown-mapping-description"]').first().exists() - ).toBeTruthy(); - expect( - wrapper.find('button[data-test-subj="dropdown-mapping-not_mapped"]').first().exists() - ).toBeTruthy(); - }); - - test('it shows the correct thirdParty fields for description', () => { - wrapper - .find('button[data-test-subj="case-configure-third-party-select-description"]') - .simulate('click'); - wrapper.update(); - - expect( - wrapper.find('button[data-test-subj="dropdown-mapping-summary"]').first().exists() - ).toBeTruthy(); - expect( - wrapper.find('button[data-test-subj="dropdown-mapping-description"]').first().exists() - ).toBeTruthy(); - expect( - wrapper.find('button[data-test-subj="dropdown-mapping-not_mapped"]').first().exists() - ).toBeTruthy(); - }); - - test('it shows the correct thirdParty fields for comments', () => { - wrapper - .find('button[data-test-subj="case-configure-third-party-select-comments"]') - .simulate('click'); - wrapper.update(); - - expect( - wrapper.find('button[data-test-subj="dropdown-mapping-comments"]').first().exists() - ).toBeTruthy(); - expect( - wrapper.find('button[data-test-subj="dropdown-mapping-not_mapped"]').first().exists() - ).toBeTruthy(); - }); + expect(setEditFlyoutVisibility).toHaveBeenCalled(); }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/mapping.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/mapping.tsx index 2c3172a30f159..7d3456a3df819 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/mapping.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/mapping.tsx @@ -4,72 +4,49 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import styled from 'styled-components'; +import React, { useMemo } from 'react'; -import { - EuiDescribedFormGroup, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiButtonEmpty, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiTextColor } from '@elastic/eui'; import * as i18n from './translations'; import { FieldMapping } from './field_mapping'; -import { CasesConfigurationMapping } from '../../containers/configure/types'; +import { CaseConnectorMapping } from '../../containers/configure/types'; +import { connectorsConfiguration } from '../connectors'; export interface MappingProps { - disabled: boolean; - updateConnectorDisabled: boolean; - mapping: CasesConfigurationMapping[] | null; connectorActionTypeId: string; - onChangeMapping: (newMapping: CasesConfigurationMapping[]) => void; - setEditFlyoutVisibility: () => void; + isLoading: boolean; + mappings: CaseConnectorMapping[]; } -const EuiButtonEmptyExtended = styled(EuiButtonEmpty)` - font-size: 12px; - height: 24px; -`; - const MappingComponent: React.FC = ({ - disabled, - updateConnectorDisabled, - mapping, - onChangeMapping, - setEditFlyoutVisibility, connectorActionTypeId, + isLoading, + mappings, }) => { + const selectedConnector = useMemo(() => connectorsConfiguration[connectorActionTypeId], [ + connectorActionTypeId, + ]); return ( - {i18n.FIELD_MAPPING_TITLE}} - description={i18n.FIELD_MAPPING_DESC} - data-test-subj="case-mapping-form-group" - > - - - - - {i18n.UPDATE_CONNECTOR} - - - - - - + + + +

{i18n.FIELD_MAPPING_TITLE(selectedConnector.name)}

+ + {i18n.FIELD_MAPPING_DESC(selectedConnector.name)} + +
+
+ + + +
); }; diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/translations.ts b/x-pack/plugins/security_solution/public/cases/components/configure_cases/translations.ts index a6ae082c4721a..6586b23dde18c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/translations.ts @@ -80,21 +80,26 @@ export const CASE_CLOSURE_OPTIONS_CLOSED_INCIDENT = i18n.translate( defaultMessage: 'Automatically close Security cases when incident is closed in external system', } ); +export const FIELD_MAPPING_TITLE = (thirdPartyName: string): string => { + return i18n.translate('xpack.securitySolution.case.configureCases.fieldMappingTitle', { + values: { thirdPartyName }, + defaultMessage: '{ thirdPartyName } field mappings', + }); +}; -export const FIELD_MAPPING_TITLE = i18n.translate( - 'xpack.securitySolution.case.configureCases.fieldMappingTitle', - { - defaultMessage: 'Field mappings', - } -); - -export const FIELD_MAPPING_DESC = i18n.translate( - 'xpack.securitySolution.case.configureCases.fieldMappingDesc', - { +export const FIELD_MAPPING_DESC = (thirdPartyName: string): string => { + return i18n.translate('xpack.securitySolution.case.configureCases.fieldMappingDesc', { + values: { thirdPartyName }, defaultMessage: - 'Map Security case fields when pushing data to a third-party. Field mappings require an established connection to an external incident management system.', - } -); + 'Map Security Case fields to { thirdPartyName } fields when pushing data to { thirdPartyName }. Field mappings require an established connection to { thirdPartyName }.', + }); +}; +export const EDIT_FIELD_MAPPING_TITLE = (thirdPartyName: string): string => { + return i18n.translate('xpack.securitySolution.case.configureCases.editFieldMappingTitle', { + values: { thirdPartyName }, + defaultMessage: 'Edit { thirdPartyName } field mappings', + }); +}; export const FIELD_MAPPING_FIRST_COL = i18n.translate( 'xpack.securitySolution.case.configureCases.fieldMappingFirstCol', @@ -103,12 +108,12 @@ export const FIELD_MAPPING_FIRST_COL = i18n.translate( } ); -export const FIELD_MAPPING_SECOND_COL = i18n.translate( - 'xpack.securitySolution.case.configureCases.fieldMappingSecondCol', - { - defaultMessage: 'External incident field', - } -); +export const FIELD_MAPPING_SECOND_COL = (thirdPartyName: string): string => { + return i18n.translate('xpack.securitySolution.case.configureCases.fieldMappingSecondCol', { + values: { thirdPartyName }, + defaultMessage: '{ thirdPartyName } field', + }); +}; export const FIELD_MAPPING_THIRD_COL = i18n.translate( 'xpack.securitySolution.case.configureCases.fieldMappingThirdCol', @@ -142,6 +147,17 @@ export const CANCEL = i18n.translate('xpack.securitySolution.case.configureCases defaultMessage: 'Cancel', }); +export const SAVE = i18n.translate('xpack.securitySolution.case.configureCases.saveButton', { + defaultMessage: 'Save', +}); + +export const SAVE_CLOSE = i18n.translate( + 'xpack.securitySolution.case.configureCases.saveAndCloseButton', + { + defaultMessage: 'Save & Close', + } +); + export const WARNING_NO_CONNECTOR_TITLE = i18n.translate( 'xpack.securitySolution.case.configureCases.warningTitle', { @@ -164,10 +180,36 @@ export const MAPPING_FIELD_NOT_MAPPED = i18n.translate( } ); -export const UPDATE_CONNECTOR = i18n.translate( +export const COMMENT = i18n.translate('xpack.securitySolution.case.configureCases.commentMapping', { + defaultMessage: 'Comments', +}); + +export const NO_FIELDS_ERROR = (connectorName: string): string => { + return i18n.translate('xpack.securitySolution.case.configureCases.noFieldsError', { + values: { connectorName }, + defaultMessage: + 'No { connectorName } fields found. Please check your { connectorName } connector settings or your { connectorName } instance settings to resolve.', + }); +}; + +export const BLANK_MAPPINGS = (connectorName: string): string => { + return i18n.translate('xpack.securitySolution.case.configureCases.blankMappings', { + values: { connectorName }, + defaultMessage: 'At least one field needs to be mapped to { connectorName }', + }); +}; + +export const REQUIRED_MAPPINGS = (connectorName: string, fields: string): string => { + return i18n.translate('xpack.securitySolution.case.configureCases.requiredMappings', { + values: { connectorName, fields }, + defaultMessage: + 'At least one Case field needs to be mapped to the following required { connectorName } fields: { fields }', + }); +}; +export const UPDATE_FIELD_MAPPINGS = i18n.translate( 'xpack.securitySolution.case.configureCases.updateConnector', { - defaultMessage: 'Update connector', + defaultMessage: 'Update field mappings', } ); diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/utils.test.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/utils.test.tsx index d6755f687100f..5e9b86429e9c6 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/utils.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/utils.test.tsx @@ -4,35 +4,35 @@ * you may not use this file except in compliance with the Elastic License. */ -import { mapping } from './__mock__'; +import { mappings } from './__mock__'; import { setActionTypeToMapping, setThirdPartyToMapping } from './utils'; -import { CasesConfigurationMapping } from '../../containers/configure/types'; +import { CaseConnectorMapping } from '../../containers/configure/types'; describe('FieldMappingRow', () => { test('it should change the action type', () => { - const newMapping = setActionTypeToMapping('title', 'nothing', mapping); + const newMapping = setActionTypeToMapping('title', 'nothing', mappings); expect(newMapping[0].actionType).toBe('nothing'); }); test('it should not change other fields', () => { - const [newTitle, description, comments] = setActionTypeToMapping('title', 'nothing', mapping); - expect(newTitle).not.toEqual(mapping[0]); - expect(description).toEqual(mapping[1]); - expect(comments).toEqual(mapping[2]); + const [newTitle, description, comments] = setActionTypeToMapping('title', 'nothing', mappings); + expect(newTitle).not.toEqual(mappings[0]); + expect(description).toEqual(mappings[1]); + expect(comments).toEqual(mappings[2]); }); test('it should return a new array when changing action type', () => { - const newMapping = setActionTypeToMapping('title', 'nothing', mapping); - expect(newMapping).not.toBe(mapping); + const newMapping = setActionTypeToMapping('title', 'nothing', mappings); + expect(newMapping).not.toBe(mappings); }); test('it should change the third party', () => { - const newMapping = setThirdPartyToMapping('title', 'description', mapping); + const newMapping = setThirdPartyToMapping('title', 'description', mappings); expect(newMapping[0].target).toBe('description'); }); test('it should not change other fields when there is not a conflict', () => { - const tempMapping: CasesConfigurationMapping[] = [ + const tempMapping: CaseConnectorMapping[] = [ { source: 'title', target: 'short_description', @@ -47,17 +47,17 @@ describe('FieldMappingRow', () => { const [newTitle, comments] = setThirdPartyToMapping('title', 'description', tempMapping); - expect(newTitle).not.toEqual(mapping[0]); + expect(newTitle).not.toEqual(mappings[0]); expect(comments).toEqual(tempMapping[1]); }); test('it should return a new array when changing third party', () => { - const newMapping = setThirdPartyToMapping('title', 'description', mapping); - expect(newMapping).not.toBe(mapping); + const newMapping = setThirdPartyToMapping('title', 'description', mappings); + expect(newMapping).not.toBe(mappings); }); test('it should change the target of the conflicting third party field to not_mapped', () => { - const newMapping = setThirdPartyToMapping('title', 'description', mapping); + const newMapping = setThirdPartyToMapping('title', 'description', mappings); expect(newMapping[1].target).toBe('not_mapped'); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/utils.ts b/x-pack/plugins/security_solution/public/cases/components/configure_cases/utils.ts index 11691b5dac332..cacfc30a5cdaf 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/utils.ts +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/utils.ts @@ -7,17 +7,17 @@ import { ConnectorTypeFields, ConnectorTypes } from '../../../../../case/common/ import { CaseField, ActionType, - CasesConfigurationMapping, ThirdPartyField, ActionConnector, CaseConnector, + CaseConnectorMapping, } from '../../containers/configure/types'; export const setActionTypeToMapping = ( caseField: CaseField, newActionType: ActionType, - mapping: CasesConfigurationMapping[] -): CasesConfigurationMapping[] => { + mapping: CaseConnectorMapping[] +): CaseConnectorMapping[] => { const findItemIndex = mapping.findIndex((item) => item.source === caseField); if (findItemIndex >= 0) { @@ -34,8 +34,8 @@ export const setActionTypeToMapping = ( export const setThirdPartyToMapping = ( caseField: CaseField, newThirdPartyField: ThirdPartyField, - mapping: CasesConfigurationMapping[] -): CasesConfigurationMapping[] => + mapping: CaseConnectorMapping[] +): CaseConnectorMapping[] => mapping.map((item) => { if (item.source !== caseField && item.target === newThirdPartyField) { return { ...item, target: 'not_mapped' }; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/index.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/index.ts index e77aa9bdd84b1..e08c0c9771366 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/index.ts +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/index.ts @@ -8,4 +8,3 @@ export { getActionType as getCaseConnectorUI } from './case'; export * from './config'; export * from './types'; -export * from './utils'; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/utils.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/utils.ts deleted file mode 100644 index 0a6dd37d9f9e2..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/utils.ts +++ /dev/null @@ -1,21 +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 { CasesConfigurationMapping } from '../../../cases/containers/configure/types'; - -import { ThirdPartyField } from './types'; - -export const createDefaultMapping = ( - fields: Record -): CasesConfigurationMapping[] => - Object.keys(fields).map( - (key) => - ({ - source: fields[key].defaultSourceField, - target: key, - actionType: fields[key].defaultActionType, - } as CasesConfigurationMapping) - ); diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/api.ts b/x-pack/plugins/security_solution/public/cases/components/settings/jira/api.ts index 5aaa3fc38b102..dbda7199f6ccf 100644 --- a/x-pack/plugins/security_solution/public/cases/components/settings/jira/api.ts +++ b/x-pack/plugins/security_solution/public/cases/components/settings/jira/api.ts @@ -5,7 +5,7 @@ */ import { HttpSetup } from 'kibana/public'; -import { ActionTypeExecutorResult } from '../../../../../../case/common/api'; +import { ActionTypeExecutorResult } from '../../../../../../actions/common'; import { IssueTypes, Fields, Issues, Issue } from './types'; export const BASE_ACTION_API_PATH = '/api/actions'; diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/api.ts b/x-pack/plugins/security_solution/public/cases/components/settings/resilient/api.ts index 961df85226ebf..57f26afe54e27 100644 --- a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/api.ts +++ b/x-pack/plugins/security_solution/public/cases/components/settings/resilient/api.ts @@ -5,7 +5,7 @@ */ import { HttpSetup } from 'kibana/public'; -import { ActionTypeExecutorResult } from '../../../../../../case/common/api'; +import { ActionTypeExecutorResult } from '../../../../../../actions/common'; import { ResilientIncidentTypes, ResilientSeverity } from './types'; export const BASE_ACTION_API_PATH = '/api/actions'; diff --git a/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx index bec1ab3dd4292..9c23081bac535 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx @@ -52,6 +52,7 @@ import { import { DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases'; import * as i18n from './translations'; +import { getCaseConfigurePushUrl } from '../../../../case/common/api/helpers'; const abortCtrl = new AbortController(); const mockKibanaServices = KibanaServices.get as jest.Mock; @@ -454,18 +455,24 @@ describe('Case Configuration API', () => { }); const connectorId = 'connectorId'; test('check url, method, signal', async () => { - await pushToService(connectorId, casePushParams, abortCtrl.signal); - expect(fetchMock).toHaveBeenCalledWith(`/api/actions/action/${connectorId}/_execute`, { + await pushToService(connectorId, ConnectorTypes.jira, casePushParams, abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith(`${getCaseConfigurePushUrl(connectorId)}`, { method: 'POST', body: JSON.stringify({ - params: { subAction: 'pushToService', subActionParams: casePushParams }, + connector_type: ConnectorTypes.jira, + params: casePushParams, }), signal: abortCtrl.signal, }); }); test('happy path', async () => { - const resp = await pushToService(connectorId, casePushParams, abortCtrl.signal); + const resp = await pushToService( + connectorId, + ConnectorTypes.jira, + casePushParams, + abortCtrl.signal + ); expect(resp).toEqual(serviceConnector); }); @@ -478,7 +485,7 @@ describe('Case Configuration API', () => { message: 'not it', }); await expect( - pushToService(connectorId, casePushParams, abortCtrl.signal) + pushToService(connectorId, ConnectorTypes.jira, casePushParams, abortCtrl.signal) ).rejects.toMatchObject({ message: theError }); }); @@ -490,7 +497,7 @@ describe('Case Configuration API', () => { message: theError, }); await expect( - pushToService(connectorId, casePushParams, abortCtrl.signal) + pushToService(connectorId, ConnectorTypes.jira, casePushParams, abortCtrl.signal) ).rejects.toMatchObject({ message: theError }); }); @@ -501,7 +508,7 @@ describe('Case Configuration API', () => { status: 'error', }); await expect( - pushToService(connectorId, casePushParams, abortCtrl.signal) + pushToService(connectorId, ConnectorTypes.jira, casePushParams, abortCtrl.signal) ).rejects.toMatchObject({ message: theError }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/containers/api.ts b/x-pack/plugins/security_solution/public/cases/containers/api.ts index 5bfa071804713..ef1e35b8ceb4b 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/api.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/api.ts @@ -5,36 +5,37 @@ */ import { - CaseResponse, - CasesResponse, - CasesFindResponse, + CaseExternalServiceRequest, CasePatchRequest, CasePostRequest, + CaseResponse, + CasesFindResponse, + CasesResponse, CasesStatusResponse, - CommentRequest, - User, + CaseStatuses, CaseUserActionsResponse, - CaseExternalServiceRequest, + CommentRequest, + CommentType, + ConnectorField, ServiceConnectorCaseParams, ServiceConnectorCaseResponse, - ActionTypeExecutorResult, - CommentType, - CaseStatuses, + User, } from '../../../../case/common/api'; import { + ACTION_TYPES_URL, + CASE_CONFIGURE_CONNECTORS_URL, + CASE_REPORTERS_URL, CASE_STATUS_URL, - CASES_URL, CASE_TAGS_URL, - CASE_REPORTERS_URL, - ACTION_TYPES_URL, - ACTION_URL, + CASES_URL, } from '../../../../case/common/constants'; import { + getCaseCommentsUrl, + getCaseConfigurePushUrl, getCaseDetailsUrl, getCaseUserActionUrl, - getCaseCommentsUrl, } from '../../../../case/common/api/helpers'; import { KibanaServices } from '../../common/lib/kibana'; @@ -61,9 +62,8 @@ import { decodeCaseUserActionsResponse, decodeServiceConnectorCaseResponse, } from './utils'; - import * as i18n from './translations'; - +import { ActionTypeExecutorResult } from '../../../../actions/common'; export const getCase = async ( caseId: string, includeComments: boolean = true, @@ -245,15 +245,17 @@ export const pushCase = async ( export const pushToService = async ( connectorId: string, + connectorType: string, casePushParams: ServiceConnectorCaseParams, signal: AbortSignal ): Promise => { const response = await KibanaServices.get().http.fetch< ActionTypeExecutorResult> - >(`${ACTION_URL}/action/${connectorId}/_execute`, { + >(`${getCaseConfigurePushUrl(connectorId)}`, { method: 'POST', body: JSON.stringify({ - params: { subAction: 'pushToService', subActionParams: casePushParams }, + connector_type: connectorType, + params: casePushParams, }), signal, }); @@ -261,7 +263,6 @@ export const pushToService = async ( if (response.status === 'error') { throw new Error(response.serviceMessage ?? response.message ?? i18n.ERROR_PUSH_TO_SERVICE); } - return decodeServiceConnectorCaseResponse(response.data); }; @@ -272,3 +273,20 @@ export const getActionLicense = async (signal: AbortSignal): Promise => { + const response = await KibanaServices.get().http.fetch( + `${CASE_CONFIGURE_CONNECTORS_URL}/${connectorId}`, + { + query: { + connector_type: connectorType, + }, + method: 'GET', + signal, + } + ); + return response; +}; diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/api.ts b/x-pack/plugins/security_solution/public/cases/containers/configure/api.ts index 647bc1b466674..8652e48fd834d 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/api.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/api.ts @@ -47,6 +47,15 @@ export const getCaseConfigure = async ({ signal }: ApiProps): Promise => { + const response = await KibanaServices.get().http.fetch(`${CASE_CONFIGURE_CONNECTORS_URL}/_find`, { + method: 'GET', + signal, + }); + + return response; +}; + export const postCaseConfigure = async ( caseConfiguration: CasesConfigureRequest, signal: AbortSignal diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts b/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts index 83c9e6fa71c24..589760be92ab3 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts @@ -10,9 +10,9 @@ import { CasesConfigureRequest, ConnectorTypes, } from '../../../../../case/common/api'; -import { CaseConfigure, CasesConfigurationMapping } from './types'; +import { CaseConfigure, CaseConnectorMapping } from './types'; -export const mapping: CasesConfigurationMapping[] = [ +export const mappings: CaseConnectorMapping[] = [ { source: 'title', target: 'short_description', @@ -21,7 +21,7 @@ export const mapping: CasesConfigurationMapping[] = [ { source: 'description', target: 'description', - actionType: 'append', + actionType: 'overwrite', }, { source: 'comments', @@ -36,10 +36,6 @@ export const connectorsMock: ActionConnector[] = [ name: 'My Connector', config: { apiUrl: 'https://instance1.service-now.com', - incidentConfiguration: { - mapping, - }, - isCaseOwned: true, }, isPreconfigured: false, }, @@ -50,25 +46,6 @@ export const connectorsMock: ActionConnector[] = [ config: { apiUrl: 'https://test/', orgId: '201', - incidentConfiguration: { - mapping: [ - { - source: 'title', - target: 'name', - actionType: 'overwrite', - }, - { - source: 'description', - target: 'description', - actionType: 'overwrite', - }, - { - source: 'comments', - target: 'comments', - actionType: 'append', - }, - ], - }, }, isPreconfigured: false, }, @@ -78,25 +55,6 @@ export const connectorsMock: ActionConnector[] = [ name: 'Jira', config: { apiUrl: 'https://instance.atlassian.ne', - incidentConfiguration: { - mapping: [ - { - source: 'title', - target: 'summary', - actionType: 'overwrite', - }, - { - source: 'description', - target: 'description', - actionType: 'overwrite', - }, - { - source: 'comments', - target: 'comments', - actionType: 'append', - }, - ], - }, }, isPreconfigured: false, }, @@ -112,6 +70,7 @@ export const caseConfigurationResposeMock: CasesConfigureResponse = { fields: null, }, closure_type: 'close-by-pushing', + mappings: [], updated_at: '2020-04-06T14:03:18.657Z', updated_by: { username: 'elastic', full_name: 'Elastic', email: 'elastic@elastic.co' }, version: 'WzHJ12', @@ -137,6 +96,7 @@ export const caseConfigurationCamelCaseResponseMock: CaseConfigure = { fields: null, }, closureType: 'close-by-pushing', + mappings: [], updatedAt: '2020-04-06T14:03:18.657Z', updatedBy: { username: 'elastic', fullName: 'Elastic', email: 'elastic@elastic.co' }, version: 'WzHJ12', diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/types.ts b/x-pack/plugins/security_solution/public/cases/containers/configure/types.ts index 879ed5e7a367a..8ec005212e4e1 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/types.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/types.ts @@ -6,42 +6,30 @@ import { ElasticUser } from '../types'; import { + ActionConnector, ActionType, - CasesConfigurationMaps, + CaseConnector, CaseField, + CasesConfigure, ClosureType, ThirdPartyField, - CasesConfigure, - ActionConnector, - CaseConnector, } from '../../../../../case/common/api'; -export { - ActionType, - CasesConfigurationMaps, - CaseField, - ClosureType, - ThirdPartyField, - ActionConnector, - CaseConnector, -}; +export { ActionConnector, ActionType, CaseConnector, CaseField, ClosureType, ThirdPartyField }; -export interface CasesConfigurationMapping { - source: CaseField; - target: ThirdPartyField; +export interface CaseConnectorMapping { actionType: ActionType; + source: CaseField; + target: string; } export interface CaseConfigure { + closureType: ClosureType; + connector: CasesConfigure['connector']; createdAt: string; createdBy: ElasticUser; - connector: CasesConfigure['connector']; - closureType: ClosureType; + mappings: CaseConnectorMapping[]; updatedAt: string; updatedBy: ElasticUser; version: string; } - -export interface CCMapsCombinedActionAttributes extends CasesConfigurationMaps { - actionType?: ActionType; -} diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.test.tsx index 342cdd8b80284..3dd17190b6199 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.test.tsx @@ -11,7 +11,7 @@ import { ReturnUseCaseConfigure, ConnectorConfiguration, } from './use_configure'; -import { mapping, caseConfigurationCamelCaseResponseMock } from './mock'; +import { mappings, caseConfigurationCamelCaseResponseMock } from './mock'; import * as api from './api'; import { ConnectorTypes } from '../../../../../case/common/api/connectors'; @@ -46,7 +46,7 @@ describe('useConfigure', () => { setCurrentConfiguration: result.current.setCurrentConfiguration, setConnector: result.current.setConnector, setClosureType: result.current.setClosureType, - setMapping: result.current.setMapping, + setMappings: result.current.setMappings, }); }); }); @@ -66,15 +66,16 @@ describe('useConfigure', () => { closureType: caseConfigurationCamelCaseResponseMock.closureType, connector: caseConfigurationCamelCaseResponseMock.connector, }, - version: caseConfigurationCamelCaseResponseMock.version, + mappings: [], firstLoad: true, loading: false, - refetchCaseConfigure: result.current.refetchCaseConfigure, persistCaseConfigure: result.current.persistCaseConfigure, - setCurrentConfiguration: result.current.setCurrentConfiguration, - setConnector: result.current.setConnector, + refetchCaseConfigure: result.current.refetchCaseConfigure, setClosureType: result.current.setClosureType, - setMapping: result.current.setMapping, + setConnector: result.current.setConnector, + setCurrentConfiguration: result.current.setCurrentConfiguration, + setMappings: result.current.setMappings, + version: caseConfigurationCamelCaseResponseMock.version, }); }); }); @@ -100,9 +101,9 @@ describe('useConfigure', () => { ); await waitForNextUpdate(); await waitForNextUpdate(); - expect(result.current.mapping).toEqual(null); - result.current.setMapping(mapping); - expect(result.current.mapping).toEqual(mapping); + expect(result.current.mappings).toEqual([]); + result.current.setMappings(mappings); + expect(result.current.mappings).toEqual(mappings); }); }); @@ -205,13 +206,13 @@ describe('useConfigure', () => { expect(result.current).toEqual({ ...initialState, loading: false, + persistCaseConfigure: result.current.persistCaseConfigure, persistLoading: false, refetchCaseConfigure: result.current.refetchCaseConfigure, - persistCaseConfigure: result.current.persistCaseConfigure, - setCurrentConfiguration: result.current.setCurrentConfiguration, - setConnector: result.current.setConnector, setClosureType: result.current.setClosureType, - setMapping: result.current.setMapping, + setConnector: result.current.setConnector, + setCurrentConfiguration: result.current.setCurrentConfiguration, + setMappings: result.current.setMappings, }); }); }); @@ -249,12 +250,13 @@ describe('useConfigure', () => { }, firstLoad: true, loading: false, - refetchCaseConfigure: result.current.refetchCaseConfigure, + mappings: [], persistCaseConfigure: result.current.persistCaseConfigure, - setCurrentConfiguration: result.current.setCurrentConfiguration, - setConnector: result.current.setConnector, + refetchCaseConfigure: result.current.refetchCaseConfigure, setClosureType: result.current.setClosureType, - setMapping: result.current.setMapping, + setConnector: result.current.setConnector, + setCurrentConfiguration: result.current.setCurrentConfiguration, + setMappings: result.current.setMappings, }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.tsx b/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.tsx index 16b813d9f4336..0ed10592dadfb 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.tsx @@ -13,7 +13,7 @@ import { displaySuccessToast, } from '../../../common/components/toasters'; import * as i18n from './translations'; -import { CasesConfigurationMapping, ClosureType, CaseConfigure, CaseConnector } from './types'; +import { ClosureType, CaseConfigure, CaseConnector, CaseConnectorMapping } from './types'; import { ConnectorTypes } from '../../../../../case/common/api/connectors'; export type ConnectorConfiguration = { connector: CaseConnector } & { @@ -24,7 +24,7 @@ export interface State extends ConnectorConfiguration { currentConfiguration: ConnectorConfiguration; firstLoad: boolean; loading: boolean; - mapping: CasesConfigurationMapping[] | null; + mappings: CaseConnectorMapping[]; persistLoading: boolean; version: string; } @@ -58,8 +58,8 @@ export type Action = closureType: ClosureType; } | { - type: 'setMapping'; - mapping: CasesConfigurationMapping[]; + type: 'setMappings'; + mappings: CaseConnectorMapping[]; }; export const configureCasesReducer = (state: State, action: Action) => { @@ -102,10 +102,10 @@ export const configureCasesReducer = (state: State, action: Action) => { closureType: action.closureType, }; } - case 'setMapping': { + case 'setMappings': { return { ...state, - mapping: action.mapping, + mappings: action.mappings, }; } default: @@ -119,7 +119,7 @@ export interface ReturnUseCaseConfigure extends State { setClosureType: (closureType: ClosureType) => void; setConnector: (connector: CaseConnector) => void; setCurrentConfiguration: (configuration: ConnectorConfiguration) => void; - setMapping: (newMapping: CasesConfigurationMapping[]) => void; + setMappings: (newMapping: CaseConnectorMapping[]) => void; } export const initialState: State = { @@ -141,7 +141,7 @@ export const initialState: State = { }, firstLoad: false, loading: true, - mapping: null, + mappings: [], persistLoading: false, version: '', }; @@ -170,10 +170,10 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { }); }, []); - const setMapping = useCallback((newMapping: CasesConfigurationMapping[]) => { + const setMappings = useCallback((mappings: CaseConnectorMapping[]) => { dispatch({ - mapping: newMapping, - type: 'setMapping', + mappings, + type: 'setMappings', }); }, []); @@ -222,6 +222,7 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { setClosureType(res.closureType); } setVersion(res.version); + setMappings(res.mappings); if (!state.firstLoad) { setFirstLoad(true); @@ -285,6 +286,7 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { setClosureType(res.closureType); } setVersion(res.version); + setMappings(res.mappings); if (setCurrentConfiguration != null) { setCurrentConfiguration({ closureType: res.closureType, @@ -299,6 +301,7 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { } } catch (error) { if (!didCancel) { + setConnector(state.currentConfiguration.connector); setPersistLoading(false); errorToToaster({ title: i18n.ERROR_TITLE, @@ -314,8 +317,16 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { abortCtrl.abort(); }; }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [state.version] + [ + dispatchToaster, + setClosureType, + setConnector, + setCurrentConfiguration, + setMappings, + setPersistLoading, + setVersion, + state, + ] ); useEffect(() => { @@ -330,6 +341,6 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { setCurrentConfiguration, setConnector, setClosureType, - setMapping, + setMappings, }; }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/mock.ts b/x-pack/plugins/security_solution/public/cases/containers/mock.ts index f94fb189c90ce..2b647de2b14ed 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/mock.ts @@ -144,7 +144,6 @@ const basicAction = { }; export const casePushParams = { - actionBy: elasticUser, savedObjectId: basicCaseId, createdAt: basicCreatedAt, createdBy: elasticUser, @@ -156,6 +155,7 @@ export const casePushParams = { description: 'nice', comments: null, }; + export const actionTypeExecutorResult = { actionId: 'string', status: 'ok', diff --git a/x-pack/plugins/security_solution/public/cases/containers/translations.ts b/x-pack/plugins/security_solution/public/cases/containers/translations.ts index 96d2ec2f874db..b0dafcec97cce 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/translations.ts @@ -65,3 +65,10 @@ export const ERROR_PUSH_TO_SERVICE = i18n.translate( defaultMessage: 'Error pushing to service', } ); + +export const ERROR_GET_FIELDS = i18n.translate( + 'xpack.securitySolution.case.configure.errorGetFields', + { + defaultMessage: 'Error getting fields from service', + } +); diff --git a/x-pack/plugins/security_solution/public/cases/containers/types.ts b/x-pack/plugins/security_solution/public/cases/containers/types.ts index a5c9c65dab62a..f83f8c70e5d87 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/types.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/types.ts @@ -132,3 +132,8 @@ export interface DeleteCase { id: string; title?: string; } + +export interface FieldMappings { + id: string; + title?: string; +} diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_fields.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_fields.tsx new file mode 100644 index 0000000000000..6b594fa60e0c7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_fields.tsx @@ -0,0 +1,82 @@ +/* + * 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 { useCallback, useEffect, useState } from 'react'; + +import { errorToToaster, useStateToaster } from '../../common/components/toasters'; +import { getFields } from './api'; +import * as i18n from './translations'; +import { ConnectorField } from '../../../../case/common/api'; + +interface FieldsState { + fields: ConnectorField[]; + isLoading: boolean; + isError: boolean; +} + +const initialData: FieldsState = { + fields: [], + isLoading: false, + isError: false, +}; + +export interface UseGetFields extends FieldsState { + fetchFields: () => void; +} + +export const useGetFields = (connectorId: string, connectorType: string): UseGetFields => { + const [fieldsState, setFieldsState] = useState(initialData); + const [, dispatchToaster] = useStateToaster(); + + const fetchFields = useCallback(() => { + let didCancel = false; + const abortCtrl = new AbortController(); + const fetchData = async () => { + setFieldsState({ + ...fieldsState, + isLoading: true, + }); + try { + const response = await getFields(connectorId, connectorType, abortCtrl.signal); + if (!didCancel) { + setFieldsState({ + fields: response, + isLoading: false, + isError: false, + }); + } + } catch (error) { + if (!didCancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + setFieldsState({ + fields: [], + isLoading: false, + isError: true, + }); + } + } + }; + fetchData(); + return () => { + didCancel = true; + abortCtrl.abort(); + }; + }, [connectorId, connectorType, dispatchToaster, fieldsState]); + + useEffect(() => { + fetchFields(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { + ...fieldsState, + fetchFields, + }; +}; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.test.tsx index fe8c793817509..71711dae69319 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.test.tsx @@ -141,6 +141,7 @@ describe('usePostPushToService', () => { await waitForNextUpdate(); expect(spyOnPushToService).toBeCalledWith( samplePush.connector.id, + samplePush.connector.type, formatServiceRequestData( basicCase, samplePush.connector, @@ -174,6 +175,7 @@ describe('usePostPushToService', () => { await waitForNextUpdate(); expect(spyOnPushToService).toBeCalledWith( samplePush2.connector.id, + samplePush2.connector.type, formatServiceRequestData(basicCase, samplePush2.connector, {}), abortCtrl.signal ); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx index d78799d5baafc..97fd0c99ffd96 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx @@ -102,6 +102,7 @@ export const usePostPushToService = (): UsePostPushToService => { const casePushData = await getCase(caseId, true, abortCtrl.signal); const responseService = await pushToService( connector.id, + connector.type, formatServiceRequestData(casePushData, connector, caseServices), abortCtrl.signal ); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx index 36a1248f94dd4..74549827b6fa2 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx @@ -382,6 +382,7 @@ export const getActionMessageParams = memoizeOne( description: 'context.results_link', useWithTripleBracesInTemplates: true, }, + { name: 'alerts', description: 'context.alerts' }, ...actionMessageRuleParams.map((param) => { const extendedParam = `rule.${param}`; return { name: extendedParam, description: `context.${extendedParam}` }; diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/event_detail.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/event_detail.tsx index 003182bd5f1b7..3c134eb6ba512 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/event_detail.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/event_detail.tsx @@ -8,7 +8,7 @@ /* eslint-disable react/display-name */ -import React, { memo, useMemo, Fragment } from 'react'; +import React, { memo, useMemo, Fragment, HTMLAttributes } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiSpacer, EuiText, EuiDescriptionList, EuiTextColor, EuiTitle } from '@elastic/eui'; @@ -246,7 +246,12 @@ function EventDetailBreadcrumbs({ panelParameters: { nodeID, eventCategory: breadcrumbEventCategory }, }); const breadcrumbs = useMemo(() => { - const crumbs = [ + const crumbs: Array< + { + text: JSX.Element | string; + 'data-test-subj'?: string; + } & HTMLAttributes + > = [ { text: i18n.translate( 'xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.events', @@ -254,6 +259,7 @@ function EventDetailBreadcrumbs({ defaultMessage: 'Events', } ), + 'data-test-subj': 'resolver:event-detail:breadcrumbs:node-list-link', ...nodesLinkNavProps, }, { diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.test.tsx index 4d3ccaf278c91..c462bd1e3553e 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.test.tsx @@ -15,7 +15,7 @@ import { urlSearch } from '../../test_utilities/url_search'; const resolverComponentInstanceID = 'resolverComponentInstanceID'; // FLAKY: https://github.com/elastic/kibana/issues/85714 -describe(`Resolver: when analyzing a tree with only the origin and paginated related events, and when the component instance ID is ${resolverComponentInstanceID}`, () => { +describe.skip(`Resolver: when analyzing a tree with only the origin and paginated related events, and when the component instance ID is ${resolverComponentInstanceID}`, () => { /** * Get (or lazily create and get) the simulator. */ diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx index fbfba38295ea4..2f6aa2ccbaa10 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx @@ -262,6 +262,7 @@ const NodeEventsInCategoryBreadcrumbs = memo(function ({ defaultMessage: 'Events', } ), + 'data-test-subj': 'resolver:node-events-in-category:breadcrumbs:node-list-link', ...nodesLinkNavProps, }, { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx index e3808514856e7..ddeb1331564ec 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx @@ -304,7 +304,11 @@ export const ColumnHeadersComponent = ({ } className={fullScreen ? FULL_SCREEN_TOGGLED_CLASS_NAME : ''} color={fullScreen ? 'ghost' : 'primary'} - data-test-subj="full-screen" + data-test-subj={ + // a full screen button gets created for timeline and for the host page + // this sets the data-test-subj for each case so that tests can differentiate between them + timelineId === TimelineId.active ? 'full-screen-active' : 'full-screen' + } iconType="fullScreen" onClick={toggleFullScreen} /> diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/create_migration.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/create_migration.test.ts new file mode 100644 index 0000000000000..cd73d5468259c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/create_migration.test.ts @@ -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 { elasticsearchServiceMock } from 'src/core/server/mocks'; +import { createMigrationIndex } from './create_migration_index'; +import { createMigration } from './create_migration'; + +jest.mock('./create_migration_index'); + +describe('createMigration', () => { + let esClient: ReturnType; + + beforeEach(() => { + esClient = elasticsearchServiceMock.createElasticsearchClient(); + }); + + it('passes reindex options to the reindex call', async () => { + const reindexOptions = { + requests_per_second: 3, + size: 10, + slices: 2, + }; + await createMigration({ + esClient, + index: 'my-signals-index', + reindexOptions, + version: 12, + }); + + expect(esClient.reindex).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + source: { + index: 'my-signals-index', + size: reindexOptions.size, + }, + }), + requests_per_second: reindexOptions.requests_per_second, + slices: reindexOptions.slices, + }) + ); + }); + + it('returns info about the created migration', async () => { + (createMigrationIndex as jest.Mock).mockResolvedValueOnce('destinationIndex'); + // @ts-expect-error minimum stub for our reindex response + esClient.reindex.mockResolvedValueOnce({ body: { task: 'reindexTaskId' } }); + + const migration = await createMigration({ + esClient, + index: 'my-signals-index', + reindexOptions: {}, + version: 12, + }); + + expect(migration).toEqual({ + destinationIndex: 'destinationIndex', + sourceIndex: 'my-signals-index', + taskId: 'reindexTaskId', + version: 12, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/migrate_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/create_migration.ts similarity index 85% rename from x-pack/plugins/security_solution/server/lib/detection_engine/migrations/migrate_signals.ts rename to x-pack/plugins/security_solution/server/lib/detection_engine/migrations/create_migration.ts index 98f492a0b80fe..836f98eac32e2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/migrate_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/create_migration.ts @@ -6,8 +6,14 @@ import { ElasticsearchClient } from 'src/core/server'; import { SignalsReindexOptions } from '../../../../common/detection_engine/schemas/request/create_signals_migration_schema'; -import { createSignalsMigrationIndex } from './create_signals_migration_index'; -import { MigrationDetails } from './types'; +import { createMigrationIndex } from './create_migration_index'; + +export interface CreatedMigration { + destinationIndex: string; + sourceIndex: string; + taskId: string; + version: number; +} /** * Migrates signals for a given concrete index. Signals are reindexed into a @@ -19,10 +25,10 @@ import { MigrationDetails } from './types'; * @param version version of the current signals template/mappings * @param reindexOptions object containing reindex options {@link SignalsReindexOptions} * - * @returns identifying information representing the {@link MigrationDetails} + * @returns identifying information representing the {@link MigrationInfo} * @throws if elasticsearch returns an error */ -export const migrateSignals = async ({ +export const createMigration = async ({ esClient, index, reindexOptions, @@ -32,8 +38,8 @@ export const migrateSignals = async ({ index: string; reindexOptions: SignalsReindexOptions; version: number; -}): Promise => { - const migrationIndex = await createSignalsMigrationIndex({ +}): Promise => { + const migrationIndex = await createMigrationIndex({ esClient, index, version, @@ -67,5 +73,6 @@ export const migrateSignals = async ({ destinationIndex: migrationIndex, sourceIndex: index, taskId: response.body.task, + version, }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/create_signals_migration_index.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/create_migration_index.test.ts similarity index 67% rename from x-pack/plugins/security_solution/server/lib/detection_engine/migrations/create_signals_migration_index.test.ts rename to x-pack/plugins/security_solution/server/lib/detection_engine/migrations/create_migration_index.test.ts index b638e89436601..ee6b4c0fa2aaf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/create_signals_migration_index.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/create_migration_index.test.ts @@ -4,19 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ElasticsearchClient } from 'src/core/server'; import { elasticsearchServiceMock } from 'src/core/server/mocks'; -import { createSignalsMigrationIndex } from './create_signals_migration_index'; +import { createMigrationIndex } from './create_migration_index'; -describe('getMigrationStatus', () => { - let esClient: ElasticsearchClient; +describe('createMigrationIndex', () => { + let esClient: ReturnType; beforeEach(() => { esClient = elasticsearchServiceMock.createElasticsearchClient(); }); it('creates an index suffixed with the template version', async () => { - await createSignalsMigrationIndex({ esClient, index: 'my-signals-index', version: 4 }); + await createMigrationIndex({ esClient, index: 'my-signals-index', version: 4 }); expect(esClient.indices.create).toHaveBeenCalledWith( expect.objectContaining({ index: 'my-signals-index-r000004' }) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/create_signals_migration_index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/create_migration_index.ts similarity index 96% rename from x-pack/plugins/security_solution/server/lib/detection_engine/migrations/create_signals_migration_index.ts rename to x-pack/plugins/security_solution/server/lib/detection_engine/migrations/create_migration_index.ts index 17929e39c24b7..4276725a83918 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/create_signals_migration_index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/create_migration_index.ts @@ -19,7 +19,7 @@ import { ElasticsearchClient } from 'src/core/server'; * * @returns the name of the created index */ -export const createSignalsMigrationIndex = async ({ +export const createMigrationIndex = async ({ esClient, index, version, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/create_migration_saved_object.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/create_migration_saved_object.test.ts new file mode 100644 index 0000000000000..89548180cada9 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/create_migration_saved_object.test.ts @@ -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 { savedObjectsClientMock } from 'src/core/server/mocks'; +import { getSignalsMigrationSavedObjectMock } from './saved_objects_schema.mock'; +import { createMigrationSavedObject } from './create_migration_saved_object'; + +describe('createMigrationSavedObjects', () => { + let soClient: ReturnType; + + beforeEach(() => { + soClient = savedObjectsClientMock.create(); + }); + + it('returns the SavedObject if valid', () => { + // @ts-expect-error response mock is missing a few fields + soClient.create.mockResolvedValue(getSignalsMigrationSavedObjectMock()); + const { attributes } = getSignalsMigrationSavedObjectMock(); + + return expect( + createMigrationSavedObject({ attributes, soClient, username: 'username' }) + ).resolves.toEqual(getSignalsMigrationSavedObjectMock()); + }); + + it('rejects if response is invalid', () => { + const { attributes } = getSignalsMigrationSavedObjectMock(); + // @ts-expect-error stubbing our SO creation + soClient.create.mockResolvedValue({ ...getSignalsMigrationSavedObjectMock(), id: null }); + + return expect( + createMigrationSavedObject({ attributes, soClient, username: 'username' }) + ).rejects.toThrow('Invalid value "null" supplied to "id"'); + }); + + it('does not pass excess fields', async () => { + // @ts-expect-error response mock is missing a few fields + soClient.create.mockResolvedValue(getSignalsMigrationSavedObjectMock()); + const { attributes } = getSignalsMigrationSavedObjectMock(); + const attributesWithExtra = { ...attributes, extra: true }; + + const result = await createMigrationSavedObject({ + attributes: attributesWithExtra, + soClient, + username: 'username', + }); + expect(result).toEqual(getSignalsMigrationSavedObjectMock()); + + const [call] = soClient.create.mock.calls; + const attrs = call[1] as Record; + + expect(Object.keys(attrs)).not.toContain('extra'); + }); + + it('rejects if attributes are invalid', () => { + const { attributes } = getSignalsMigrationSavedObjectMock(); + // @ts-expect-error intentionally breaking the type + attributes.destinationIndex = null; + + return expect( + createMigrationSavedObject({ attributes, soClient, username: 'username' }) + ).rejects.toThrow('Invalid value "null" supplied to "destinationIndex"'); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/create_migration_saved_object.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/create_migration_saved_object.ts new file mode 100644 index 0000000000000..b156ac5593601 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/create_migration_saved_object.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { chain, tryCatch } from 'fp-ts/lib/TaskEither'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { SavedObjectsClientContract } from 'src/core/server'; +import { validateTaskEither } from '../../../../common/validate'; +import { signalsMigrationSOClient } from './saved_objects_client'; +import { + signalsMigrationSO, + SignalsMigrationSO, + signalsMigrationSOCreateAttributes, + SignalsMigrationSOCreateAttributes, +} from './saved_objects_schema'; +import { getIsoDateString, toError, toPromise } from './helpers'; + +const generateAttributes = (username: string) => { + const now = getIsoDateString(); + return { created: now, createdBy: username, updated: now, updatedBy: username }; +}; + +export const createMigrationSavedObject = async ({ + attributes, + soClient, + username, +}: { + attributes: SignalsMigrationSOCreateAttributes; + soClient: SavedObjectsClientContract; + username: string; +}): Promise => { + const client = signalsMigrationSOClient(soClient); + + return pipe( + attributes, + (attrs) => validateTaskEither(signalsMigrationSOCreateAttributes, attrs), + chain((validAttrs) => + tryCatch(() => client.create({ ...validAttrs, ...generateAttributes(username) }), toError) + ), + chain((so) => validateTaskEither(signalsMigrationSO, so)), + toPromise + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/delete_migration.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/delete_migration.test.ts new file mode 100644 index 0000000000000..de39c6db06f21 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/delete_migration.test.ts @@ -0,0 +1,81 @@ +/* + * 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 { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; +import { elasticsearchServiceMock, savedObjectsClientMock } from 'src/core/server/mocks'; +import { deleteMigration } from './delete_migration'; +import { getSignalsMigrationSavedObjectMock } from './saved_objects_schema.mock'; +import { deleteMigrationSavedObject } from './delete_migration_saved_object'; +import { applyMigrationCleanupPolicy } from './migration_cleanup'; + +jest.mock('./migration_cleanup'); +jest.mock('./delete_migration_saved_object'); + +describe('deleteMigration', () => { + let esClient: ElasticsearchClient; + let soClient: SavedObjectsClientContract; + + beforeEach(() => { + esClient = elasticsearchServiceMock.createElasticsearchClient(); + soClient = savedObjectsClientMock.create(); + }); + + it('does not delete a pending migration', async () => { + const pendingMigration = getSignalsMigrationSavedObjectMock(); + await deleteMigration({ + esClient, + migration: pendingMigration, + signalsAlias: 'my-signals-alias', + soClient, + }); + + expect(deleteMigrationSavedObject).not.toHaveBeenCalled(); + }); + + it('deletes a failed migration', async () => { + const failedMigration = getSignalsMigrationSavedObjectMock({ status: 'failure' }); + const deletedMigration = await deleteMigration({ + esClient, + migration: failedMigration, + signalsAlias: 'my-signals-alias', + soClient, + }); + + expect(deletedMigration.id).toEqual(failedMigration.id); + expect(deleteMigrationSavedObject).toHaveBeenCalledWith( + expect.objectContaining({ + id: failedMigration.id, + }) + ); + expect(applyMigrationCleanupPolicy).toHaveBeenCalledWith( + expect.objectContaining({ + index: failedMigration.attributes.destinationIndex, + }) + ); + }); + + it('deletes a successful migration', async () => { + const successMigration = getSignalsMigrationSavedObjectMock({ status: 'success' }); + const deletedMigration = await deleteMigration({ + esClient, + migration: successMigration, + signalsAlias: 'my-signals-alias', + soClient, + }); + + expect(deletedMigration.id).toEqual(successMigration.id); + expect(deleteMigrationSavedObject).toHaveBeenCalledWith( + expect.objectContaining({ + id: successMigration.id, + }) + ); + expect(applyMigrationCleanupPolicy).toHaveBeenCalledWith( + expect.objectContaining({ + index: successMigration.attributes.sourceIndex, + }) + ); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/delete_migration.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/delete_migration.ts new file mode 100644 index 0000000000000..0a9ec56779c25 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/delete_migration.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; +import { deleteMigrationSavedObject } from './delete_migration_saved_object'; +import { isMigrationFailed, isMigrationPending, isMigrationSuccess } from './helpers'; +import { applyMigrationCleanupPolicy } from './migration_cleanup'; +import { SignalsMigrationSO } from './saved_objects_schema'; + +/** + * Deletes a completed migration: + * * deletes the migration SO + * * deletes the underlying task document + * * applies deletion policy to the relevant index + * + * @param esClient An {@link ElasticsearchClient} + * @param soClient An {@link SavedObjectsClientContract} + * @param migration the migration to be finalized {@link SignalsMigrationSO} + * @param signalsAlias the alias for signals indices + * + * @returns the migration SavedObject {@link SignalsMigrationSO} + * @throws if the migration is invalid or a client throws + */ +export const deleteMigration = async ({ + esClient, + migration, + signalsAlias, + soClient, +}: { + esClient: ElasticsearchClient; + migration: SignalsMigrationSO; + signalsAlias: string; + soClient: SavedObjectsClientContract; +}): Promise => { + if (isMigrationPending(migration)) { + return migration; + } + + const { destinationIndex, sourceIndex, taskId } = migration.attributes; + + if (isMigrationFailed(migration)) { + await applyMigrationCleanupPolicy({ + alias: signalsAlias, + esClient, + index: destinationIndex, + }); + } + if (isMigrationSuccess(migration)) { + await applyMigrationCleanupPolicy({ + alias: signalsAlias, + esClient, + index: sourceIndex, + }); + } + + await esClient.delete({ index: '.tasks', id: taskId }); + await deleteMigrationSavedObject({ id: migration.id, soClient }); + + return migration; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/delete_migration_saved_object.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/delete_migration_saved_object.ts new file mode 100644 index 0000000000000..b7c292c5f6514 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/delete_migration_saved_object.ts @@ -0,0 +1,16 @@ +/* + * 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 { SavedObjectsClientContract } from 'src/core/server'; +import { signalsMigrationSOClient } from './saved_objects_client'; + +export const deleteMigrationSavedObject = async ({ + id, + soClient, +}: { + id: string; + soClient: SavedObjectsClientContract; +}): Promise<{}> => signalsMigrationSOClient(soClient).delete(id); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/finalize_migration.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/finalize_migration.test.ts new file mode 100644 index 0000000000000..d8cba78fd08fc --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/finalize_migration.test.ts @@ -0,0 +1,88 @@ +/* + * 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 { elasticsearchServiceMock, savedObjectsClientMock } from 'src/core/server/mocks'; +import { getIndexCount } from '../index/get_index_count'; +import { updateMigrationSavedObject } from './update_migration_saved_object'; +import { getSignalsMigrationSavedObjectMock } from './saved_objects_schema.mock'; +import { finalizeMigration } from './finalize_migration'; + +jest.mock('./update_migration_saved_object'); +jest.mock('../index/get_index_count'); + +describe('finalizeMigration', () => { + let esClient: ReturnType; + let soClient: ReturnType; + + beforeEach(() => { + esClient = elasticsearchServiceMock.createElasticsearchClient(); + soClient = savedObjectsClientMock.create(); + + // @ts-expect-error stubbing what we use of the task response + // all our reindex tasks are completed + esClient.tasks.get.mockResolvedValueOnce({ body: { completed: true } }); + + // stub out our update call to just return the attributes we passed + (updateMigrationSavedObject as jest.Mock).mockImplementation(({ attributes }) => ({ + attributes, + })); + }); + + it('does not finalize a failed migration', async () => { + const failedMigration = getSignalsMigrationSavedObjectMock({ status: 'failure' }); + const finalizedMigration = await finalizeMigration({ + esClient, + migration: failedMigration, + signalsAlias: 'my-signals-alias', + soClient, + username: 'username', + }); + + expect(updateMigrationSavedObject).not.toHaveBeenCalled(); + expect(finalizedMigration).toEqual(failedMigration); + }); + + it('does not finalize a successful (aka finalized) migration', async () => { + const alreadyFinalizedMigration = getSignalsMigrationSavedObjectMock({ status: 'success' }); + const finalizedMigration = await finalizeMigration({ + esClient, + migration: alreadyFinalizedMigration, + signalsAlias: 'my-signals-alias', + soClient, + username: 'username', + }); + + expect(updateMigrationSavedObject).not.toHaveBeenCalled(); + expect(finalizedMigration).toEqual(alreadyFinalizedMigration); + }); + + it('fails the migration if migration index size does not match the original index', async () => { + (getIndexCount as jest.Mock).mockResolvedValueOnce(1).mockResolvedValueOnce(2); + + const expectedError = + 'The source and destination indexes have different document counts. Source [sourceIndex] has [1] documents, while destination [destinationIndex] has [2] documents.'; + const migration = getSignalsMigrationSavedObjectMock(); + const finalizedMigration = await finalizeMigration({ + esClient, + migration, + signalsAlias: 'my-signals-alias', + soClient, + username: 'username', + }); + + expect(updateMigrationSavedObject).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: { + status: 'failure', + error: expectedError, + }, + }) + ); + expect(finalizedMigration.id).toEqual(migration.id); + expect(finalizedMigration.attributes.status).toEqual('failure'); + expect(finalizedMigration.attributes.error).toEqual(expectedError); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/finalize_migration.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/finalize_migration.ts new file mode 100644 index 0000000000000..8e6fd300187cf --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/finalize_migration.ts @@ -0,0 +1,106 @@ +/* + * 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 { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; +import { getIndexCount } from '../index/get_index_count'; +import { isMigrationPending } from './helpers'; +import { applyMigrationCleanupPolicy } from './migration_cleanup'; +import { replaceSignalsIndexAlias } from './replace_signals_index_alias'; +import { SignalsMigrationSO } from './saved_objects_schema'; +import { updateMigrationSavedObject } from './update_migration_saved_object'; + +/** + * Finalizes a given migration: + * * validates that the migration has completed successfully + * * deletes the reindex task document + * * applies the deletion policy to the old index + * * swaps aliases on the old/new index + * + * @param esClient An {@link ElasticsearchClient} + * @param soClient An {@link SavedObjectsClientContract} + * @param migration the migration to be finalized {@link SignalsMigrationSO} + * @param signalsAlias the alias for signals indices + * @param username name of the user initiating the finalization + * + * @returns the migration SavedObject {@link SignalsMigrationSO} + * @throws if the migration is invalid or a client throws + */ +export const finalizeMigration = async ({ + esClient, + migration, + signalsAlias, + soClient, + username, +}: { + esClient: ElasticsearchClient; + migration: SignalsMigrationSO; + signalsAlias: string; + soClient: SavedObjectsClientContract; + username: string; +}): Promise => { + if (!isMigrationPending(migration)) { + return migration; + } + + const { destinationIndex, sourceIndex, taskId } = migration.attributes; + + const { body: task } = await esClient.tasks.get<{ completed: boolean }>({ task_id: taskId }); + if (!task.completed) { + return migration; + } + + const sourceCount = await getIndexCount({ esClient, index: sourceIndex }); + const destinationCount = await getIndexCount({ esClient, index: destinationIndex }); + if (sourceCount !== destinationCount) { + const updatedMigration = await updateMigrationSavedObject({ + username, + soClient, + id: migration.id, + attributes: { + status: 'failure', + error: `The source and destination indexes have different document counts. Source [${sourceIndex}] has [${sourceCount}] documents, while destination [${destinationIndex}] has [${destinationCount}] documents.`, + }, + }); + + await applyMigrationCleanupPolicy({ + alias: signalsAlias, + esClient, + index: destinationIndex, + }); + + return { + ...migration, + attributes: { + ...migration.attributes, + ...updatedMigration.attributes, + }, + }; + } + + await replaceSignalsIndexAlias({ + alias: signalsAlias, + esClient, + newIndex: destinationIndex, + oldIndex: sourceIndex, + }); + + const updatedMigration = await updateMigrationSavedObject({ + username, + soClient, + id: migration.id, + attributes: { + status: 'success', + }, + }); + + return { + ...migration, + attributes: { + ...migration.attributes, + ...updatedMigration.attributes, + }, + }; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/find_migration_saved_objects.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/find_migration_saved_objects.test.ts new file mode 100644 index 0000000000000..3938b51d9d285 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/find_migration_saved_objects.test.ts @@ -0,0 +1,45 @@ +/* + * 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 { savedObjectsClientMock } from 'src/core/server/mocks'; +import { getSignalsMigrationSavedObjectMock } from './saved_objects_schema.mock'; +import { findMigrationSavedObjects } from './find_migration_saved_objects'; + +describe('findMigrationSavedObjects', () => { + let soClient: ReturnType; + + beforeEach(() => { + soClient = savedObjectsClientMock.create(); + }); + + it('resolves an array of objects, if valid', async () => { + // @ts-expect-error stubbing our SO call + soClient.find.mockResolvedValue({ saved_objects: [getSignalsMigrationSavedObjectMock()] }); + const result = await findMigrationSavedObjects({ + soClient, + }); + + expect(result).toEqual([getSignalsMigrationSavedObjectMock()]); + }); + + it('rejects if SO client throws', () => { + const error = new Error('whoops'); + soClient.find.mockRejectedValue(error); + + return expect(findMigrationSavedObjects({ soClient })).rejects.toThrow(error); + }); + + it('rejects if response is invalid', () => { + // @ts-expect-error intentionally breaking the type + const badSavedObject = getSignalsMigrationSavedObjectMock({ destinationIndex: 4 }); + // @ts-expect-error stubbing our SO call + soClient.find.mockResolvedValue({ saved_objects: [badSavedObject] }); + + return expect(() => findMigrationSavedObjects({ soClient })).rejects.toThrow( + 'Invalid value "4" supplied to "attributes,destinationIndex"' + ); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/find_migration_saved_objects.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/find_migration_saved_objects.ts new file mode 100644 index 0000000000000..ee7bd01ddc002 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/find_migration_saved_objects.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 { fold } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { SavedObjectsClientContract, SavedObjectsFindOptions } from 'src/core/server'; +import { signalsMigrationSOClient } from './saved_objects_client'; +import { SignalsMigrationSO, signalsMigrationSOs } from './saved_objects_schema'; +import { validateEither } from '../../../../common/validate'; + +export const findMigrationSavedObjects = async ({ + options, + soClient, +}: { + options?: Omit; + soClient: SavedObjectsClientContract; +}): Promise => { + const client = signalsMigrationSOClient(soClient); + + return pipe( + await client.find(options), + (so) => validateEither(signalsMigrationSOs, so.saved_objects), + fold( + (e) => Promise.reject(e), + (a) => Promise.resolve(a) + ) + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_index_versions_by_index.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_index_versions_by_index.test.ts new file mode 100644 index 0000000000000..1f20b6f4e3b00 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_index_versions_by_index.test.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { elasticsearchServiceMock } from 'src/core/server/mocks'; +import { getIndexVersionsByIndex } from './get_index_versions_by_index'; + +describe('getIndexVersionsByIndex', () => { + let esClient: ReturnType; + + beforeEach(() => { + esClient = elasticsearchServiceMock.createElasticsearchClient(); + }); + + it('returns keys for each specified index', async () => { + // @ts-expect-error mocking only what we need + esClient.indices.getMapping.mockResolvedValue({ body: {} }); + + const result = await getIndexVersionsByIndex({ + esClient, + index: ['index1', 'index2'], + }); + + expect(Object.keys(result)).toEqual(['index1', 'index2']); + }); + + it('returns undefined values if no mappings are found', async () => { + // @ts-expect-error mocking only what we need + esClient.indices.getMapping.mockResolvedValue({ body: {} }); + + const result = await getIndexVersionsByIndex({ + esClient, + index: ['index1', 'index2'], + }); + + expect(result).toEqual({ + index1: undefined, + index2: undefined, + }); + }); + + it('properly transforms the response', async () => { + // @ts-expect-error mocking only what we need + esClient.indices.getMapping.mockResolvedValue({ + body: { + index1: { mappings: { _meta: { version: 3 } } }, + }, + }); + + const result = await getIndexVersionsByIndex({ + esClient, + index: ['index1', 'index2'], + }); + + expect(result).toEqual({ + index1: 3, + index2: undefined, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_index_versions_by_index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_index_versions_by_index.ts new file mode 100644 index 0000000000000..bf7d844dafdbf --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_index_versions_by_index.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ElasticsearchClient } from 'src/core/server'; + +export interface IndexMappingsResponse { + [indexName: string]: { mappings: { _meta: { version: number } } }; +} + +export interface IndexVersionsByIndex { + [indexName: string]: number | undefined; +} + +/** + * Retrieves a breakdown of index versions for each + * given signals index. + * + * @param esClient An {@link ElasticsearchClient} + * @param index name(s) of the signals index(es) + * + * @returns a {@link IndexVersionsByIndex} object + * + * @throws if client returns an error + */ +export const getIndexVersionsByIndex = async ({ + esClient, + index, +}: { + esClient: ElasticsearchClient; + index: string[]; +}): Promise => { + const { body: indexVersions } = await esClient.indices.getMapping({ + index, + }); + + return index.reduce( + (agg, _index) => ({ + ...agg, + [_index]: indexVersions[_index]?.mappings?._meta?.version, + }), + {} + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_saved_objects_by_id.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_saved_objects_by_id.ts new file mode 100644 index 0000000000000..58398dd79bdf4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_saved_objects_by_id.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsClientContract } from 'src/core/server'; +import { findMigrationSavedObjects } from './find_migration_saved_objects'; +import { signalsMigrationType } from './saved_objects'; +import { SignalsMigrationSO } from './saved_objects_schema'; + +/** + * Retrieves a list of migrations SOs by their ID + * + * @param soClient An {@link SavedObjectsClientContract} + * @param ids IDs of the migration SOs + * + * @returns a list of {@link SignalsMigrationSO[]} + * + * @throws if client returns an error + */ +export const getMigrationSavedObjectsById = async ({ + ids, + soClient, +}: { + ids: string[]; + soClient: SavedObjectsClientContract; +}): Promise => + findMigrationSavedObjects({ + soClient, + options: { + search: ids.map((id) => `${signalsMigrationType}:${id}`).join(' OR '), + rootSearchFields: ['_id'], + sortField: 'updated', + sortOrder: 'desc', + }, + }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_saved_objects_by_index.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_saved_objects_by_index.test.ts new file mode 100644 index 0000000000000..5ab5669659f15 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_saved_objects_by_index.test.ts @@ -0,0 +1,53 @@ +/* + * 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 { savedObjectsClientMock } from 'src/core/server/mocks'; +import { findMigrationSavedObjects } from './find_migration_saved_objects'; +import { getMigrationSavedObjectsByIndex } from './get_migration_saved_objects_by_index'; +import { getSignalsMigrationSavedObjectMock } from './saved_objects_schema.mock'; + +jest.mock('./find_migration_saved_objects'); + +describe('getMigrationSavedObjectsByIndex', () => { + let soClient: ReturnType; + + beforeEach(() => { + soClient = savedObjectsClientMock.create(); + }); + + it('aggregates migrations by index', async () => { + const indices = ['index1', 'index2']; + const migrations = indices.flatMap((index) => [ + getSignalsMigrationSavedObjectMock({ sourceIndex: index, version: 1 }), + getSignalsMigrationSavedObjectMock({ sourceIndex: index, version: 2 }), + ]); + (findMigrationSavedObjects as jest.Mock).mockResolvedValueOnce(migrations); + + const result = await getMigrationSavedObjectsByIndex({ + soClient, + index: indices, + }); + + expect(result).toEqual({ + index1: [ + expect.objectContaining({ + attributes: expect.objectContaining({ sourceIndex: 'index1', version: 1 }), + }), + expect.objectContaining({ + attributes: expect.objectContaining({ sourceIndex: 'index1', version: 2 }), + }), + ], + index2: [ + expect.objectContaining({ + attributes: expect.objectContaining({ sourceIndex: 'index2', version: 1 }), + }), + expect.objectContaining({ + attributes: expect.objectContaining({ sourceIndex: 'index2', version: 2 }), + }), + ], + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_saved_objects_by_index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_saved_objects_by_index.ts new file mode 100644 index 0000000000000..67e228ea7a706 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_saved_objects_by_index.ts @@ -0,0 +1,51 @@ +/* + * 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 { SavedObjectsClientContract } from 'src/core/server'; +import { findMigrationSavedObjects } from './find_migration_saved_objects'; + +import { SignalsMigrationSO } from './saved_objects_schema'; + +export interface MigrationsByIndex { + [indexName: string]: SignalsMigrationSO[] | undefined; +} + +/** + * Retrieves a list of migrations SOs for each + * given signals index. + * + * @param soClient An {@link SavedObjectsClientContract} + * @param index name(s) of the signals index(es) + * + * @returns a {@link MigrationsByIndex} object + * + * @throws if client returns an error + */ +export const getMigrationSavedObjectsByIndex = async ({ + index, + soClient, +}: { + index: string[]; + soClient: SavedObjectsClientContract; +}): Promise => { + const migrationSavedObjects = await findMigrationSavedObjects({ + soClient, + options: { + search: index.join(' OR '), + searchFields: ['sourceIndex'], + sortField: 'updated', + sortOrder: 'desc', + }, + }); + + return migrationSavedObjects.reduce((agg, migration) => { + const { sourceIndex } = migration.attributes; + return { + ...agg, + [sourceIndex]: [...(agg[sourceIndex] ?? []), migration], + }; + }, {}); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_status.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_status.mock.ts deleted file mode 100644 index 08b74b6c2ca7b..0000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_status.mock.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { IndexMappingsResponse, MigrationStatusSearchResponse } from './types'; - -export const getMigrationStatusSearchResponseMock = ( - indices: string[] = ['signals-index'], - signalVersions: number[] = [-1] -): MigrationStatusSearchResponse => ({ - aggregations: { - signals_indices: { - buckets: indices.map((index) => ({ - key: index, - signal_versions: { - buckets: signalVersions.map((version) => ({ - key: version, - doc_count: 4, - })), - }, - })), - }, - }, -}); - -export const getIndexMappingsResponseMock = ( - index: string = 'signals-index' -): IndexMappingsResponse => ({ - [index]: { mappings: { _meta: { version: -1 } } }, -}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_status.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_status.test.ts deleted file mode 100644 index 2cd506fe1e870..0000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_status.test.ts +++ /dev/null @@ -1,103 +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 { ElasticsearchClient } from 'src/core/server'; -import { elasticsearchServiceMock } from 'src/core/server/mocks'; -import { - getIndexMappingsResponseMock, - getMigrationStatusSearchResponseMock, -} from './get_migration_status.mock'; -import { getMigrationStatus } from './get_migration_status'; - -describe('getMigrationStatus', () => { - let esClient: ElasticsearchClient; - - beforeEach(() => { - esClient = elasticsearchServiceMock.createElasticsearchClient(); - - // mock index version - (esClient.indices.getMapping as jest.Mock).mockResolvedValue({ - body: { - ...getIndexMappingsResponseMock('index1'), - }, - }); - - // mock index search - (esClient.search as jest.Mock).mockResolvedValue({ - body: { - ...getMigrationStatusSearchResponseMock(['index1']), - }, - }); - }); - - it('returns one entry for each index provided', async () => { - (esClient.indices.getMapping as jest.Mock).mockResolvedValueOnce({ - body: { - ...getIndexMappingsResponseMock('index1'), - ...getIndexMappingsResponseMock('index2'), - ...getIndexMappingsResponseMock('index3'), - }, - }); - - // mock index search - (esClient.search as jest.Mock).mockResolvedValueOnce({ - body: getMigrationStatusSearchResponseMock(['index1', 'index2', 'index3']), - }); - - const migrationStatuses = await getMigrationStatus({ - esClient, - index: ['index1', 'index2', 'index3'], - }); - - expect(migrationStatuses).toHaveLength(3); - }); - - it('returns the name and version for each index provided', async () => { - const [migrationStatus] = await getMigrationStatus({ - esClient, - index: ['index1'], - }); - - expect(migrationStatus).toEqual( - expect.objectContaining({ - name: 'index1', - version: -1, - }) - ); - }); - - it('returns the breakdown of signals versions available in each index', async () => { - const [migrationStatus] = await getMigrationStatus({ - esClient, - index: ['index1'], - }); - - expect(migrationStatus).toEqual( - expect.objectContaining({ - signal_versions: [{ key: -1, doc_count: 4 }], - }) - ); - }); - - it('defaults the index version to 0 if missing from the mapping', async () => { - (esClient.indices.getMapping as jest.Mock).mockResolvedValueOnce({ - body: { - index1: { mappings: {} }, - }, - }); - - const [migrationStatus] = await getMigrationStatus({ - esClient, - index: ['index1'], - }); - - expect(migrationStatus).toEqual( - expect.objectContaining({ - version: 0, - }) - ); - }); -}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_status.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_status.ts deleted file mode 100644 index af0a28e807fa2..0000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_migration_status.ts +++ /dev/null @@ -1,76 +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 { ElasticsearchClient } from 'src/core/server'; -import { IndexMappingsResponse, MigrationStatus, MigrationStatusSearchResponse } from './types'; - -/** - * Retrieves a breakdown of information relevant to the migration of each - * given signals index. - * - * This includes: - * * the mappings version of the index - * * aggregated counts of the schema versions of signals in the index - * * aggregated counts of the migration versions of signals in the index - * - * @param esClient An {@link ElasticsearchClient} - * @param index name(s) of the signals index(es) - * - * @returns an array of {@link MigrationStatus} objects - * - * @throws if elasticsearch returns an error - */ -export const getMigrationStatus = async ({ - esClient, - index, -}: { - esClient: ElasticsearchClient; - index: string[]; -}): Promise => { - if (index.length === 0) { - return []; - } - - const { body: indexVersions } = await esClient.indices.getMapping({ - index, - }); - const response = await esClient.search({ - index, - size: 0, - body: { - aggs: { - signals_indices: { - terms: { - field: '_index', - }, - aggs: { - signal_versions: { - terms: { - field: 'signal._meta.version', - missing: 0, - }, - }, - }, - }, - }, - }, - }); - - const indexBuckets = response.body.aggregations.signals_indices.buckets; - return indexBuckets.reduce((statuses, bucket) => { - const indexName = bucket.key; - const indexVersion = indexVersions[indexName]?.mappings?._meta?.version ?? 0; - - return [ - ...statuses, - { - name: indexName, - version: indexVersion, - signal_versions: bucket.signal_versions.buckets, - }, - ]; - }, []); -}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_signal_versions_by_index.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_signal_versions_by_index.test.ts new file mode 100644 index 0000000000000..8fc693e4358b1 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_signal_versions_by_index.test.ts @@ -0,0 +1,52 @@ +/* + * 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 { elasticsearchServiceMock } from 'src/core/server/mocks'; +import { getSignalVersionsByIndex } from './get_signal_versions_by_index'; + +describe('getSignalVersionsByIndex', () => { + let esClient: ReturnType; + + beforeEach(() => { + esClient = elasticsearchServiceMock.createElasticsearchClient(); + }); + + it('properly transforms the elasticsearch aggregation', async () => { + // @ts-expect-error mocking only what we need + esClient.search.mockResolvedValueOnce({ + body: { + aggregations: { + signals_indices: { + buckets: [ + { + key: 'index1', + signal_versions: { + buckets: [ + { key: 1, doc_count: 2 }, + { key: 2, doc_count: 3 }, + ], + }, + }, + ], + }, + }, + }, + }); + + const result = await getSignalVersionsByIndex({ + esClient, + index: ['index1', 'index2'], + }); + + expect(result).toEqual({ + index1: [ + { count: 2, version: 1 }, + { count: 3, version: 2 }, + ], + index2: [], + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_signal_versions_by_index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_signal_versions_by_index.ts new file mode 100644 index 0000000000000..3eebc608f1daf --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/get_signal_versions_by_index.ts @@ -0,0 +1,88 @@ +/* + * 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 { ElasticsearchClient } from 'src/core/server'; + +export interface SignalVersionsAggResponse { + aggregations: { + signals_indices: { + buckets: Array<{ + key: string; + signal_versions: { + buckets: Array<{ + key: number; + doc_count: number; + }>; + }; + }>; + }; + }; +} + +export interface SignalVersion { + version: number; + count: number; +} + +export interface SignalVersionsByIndex { + [indexName: string]: SignalVersion[] | undefined; +} + +/** + * Retrieves a breakdown of signals version for each + * given signals index. + * + * @param esClient An {@link ElasticsearchClient} + * @param index name(s) of the signals index(es) + * + * @returns a {@link SignalsVersionsByIndex} object + * + * @throws if client returns an error + */ +export const getSignalVersionsByIndex = async ({ + esClient, + index, +}: { + esClient: ElasticsearchClient; + index: string[]; +}): Promise => { + const { body } = await esClient.search({ + index, + size: 0, + body: { + aggs: { + signals_indices: { + terms: { + field: '_index', + }, + aggs: { + signal_versions: { + terms: { + field: 'signal._meta.version', + missing: 0, + }, + }, + }, + }, + }, + }, + }); + const indexBuckets = body.aggregations.signals_indices.buckets; + + return index.reduce((agg, _index) => { + const bucket = indexBuckets.find((ib) => ib.key === _index); + const signalVersionBuckets = bucket?.signal_versions?.buckets ?? []; + const signalsVersions = signalVersionBuckets.map((sb) => ({ + version: sb.key, + count: sb.doc_count, + })); + + return { + ...agg, + [_index]: signalsVersions, + }; + }, {}); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/helpers.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/helpers.test.ts index 0a7f714553fcd..60e57cc349d18 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/helpers.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/helpers.test.ts @@ -4,46 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MigrationDetails } from './types'; -import { decodeMigrationToken, encodeMigrationToken } from './helpers'; +import { isOutdated } from './helpers'; -describe('migration tokens', () => { - let details: MigrationDetails; - - beforeEach(() => { - details = { - destinationIndex: 'destinationIndex', - sourceIndex: 'sourceIndex', - taskId: 'my-task-id', - }; - }); - - describe('decodeMigrationToken', () => { - it('decodes a valid token to migration details', () => { - const token = encodeMigrationToken({ ...details }); - const decodedDetails = decodeMigrationToken(token); - expect(decodedDetails).toEqual(details); +describe('signals migration helpers', () => { + describe('isOutdated', () => { + it('is true when current less than target', () => { + expect(isOutdated({ current: 1, target: 2 })).toBe(true); }); - it('decoding a misencoded string throws an error', () => { - const badToken = 'not-properly-encoded'; - expect(() => decodeMigrationToken(badToken)).toThrowError( - 'An error occurred while decoding the migration token: [not-properly-encoded]' - ); + it('is false when current is equal to target', () => { + expect(isOutdated({ current: 2, target: 2 })).toBe(false); }); - it('decoding invalid details throws an error', () => { - const invalidDetails = ({ ...details, taskId: null } as unknown) as MigrationDetails; - const token = encodeMigrationToken(invalidDetails); - expect(() => decodeMigrationToken(token)).toThrowError( - 'An error occurred while decoding the migration token: [eyJkZXN0aW5hdGlvbkluZGV4IjoiZGVzdGluYXRpb25JbmRleCIsInNvdXJjZUluZGV4Ijoic291cmNlSW5kZXgiLCJ0YXNrSWQiOm51bGx9]' - ); - }); - }); - - describe('encodeMigrationToken', () => { - it('encodes idempotently', () => { - expect(encodeMigrationToken(details)).toEqual(encodeMigrationToken(details)); + it('is false when current is greater than target', () => { + expect(isOutdated({ current: 3, target: 2 })).toBe(false); }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/helpers.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/helpers.ts index 10763e0f3f41c..6ad8e595f8c28 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/helpers.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/helpers.ts @@ -4,59 +4,46 @@ * you may not use this file except in compliance with the Elastic License. */ -import { BadRequestError } from '../errors/bad_request_error'; -import { MigrationDetails, MigrationStatus } from './types'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { TaskEither } from 'fp-ts/lib/TaskEither'; +import { fold } from 'fp-ts/lib/Either'; -const decodeBase64 = (base64: string) => Buffer.from(base64, 'base64').toString('utf8'); -const encodeBase64 = (utf8: string) => Buffer.from(utf8, 'utf8').toString('base64'); +import { SignalsMigrationSO } from './saved_objects_schema'; +import { SignalVersion } from './get_signal_versions_by_index'; -export const encodeMigrationToken = (details: MigrationDetails): string => - encodeBase64(JSON.stringify(details)); +export const isMigrationPending = (migration: SignalsMigrationSO): boolean => + migration.attributes.status === 'pending'; -export const decodeMigrationToken = (token: string): MigrationDetails => { - try { - const details = JSON.parse(decodeBase64(token)) as MigrationDetails; +export const isMigrationSuccess = (migration: SignalsMigrationSO): boolean => + migration.attributes.status === 'success'; - if (details.destinationIndex == null || details.sourceIndex == null || details.taskId == null) { - throw new TypeError(); - } - - return details; - } catch (_) { - throw new BadRequestError(`An error occurred while decoding the migration token: [${token}]`); - } -}; +export const isMigrationFailed = (migration: SignalsMigrationSO): boolean => + migration.attributes.status === 'failure'; export const isOutdated = ({ current, target }: { current: number; target: number }): boolean => current < target; -const mappingsAreOutdated = ({ - status, - version, -}: { - status: MigrationStatus; - version: number; -}): boolean => isOutdated({ current: status.version, target: version }); - -const signalsAreOutdated = ({ - status, - version, -}: { - status: MigrationStatus; - version: number; -}): boolean => - status.signal_versions.some((signalVersion) => { - return ( - signalVersion.doc_count > 0 && isOutdated({ current: signalVersion.key, target: version }) - ); - }); - -export const indexIsOutdated = ({ - status, - version, +export const signalsAreOutdated = ({ + signalVersions, + target, }: { - status?: MigrationStatus; - version: number; + signalVersions: SignalVersion[]; + target: number; }): boolean => - status != null && - (mappingsAreOutdated({ status, version }) || signalsAreOutdated({ status, version })); + signalVersions.some( + (signalVersion) => + signalVersion.count > 0 && isOutdated({ current: signalVersion.version, target }) + ); + +export const getIsoDateString = () => new Date().toISOString(); + +export const toPromise = async (taskEither: TaskEither): Promise => + pipe( + await taskEither(), + fold( + (e) => Promise.reject(e), + (a) => Promise.resolve(a) + ) + ); + +export const toError = (e: unknown): Error => (e instanceof Error ? e : new Error(String(e))); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/migration_service.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/migration_service.mock.ts new file mode 100644 index 0000000000000..2070985b5288c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/migration_service.mock.ts @@ -0,0 +1,16 @@ +/* + * 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 { SignalsMigrationService } from './migration_service'; + +const create = () => + ({ + create: jest.fn(), + delete: jest.fn(), + finalize: jest.fn(), + } as jest.Mocked); + +export const migrationServiceMock = { create }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/migration_service.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/migration_service.ts new file mode 100644 index 0000000000000..251791d58c8cb --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/migration_service.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; +import { SignalsReindexOptions } from '../../../../common/detection_engine/schemas/request/create_signals_migration_schema'; +import { SignalsMigrationSO } from './saved_objects_schema'; +import { createMigrationSavedObject } from './create_migration_saved_object'; +import { createMigration } from './create_migration'; +import { finalizeMigration } from './finalize_migration'; +import { deleteMigration } from './delete_migration'; + +export interface CreateParams { + index: string; + version: number; + reindexOptions: SignalsReindexOptions; +} + +export interface FinalizeParams { + signalsAlias: string; + migration: SignalsMigrationSO; +} + +export interface DeleteParams { + signalsAlias: string; + migration: SignalsMigrationSO; +} + +export interface SignalsMigrationService { + create: (params: CreateParams) => Promise; + finalize: (params: FinalizeParams) => Promise; + delete: (params: DeleteParams) => Promise; +} + +export const signalsMigrationService = ({ + esClient, + soClient, + username, +}: { + esClient: ElasticsearchClient; + soClient: SavedObjectsClientContract; + username: string; +}): SignalsMigrationService => { + return { + create: async ({ index, reindexOptions, version }) => { + const migrationInfo = await createMigration({ + esClient, + index, + version, + reindexOptions, + }); + + return createMigrationSavedObject({ + attributes: { ...migrationInfo, status: 'pending', error: null }, + soClient, + username, + }); + }, + finalize: ({ migration, signalsAlias }) => + finalizeMigration({ + esClient, + migration, + signalsAlias, + soClient, + username, + }), + delete: ({ migration, signalsAlias }) => + deleteMigration({ + esClient, + migration, + signalsAlias, + soClient, + }), + }; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects.ts new file mode 100644 index 0000000000000..4bc1abb5dbd96 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects.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 { SavedObjectsType } from 'src/core/server'; + +export const signalsMigrationType = 'security-solution-signals-migration'; + +export const signalsMigrationMappings: SavedObjectsType['mappings'] = { + properties: { + sourceIndex: { + type: 'keyword', + }, + destinationIndex: { + type: 'keyword', + index: false, + }, + version: { + type: 'long', + }, + error: { + type: 'text', + index: false, + }, + taskId: { + type: 'keyword', + index: false, + }, + status: { + type: 'keyword', + index: false, + }, + created: { + type: 'date', + index: false, + }, + createdBy: { + type: 'text', + index: false, + }, + updated: { + type: 'date', + index: false, + }, + updatedBy: { + type: 'text', + index: false, + }, + }, +}; + +export const type: SavedObjectsType = { + name: signalsMigrationType, + hidden: false, + namespaceType: 'single', + mappings: signalsMigrationMappings, +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_client.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_client.mock.ts new file mode 100644 index 0000000000000..38f5a63a82fc5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_client.mock.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SignalsMigrationSOClient } from './saved_objects_client'; + +const create = () => + ({ + create: jest.fn(), + delete: jest.fn(), + find: jest.fn(), + update: jest.fn(), + } as jest.Mocked); + +export const savedObjectClientMock = { create }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_client.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_client.ts new file mode 100644 index 0000000000000..51065ddcbf6e3 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_client.ts @@ -0,0 +1,42 @@ +/* + * 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 { + SavedObjectsClientContract, + SavedObject, + SavedObjectsUpdateResponse, + SavedObjectsFindOptions, + SavedObjectsFindResponse, +} from 'src/core/server'; +import { signalsMigrationType } from './saved_objects'; +import { SignalsMigrationSOAttributes } from './saved_objects_schema'; + +export interface SignalsMigrationSOClient { + find: ( + options?: Omit + ) => Promise>; + create: ( + attributes: SignalsMigrationSOAttributes + ) => Promise>; + update: ( + id: string, + attributes: Partial + ) => Promise>; + delete: (id: string) => Promise<{}>; +} + +export const signalsMigrationSOClient = ( + savedObjectsClient: SavedObjectsClientContract +): SignalsMigrationSOClient => ({ + find: (options) => + savedObjectsClient.find({ + ...options, + type: signalsMigrationType, + }), + create: (attributes) => savedObjectsClient.create(signalsMigrationType, attributes), + update: (id, attributes) => savedObjectsClient.update(signalsMigrationType, id, attributes), + delete: (id) => savedObjectsClient.delete(signalsMigrationType, id), +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_schema.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_schema.mock.ts new file mode 100644 index 0000000000000..05c48e9545777 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_schema.mock.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 { signalsMigrationType } from './saved_objects'; +import { SignalsMigrationSO } from './saved_objects_schema'; + +export const getSignalsMigrationSavedObjectMock = ( + overrides: Partial = {} +): SignalsMigrationSO => ({ + id: '4a7ff78d-3055-4bb2-ba73-587b9c6c15a4', + type: signalsMigrationType, + attributes: { + destinationIndex: 'destinationIndex', + sourceIndex: 'sourceIndex', + error: null, + status: 'pending', + taskId: 'taskid', + version: 14, + createdBy: 'username', + created: '2020-03-27T22:55:59.517Z', + updatedBy: 'username', + updated: '2020-03-27T22:55:59.517Z', + ...overrides, + }, +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_schema.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_schema.ts new file mode 100644 index 0000000000000..b1a726d97193a --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/saved_objects_schema.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; + +import { IsoDateString, PositiveInteger } from '../../../../common/detection_engine/schemas/types'; +import { unionWithNullType } from '../../../../common/utility_types'; + +const status = t.keyof({ success: null, failure: null, pending: null }); + +const signalsMigrationSOWriteAttributes = { + destinationIndex: t.string, + error: unionWithNullType(t.string), + sourceIndex: t.string, + status, + taskId: t.string, + version: PositiveInteger, +}; + +const signalsMigrationSOGeneratedAttributes = { + created: IsoDateString, + createdBy: t.string, + updated: IsoDateString, + updatedBy: t.string, +}; + +/** + The attributes necessary to create a Signals Migration Saved Object + */ +export const signalsMigrationSOCreateAttributes = t.exact( + t.type(signalsMigrationSOWriteAttributes) +); +export type SignalsMigrationSOCreateAttributes = t.TypeOf< + typeof signalsMigrationSOCreateAttributes +>; + +/** + The attributes necessary to update a Signals Migration Saved Object + */ +export const signalsMigrationSOUpdateAttributes = t.exact( + t.partial(signalsMigrationSOWriteAttributes) +); +export type SignalsMigrationSOUpdateAttributes = t.TypeOf< + typeof signalsMigrationSOUpdateAttributes +>; + +/** + The attributes of our Signals Migration Saved Object + */ +export const signalsMigrationSOAttributes = t.exact( + t.type({ + ...signalsMigrationSOWriteAttributes, + ...signalsMigrationSOGeneratedAttributes, + }) +); +export type SignalsMigrationSOAttributes = t.TypeOf; + +export const signalsMigrationSO = t.type({ + id: t.string, + attributes: signalsMigrationSOAttributes, + type: t.string, +}); +export type SignalsMigrationSO = t.TypeOf; + +export const signalsMigrationSOs = t.array(signalsMigrationSO); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/types.ts deleted file mode 100644 index 0c05361b0941b..0000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/types.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export interface Bucket { - key: number; - doc_count: number; -} - -export interface MigrationStatus { - name: string; - version: number; - signal_versions: Bucket[]; -} - -export interface MigrationDetails { - destinationIndex: string; - sourceIndex: string; - taskId: string; -} - -export interface MigrationStatusSearchResponse { - aggregations: { - signals_indices: { - buckets: Array<{ - key: string; - signal_versions: { - buckets: Bucket[]; - }; - }>; - }; - }; -} - -export interface IndexMappingsResponse { - [indexName: string]: { mappings: { _meta: { version: number } } }; -} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/update_migration_saved_object.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/update_migration_saved_object.test.ts new file mode 100644 index 0000000000000..c77bda737e441 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/update_migration_saved_object.test.ts @@ -0,0 +1,97 @@ +/* + * 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 { savedObjectsClientMock } from 'src/core/server/mocks'; +import { SignalsMigrationSOUpdateAttributes } from './saved_objects_schema'; +import { getSignalsMigrationSavedObjectMock } from './saved_objects_schema.mock'; +import { updateMigrationSavedObject } from './update_migration_saved_object'; + +const expectIsoDateString = expect.stringMatching(/2.*Z$/); +describe('updateMigrationSavedObject', () => { + let partialMigration: { attributes: Partial }; + let soClient: ReturnType; + + beforeEach(() => { + soClient = savedObjectsClientMock.create(); + const migrationMock = getSignalsMigrationSavedObjectMock(); + + partialMigration = { + ...migrationMock, + attributes: { + ...migrationMock.attributes, + destinationIndex: undefined, + sourceIndex: undefined, + }, + }; + + // @ts-expect-error response mock is missing a few fields + soClient.update.mockResolvedValueOnce(partialMigration); + }); + + it('returns the partial SavedObject if valid', () => { + const { attributes } = getSignalsMigrationSavedObjectMock(); + + return expect( + updateMigrationSavedObject({ attributes, id: 'stubbed', soClient, username: 'username' }) + ).resolves.toEqual(partialMigration); + }); + + it('allows partial attributes', () => { + const { attributes } = getSignalsMigrationSavedObjectMock(); + // @ts-expect-error intentionally breaking the type + attributes.destinationIndex = undefined; + + return expect( + updateMigrationSavedObject({ attributes, id: 'stubbed', soClient, username: 'username' }) + ).resolves.toEqual(partialMigration); + }); + + it('rejects if attributes are invalid', () => { + const { attributes } = getSignalsMigrationSavedObjectMock(); + // @ts-expect-error intentionally breaking the type + attributes.destinationIndex = null; + + return expect( + updateMigrationSavedObject({ attributes, id: 'stubbed', soClient, username: 'username' }) + ).rejects.toThrow('Invalid value "null" supplied to "destinationIndex"'); + }); + + it('updates our updated* fields', async () => { + const { attributes } = getSignalsMigrationSavedObjectMock(); + + await updateMigrationSavedObject({ id: 'my-id', attributes, soClient, username: 'username' }); + const [call] = soClient.update.mock.calls; + const updatedAttrs = call[2]; + + expect(updatedAttrs).toEqual( + expect.objectContaining({ + updated: expectIsoDateString, + updatedBy: 'username', + }) + ); + }); + + it('does not pass excess fields', async () => { + const { attributes } = getSignalsMigrationSavedObjectMock(); + const attributesWithExtra = { ...attributes, extra: true }; + + const result = await updateMigrationSavedObject({ + attributes: attributesWithExtra, + id: 'stubbed', + soClient, + username: 'username', + }); + expect(result).toEqual(partialMigration); + + const [call] = soClient.update.mock.calls; + const updatedAttrs = call[2]; + const updatedKeys = Object.keys(updatedAttrs); + + expect(updatedKeys).toContain('updated'); + expect(updatedKeys).not.toContain('created'); + expect(updatedKeys).not.toContain('createdBy'); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/update_migration_saved_object.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/update_migration_saved_object.ts new file mode 100644 index 0000000000000..93cb6e0d6ed8a --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/migrations/update_migration_saved_object.ts @@ -0,0 +1,48 @@ +/* + * 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 { chain, tryCatch } from 'fp-ts/lib/TaskEither'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { SavedObjectsClientContract, SavedObjectsUpdateResponse } from 'src/core/server'; +import { validateTaskEither } from '../../../../common/validate'; +import { signalsMigrationSOClient } from './saved_objects_client'; +import { + SignalsMigrationSOUpdateAttributes, + signalsMigrationSOUpdateAttributes, +} from './saved_objects_schema'; +import { getIsoDateString, toError, toPromise } from './helpers'; + +export const updateMigrationSavedObject = async ({ + attributes, + id, + username, + soClient, +}: { + attributes: SignalsMigrationSOUpdateAttributes; + id: string; + username: string; + soClient: SavedObjectsClientContract; +}): Promise> => { + const client = signalsMigrationSOClient(soClient); + + return pipe( + attributes, + (attrs) => validateTaskEither(signalsMigrationSOUpdateAttributes, attrs), + chain((validAttrs) => + tryCatch( + () => + client.update(id, { + ...validAttrs, + updated: getIsoDateString(), + updatedBy: username, + }), + toError + ) + ), + toPromise + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/build_signals_query.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/build_signals_query.ts index be0943c014225..b3cdf3afe683a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/build_signals_query.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/build_signals_query.ts @@ -9,11 +9,19 @@ interface BuildSignalsSearchQuery { index: string; from: string; to: string; + size?: number; } -export const buildSignalsSearchQuery = ({ ruleId, index, from, to }: BuildSignalsSearchQuery) => ({ +export const buildSignalsSearchQuery = ({ + ruleId, + index, + from, + to, + size, +}: BuildSignalsSearchQuery) => ({ index, body: { + size, query: { bool: { filter: [ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/get_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/get_signals.ts new file mode 100644 index 0000000000000..4d80736ca6bb6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/get_signals.ts @@ -0,0 +1,43 @@ +/* + * 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 { AlertServices } from '../../../../../alerts/server'; +import { SignalSearchResponse } from '../signals/types'; +import { buildSignalsSearchQuery } from './build_signals_query'; + +interface GetSignalsParams { + from?: string; + to?: string; + size?: number; + ruleId: string; + index: string; + callCluster: AlertServices['callCluster']; +} + +export const getSignals = async ({ + from, + to, + size, + ruleId, + index, + callCluster, +}: GetSignalsParams): Promise => { + if (from == null || to == null) { + throw Error('"from" or "to" was not provided to signals query'); + } + + const query = buildSignalsSearchQuery({ + index, + ruleId, + to, + from, + size, + }); + + const result: SignalSearchResponse = await callCluster('search', query); + + return result; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts index 593ada470b118..5c38461b124c6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts @@ -10,6 +10,12 @@ import { rulesNotificationAlertType } from './rules_notification_alert_type'; import { buildSignalsSearchQuery } from './build_signals_query'; import { alertsMock, AlertServicesMock } from '../../../../../alerts/server/mocks'; import { NotificationExecutorOptions } from './types'; +import { + sampleDocSearchResultsNoSortIdNoVersion, + sampleDocSearchResultsWithSortId, + sampleEmptyDocSearchResults, +} from '../signals/__mocks__/es_results'; +import { DEFAULT_RULE_NOTIFICATION_QUERY_SIZE } from '../../../../common/constants'; jest.mock('./build_signals_query'); describe('rules_notification_alert_type', () => { @@ -63,9 +69,7 @@ describe('rules_notification_alert_type', () => { references: [], attributes: ruleAlert, }); - alertServices.callCluster.mockResolvedValue({ - count: 0, - }); + alertServices.callCluster.mockResolvedValue(sampleDocSearchResultsWithSortId()); await alert.executor(payload); @@ -75,6 +79,7 @@ describe('rules_notification_alert_type', () => { index: '.siem-signals', ruleId: 'rule-1', to: '1576341633400', + size: DEFAULT_RULE_NOTIFICATION_QUERY_SIZE, }) ); }); @@ -88,9 +93,7 @@ describe('rules_notification_alert_type', () => { references: [], attributes: ruleAlert, }); - alertServices.callCluster.mockResolvedValue({ - count: 10, - }); + alertServices.callCluster.mockResolvedValue(sampleDocSearchResultsWithSortId()); await alert.executor(payload); expect(alertServices.alertInstanceFactory).toHaveBeenCalled(); @@ -114,9 +117,7 @@ describe('rules_notification_alert_type', () => { references: [], attributes: ruleAlert, }); - alertServices.callCluster.mockResolvedValue({ - count: 10, - }); + alertServices.callCluster.mockResolvedValue(sampleDocSearchResultsWithSortId()); await alert.executor(payload); expect(alertServices.alertInstanceFactory).toHaveBeenCalled(); @@ -141,9 +142,7 @@ describe('rules_notification_alert_type', () => { references: [], attributes: ruleAlert, }); - alertServices.callCluster.mockResolvedValue({ - count: 10, - }); + alertServices.callCluster.mockResolvedValue(sampleDocSearchResultsWithSortId()); await alert.executor(payload); expect(alertServices.alertInstanceFactory).toHaveBeenCalled(); @@ -165,9 +164,7 @@ describe('rules_notification_alert_type', () => { references: [], attributes: ruleAlert, }); - alertServices.callCluster.mockResolvedValue({ - count: 0, - }); + alertServices.callCluster.mockResolvedValue(sampleEmptyDocSearchResults()); await alert.executor(payload); @@ -182,9 +179,7 @@ describe('rules_notification_alert_type', () => { references: [], attributes: ruleAlert, }); - alertServices.callCluster.mockResolvedValue({ - count: 10, - }); + alertServices.callCluster.mockResolvedValue(sampleDocSearchResultsNoSortIdNoVersion()); await alert.executor(payload); @@ -192,7 +187,7 @@ describe('rules_notification_alert_type', () => { const [{ value: alertInstanceMock }] = alertServices.alertInstanceFactory.mock.results; expect(alertInstanceMock.replaceState).toHaveBeenCalledWith( - expect.objectContaining({ signals_count: 10 }) + expect.objectContaining({ signals_count: 100 }) ); expect(alertInstanceMock.scheduleActions).toHaveBeenCalledWith( 'default', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts index 802b9472a4487..6bda5a599c83d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/rules_notification_alert_type.ts @@ -6,15 +6,19 @@ import { Logger } from 'src/core/server'; import { schema } from '@kbn/config-schema'; -import { NOTIFICATIONS_ID, SERVER_APP_ID } from '../../../../common/constants'; +import { + DEFAULT_RULE_NOTIFICATION_QUERY_SIZE, + NOTIFICATIONS_ID, + SERVER_APP_ID, +} from '../../../../common/constants'; import { NotificationAlertTypeDefinition } from './types'; -import { getSignalsCount } from './get_signals_count'; import { RuleAlertAttributes } from '../signals/types'; import { siemRuleActionGroups } from '../signals/siem_rule_action_groups'; import { scheduleNotificationActions } from './schedule_notification_actions'; import { getNotificationResultsLink } from './utils'; import { parseScheduleDates } from '../../../../common/detection_engine/parse_schedule_dates'; +import { getSignals } from './get_signals'; export const rulesNotificationAlertType = ({ logger, @@ -31,6 +35,7 @@ export const rulesNotificationAlertType = ({ ruleAlertId: schema.string(), }), }, + minimumLicenseRequired: 'basic', async executor({ startedAt, previousStartedAt, alertId, services, params }) { const ruleAlertSavedObject = await services.savedObjectsClient.get( 'alert', @@ -52,14 +57,20 @@ export const rulesNotificationAlertType = ({ )?.format('x'); const toInMs = parseScheduleDates(startedAt.toISOString())?.format('x'); - const signalsCount = await getSignalsCount({ + const results = await getSignals({ from: fromInMs, to: toInMs, + size: DEFAULT_RULE_NOTIFICATION_QUERY_SIZE, index: ruleParams.outputIndex, ruleId: ruleParams.ruleId, callCluster: services.callCluster, }); + const signals = results.hits.hits.map((hit) => hit._source); + + const signalsCount = + typeof results.hits.total === 'number' ? results.hits.total : results.hits.total.value; + const resultsLink = getNotificationResultsLink({ from: fromInMs, to: toInMs, @@ -74,7 +85,13 @@ export const rulesNotificationAlertType = ({ if (signalsCount !== 0) { const alertInstance = services.alertInstanceFactory(alertId); - scheduleNotificationActions({ alertInstance, signalsCount, resultsLink, ruleParams }); + scheduleNotificationActions({ + alertInstance, + signalsCount, + resultsLink, + ruleParams, + signals, + }); } }, }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_notification_actions.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_notification_actions.ts index a26ddfc90434a..bc697b08c5d29 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_notification_actions.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_notification_actions.ts @@ -6,6 +6,7 @@ import { mapKeys, snakeCase } from 'lodash/fp'; import { AlertInstance } from '../../../../../alerts/server'; +import { SignalSource } from '../signals/types'; import { RuleTypeParams } from '../types'; export type NotificationRuleTypeParams = RuleTypeParams & { @@ -18,6 +19,7 @@ interface ScheduleNotificationActions { signalsCount: number; resultsLink: string; ruleParams: NotificationRuleTypeParams; + signals: SignalSource[]; } export const scheduleNotificationActions = ({ @@ -25,6 +27,7 @@ export const scheduleNotificationActions = ({ signalsCount, resultsLink = '', ruleParams, + signals, }: ScheduleNotificationActions): AlertInstance => alertInstance .replaceState({ @@ -33,4 +36,5 @@ export const scheduleNotificationActions = ({ .scheduleActions('default', { results_link: resultsLink, rule: mapKeys(snakeCase, ruleParams), + alerts: signals, }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 3f8dcefd01e23..dc23297862a0b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -16,6 +16,7 @@ import { INTERNAL_IMMUTABLE_KEY, DETECTION_ENGINE_PREPACKAGED_URL, DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL, + DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL, } from '../../../../../common/constants'; import { ShardsResponse } from '../../../types'; import { @@ -32,6 +33,7 @@ import { getFinalizeSignalsMigrationSchemaMock } from '../../../../../common/det import { getListArrayMock } from '../../../../../common/detection_engine/schemas/types/lists.mock'; import { EqlSearchResponse } from '../../../../../common/detection_engine/types'; import { getThreatMock } from '../../../../../common/detection_engine/schemas/types/threat.mock'; +import { getSignalsMigrationStatusSchemaMock } from '../../../../../common/detection_engine/schemas/request/get_signals_migration_status_schema.mock'; export const typicalSetStatusSignalByIdsPayload = (): SetSignalsStatusSchemaDecoded => ({ signal_ids: ['somefakeid1', 'somefakeid2'], @@ -659,3 +661,10 @@ export const getFinalizeSignalsMigrationRequest = () => path: DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL, body: getFinalizeSignalsMigrationSchemaMock(), }); + +export const getSignalsMigrationStatusRequest = () => + requestMock.create({ + method: 'get', + path: DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL, + query: getSignalsMigrationStatusSchemaMock(), + }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/create_signals_migration_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/create_signals_migration_route.test.ts index 3e3a43855fa46..123bf538cdeb2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/create_signals_migration_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/create_signals_migration_route.test.ts @@ -4,84 +4,96 @@ * you may not use this file except in compliance with the Elastic License. */ -import { requestContextMock, requestMock, serverMock } from '../__mocks__'; -import { createSignalsMigrationRoute } from './create_signals_migration_route'; -import { - getIndexMappingsResponseMock, - getMigrationStatusSearchResponseMock, -} from '../../migrations/get_migration_status.mock'; +import { requestMock, serverMock } from '../__mocks__'; +import { SetupPlugins } from '../../../../plugin'; import { SignalsReindexOptions } from '../../../../../common/detection_engine/schemas/request/create_signals_migration_schema'; import { DETECTION_ENGINE_SIGNALS_MIGRATION_URL } from '../../../../../common/constants'; import { getCreateSignalsMigrationSchemaMock } from '../../../../../common/detection_engine/schemas/request/create_signals_migration_schema.mock'; +import { getIndexVersionsByIndex } from '../../migrations/get_index_versions_by_index'; +import { getSignalVersionsByIndex } from '../../migrations/get_signal_versions_by_index'; +import { createMigration } from '../../migrations/create_migration'; +import { getIndexAliases } from '../../index/get_index_aliases'; +import { getTemplateVersion } from '../index/check_template_version'; +import { createSignalsMigrationRoute } from './create_signals_migration_route'; +import { SIGNALS_TEMPLATE_VERSION } from '../index/get_signals_template'; + +jest.mock('../index/check_template_version'); +jest.mock('../../index/get_index_aliases'); +jest.mock('../../migrations/create_migration'); +jest.mock('../../migrations/get_index_versions_by_index'); +jest.mock('../../migrations/get_signal_versions_by_index'); -describe('query for signal', () => { +describe('creating signals migrations route', () => { let server: ReturnType; - let { clients, context } = requestContextMock.createTools(); beforeEach(() => { server = serverMock.create(); - ({ clients, context } = requestContextMock.createTools()); - - // @ts-expect-error mocking the bare minimum of our queries - // get our migration status - clients.newClusterClient.asCurrentUser.search.mockResolvedValueOnce({ - body: getMigrationStatusSearchResponseMock(['my-index']), - }); - // @ts-expect-error mocking the bare minimum of our queries - // get our signals aliases - clients.newClusterClient.asCurrentUser.indices.getAlias.mockResolvedValueOnce({ - body: { 'my-index': { aliases: {} } }, - }); + (getIndexAliases as jest.Mock).mockResolvedValue([ + { index: 'my-signals-index', isWriteIndex: false }, + ]); + (getTemplateVersion as jest.Mock).mockResolvedValue(SIGNALS_TEMPLATE_VERSION); + (getIndexVersionsByIndex as jest.Mock).mockResolvedValue({ 'my-signals-index': -1 }); + (getSignalVersionsByIndex as jest.Mock).mockResolvedValue({ 'my-signals-index': [] }); - // @ts-expect-error mocking the bare minimum of our queries - // get our index version - clients.newClusterClient.asCurrentUser.indices.getMapping.mockResolvedValueOnce({ - body: getIndexMappingsResponseMock('my-index'), - }); + const securityMock = ({ + authc: { + getCurrentUser: jest.fn().mockReturnValue({ user: { username: 'my-username' } }), + }, + } as unknown) as SetupPlugins['security']; - createSignalsMigrationRoute(server.router); + createSignalsMigrationRoute(server.router, securityMock); }); - test('passes reindex options along to the reindex call', async () => { + it('passes options to the createMigration', async () => { const reindexOptions: SignalsReindexOptions = { requests_per_second: 4, size: 10, slices: 2 }; const request = requestMock.create({ method: 'post', path: DETECTION_ENGINE_SIGNALS_MIGRATION_URL, - body: { ...getCreateSignalsMigrationSchemaMock('my-index'), ...reindexOptions }, + body: { ...getCreateSignalsMigrationSchemaMock('my-signals-index'), ...reindexOptions }, }); - const response = await server.inject(request, context); + const response = await server.inject(request); expect(response.status).toEqual(200); - expect(clients.newClusterClient.asCurrentUser.reindex).toHaveBeenCalledWith( + expect(createMigration).toHaveBeenCalledWith( expect.objectContaining({ - body: expect.objectContaining({ - source: { - index: 'my-index', - size: reindexOptions.size, - }, - }), - requests_per_second: reindexOptions.requests_per_second, - slices: reindexOptions.slices, + reindexOptions, + index: 'my-signals-index', }) ); }); + it('rejects the request if template is not up to date', async () => { + (getTemplateVersion as jest.Mock).mockResolvedValue(SIGNALS_TEMPLATE_VERSION - 1); + const request = requestMock.create({ + method: 'post', + path: DETECTION_ENGINE_SIGNALS_MIGRATION_URL, + body: getCreateSignalsMigrationSchemaMock('my-signals-index'), + }); + const response = await server.inject(request); + + expect(response.status).toEqual(400); + expect(response.body).toEqual({ + message: expect.stringMatching( + /Cannot migrate due to the signals template being out of date\. Latest version: \[\d+\], template version: \[\d+\]\. Please visit Detections to automatically update your template, then try again\./ + ), + status_code: 400, + }); + }); + it('returns an inline error if write index is out of date but specified', async () => { - clients.appClient.getSignalsIndex.mockReturnValue('my-alias'); - // @ts-expect-error mocking the bare minimum of our queries // stub index to be write index. - clients.newClusterClient.asCurrentUser.indices.getAlias.mockReset().mockResolvedValueOnce({ - body: { 'my-index': { aliases: { 'my-alias': { is_write_index: true } } } }, - }); + (getIndexAliases as jest.Mock).mockResolvedValue([ + { index: 'my-signals-index', isWriteIndex: true }, + ]); const request = requestMock.create({ method: 'post', path: DETECTION_ENGINE_SIGNALS_MIGRATION_URL, - body: getCreateSignalsMigrationSchemaMock('my-index'), + body: getCreateSignalsMigrationSchemaMock('my-signals-index'), }); - const response = await server.inject(request, context); + const response = await server.inject(request); expect(response.status).toEqual(200); expect(response.body.indices).toEqual([ @@ -90,10 +102,9 @@ describe('query for signal', () => { message: 'The specified index is a write index and cannot be migrated.', status_code: 400, }, - index: 'my-index', + index: 'my-signals-index', + migration_id: null, migration_index: null, - migration_task_id: null, - migration_token: null, }, ]); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/create_signals_migration_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/create_signals_migration_route.ts index ca443484bcd9c..313cc37b20d88 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/create_signals_migration_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/create_signals_migration_route.ts @@ -5,18 +5,24 @@ */ import { IRouter } from 'src/core/server'; +import { SetupPlugins } from '../../../../plugin'; import { DETECTION_ENGINE_SIGNALS_MIGRATION_URL } from '../../../../../common/constants'; import { createSignalsMigrationSchema } from '../../../../../common/detection_engine/schemas/request/create_signals_migration_schema'; import { buildRouteValidation } from '../../../../utils/build_validation/route_validation'; -import { migrateSignals } from '../../migrations/migrate_signals'; import { buildSiemResponse, transformError } from '../utils'; import { getTemplateVersion } from '../index/check_template_version'; -import { getMigrationStatus } from '../../migrations/get_migration_status'; -import { encodeMigrationToken, indexIsOutdated } from '../../migrations/helpers'; +import { isOutdated, signalsAreOutdated } from '../../migrations/helpers'; import { getIndexAliases } from '../../index/get_index_aliases'; import { BadRequestError } from '../../errors/bad_request_error'; +import { signalsMigrationService } from '../../migrations/migration_service'; +import { getIndexVersionsByIndex } from '../../migrations/get_index_versions_by_index'; +import { getSignalVersionsByIndex } from '../../migrations/get_signal_versions_by_index'; +import { SIGNALS_TEMPLATE_VERSION } from '../index/get_signals_template'; -export const createSignalsMigrationRoute = (router: IRouter) => { +export const createSignalsMigrationRoute = ( + router: IRouter, + security: SetupPlugins['security'] +) => { router.post( { path: DETECTION_ENGINE_SIGNALS_MIGRATION_URL, @@ -29,20 +35,33 @@ export const createSignalsMigrationRoute = (router: IRouter) => { }, async (context, request, response) => { const siemResponse = buildSiemResponse(response); - const esClient = context.core.elasticsearch.client.asCurrentUser; const { index: indices, ...reindexOptions } = request.body; try { + const esClient = context.core.elasticsearch.client.asCurrentUser; + const soClient = context.core.savedObjects.client; const appClient = context.securitySolution?.getAppClient(); if (!appClient) { return siemResponse.error({ statusCode: 404 }); } + const user = await security?.authc.getCurrentUser(request); + const migrationService = signalsMigrationService({ + esClient, + soClient, + username: user?.username ?? 'elastic', + }); const signalsAlias = appClient.getSignalsIndex(); const currentVersion = await getTemplateVersion({ alias: signalsAlias, esClient, }); + + if (isOutdated({ current: currentVersion, target: SIGNALS_TEMPLATE_VERSION })) { + throw new BadRequestError( + `Cannot migrate due to the signals template being out of date. Latest version: [${SIGNALS_TEMPLATE_VERSION}], template version: [${currentVersion}]. Please visit Detections to automatically update your template, then try again.` + ); + } const signalsIndexAliases = await getIndexAliases({ esClient, alias: signalsAlias }); const nonSignalsIndices = indices.filter( @@ -54,11 +73,18 @@ export const createSignalsMigrationRoute = (router: IRouter) => { ); } - const migrationStatuses = await getMigrationStatus({ esClient, index: indices }); + const indexVersionsByIndex = await getIndexVersionsByIndex({ esClient, index: indices }); + const signalVersionsByIndex = await getSignalVersionsByIndex({ esClient, index: indices }); + const migrationResults = await Promise.all( indices.map(async (index) => { - const status = migrationStatuses.find(({ name }) => name === index); - if (indexIsOutdated({ status, version: currentVersion })) { + const indexVersion = indexVersionsByIndex[index] ?? 0; + const signalVersions = signalVersionsByIndex[index] ?? []; + + if ( + isOutdated({ current: indexVersion, target: currentVersion }) || + signalsAreOutdated({ signalVersions, target: currentVersion }) + ) { try { const isWriteIndex = signalsIndexAliases.some( (alias) => alias.isWriteIndex && alias.index === index @@ -69,19 +95,16 @@ export const createSignalsMigrationRoute = (router: IRouter) => { ); } - const migrationDetails = await migrateSignals({ - esClient, + const migration = await migrationService.create({ index, - version: currentVersion, reindexOptions, + version: currentVersion, }); - const migrationToken = encodeMigrationToken(migrationDetails); return { - index, - migration_index: migrationDetails.destinationIndex, - migration_task_id: migrationDetails.taskId, - migration_token: migrationToken, + index: migration.attributes.sourceIndex, + migration_id: migration.id, + migration_index: migration.attributes.destinationIndex, }; } catch (err) { const error = transformError(err); @@ -91,17 +114,15 @@ export const createSignalsMigrationRoute = (router: IRouter) => { message: error.message, status_code: error.statusCode, }, + migration_id: null, migration_index: null, - migration_task_id: null, - migration_token: null, }; } } else { return { index, + migration_id: null, migration_index: null, - migration_task_id: null, - migration_token: null, }; } }) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/delete_signals_migration_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/delete_signals_migration_route.ts new file mode 100644 index 0000000000000..2515a5fabe992 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/delete_signals_migration_route.ts @@ -0,0 +1,99 @@ +/* + * 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 { IRouter } from 'src/core/server'; +import { SetupPlugins } from '../../../../plugin'; +import { DETECTION_ENGINE_SIGNALS_MIGRATION_URL } from '../../../../../common/constants'; +import { deleteSignalsMigrationSchema } from '../../../../../common/detection_engine/schemas/request/delete_signals_migration_schema'; +import { buildRouteValidation } from '../../../../utils/build_validation/route_validation'; +import { buildSiemResponse, transformError } from '../utils'; +import { signalsMigrationService } from '../../migrations/migration_service'; +import { getMigrationSavedObjectsById } from '../../migrations/get_migration_saved_objects_by_id'; + +export const deleteSignalsMigrationRoute = ( + router: IRouter, + security: SetupPlugins['security'] +) => { + router.delete( + { + path: DETECTION_ENGINE_SIGNALS_MIGRATION_URL, + validate: { + body: buildRouteValidation(deleteSignalsMigrationSchema), + }, + options: { + tags: ['access:securitySolution'], + }, + }, + async (context, request, response) => { + const siemResponse = buildSiemResponse(response); + const { migration_ids: migrationIds } = request.body; + + try { + const esClient = context.core.elasticsearch.client.asCurrentUser; + const soClient = context.core.savedObjects.client; + const appClient = context.securitySolution?.getAppClient(); + if (!appClient) { + return siemResponse.error({ statusCode: 404 }); + } + + const user = await security?.authc.getCurrentUser(request); + const migrationService = signalsMigrationService({ + esClient, + soClient, + username: user?.username ?? 'elastic', + }); + + const signalsAlias = appClient.getSignalsIndex(); + const migrations = await getMigrationSavedObjectsById({ + ids: migrationIds, + soClient, + }); + + const deletionResults = await Promise.all( + migrations.map(async (migration) => { + try { + const deletedMigration = await migrationService.delete({ + migration, + signalsAlias, + }); + + return { + id: deletedMigration.id, + destinationIndex: deletedMigration.attributes.destinationIndex, + status: deletedMigration.attributes.status, + sourceIndex: deletedMigration.attributes.sourceIndex, + version: deletedMigration.attributes.version, + updated: deletedMigration.attributes.updated, + }; + } catch (err) { + const error = transformError(err); + return { + id: migration.id, + destinationIndex: migration.attributes.destinationIndex, + error: { + message: error.message, + status_code: error.statusCode, + }, + status: migration.attributes.status, + sourceIndex: migration.attributes.sourceIndex, + version: migration.attributes.version, + updated: migration.attributes.updated, + }; + } + }) + ); + + return response.ok({ body: { migrations: deletionResults } }); + } catch (err) { + const error = transformError(err); + return siemResponse.error({ + body: error.message, + statusCode: error.statusCode, + }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/finalize_signals_migration_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/finalize_signals_migration_route.test.ts index 207e4c614b9ad..72ff9a54454f3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/finalize_signals_migration_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/finalize_signals_migration_route.test.ts @@ -4,46 +4,61 @@ * you may not use this file except in compliance with the Elastic License. */ +import { serverMock } from '../__mocks__'; +import { SetupPlugins } from '../../../../plugin'; import { getFinalizeSignalsMigrationRequest } from '../__mocks__/request_responses'; -import { requestContextMock, serverMock } from '../__mocks__'; +import { getMigrationSavedObjectsById } from '../../migrations/get_migration_saved_objects_by_id'; +import { getSignalsMigrationSavedObjectMock } from '../../migrations/saved_objects_schema.mock'; import { finalizeSignalsMigrationRoute } from './finalize_signals_migration_route'; -describe('query for signal', () => { +jest.mock('../../migrations/get_migration_saved_objects_by_id'); + +describe('finalizing signals migrations', () => { let server: ReturnType; - let { clients, context } = requestContextMock.createTools(); beforeEach(() => { server = serverMock.create(); - ({ clients, context } = requestContextMock.createTools()); - - // @ts-expect-error mocking the bare minimum of the response - // get our completed task - clients.newClusterClient.asCurrentUser.tasks.get.mockResolvedValueOnce({ - body: { - completed: true, - response: {}, - // satisfies our "is this the right task" validation - task: { description: 'reindexing from sourceIndex to destinationIndex' }, - }, - }); - // @ts-expect-error mocking the bare minimum of the response - // count of original index - clients.newClusterClient.asCurrentUser.count.mockResolvedValueOnce({ body: { count: 1 } }); - // @ts-expect-error mocking the bare minimum of the response - // count of migrated index - clients.newClusterClient.asCurrentUser.count.mockResolvedValueOnce({ body: { count: 2 } }); + const securityMock = ({ + authc: { + getCurrentUser: jest.fn().mockReturnValue({ user: { username: 'my-username' } }), + }, + } as unknown) as SetupPlugins['security']; + finalizeSignalsMigrationRoute(server.router, securityMock); + }); - finalizeSignalsMigrationRoute(server.router); + it('returns an empty array error if no migrations exists', async () => { + (getMigrationSavedObjectsById as jest.Mock).mockResolvedValue([]); + const response = await server.inject(getFinalizeSignalsMigrationRequest()); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + migrations: [], + }); }); - test('returns an error if migration index size does not match the original index', async () => { - const response = await server.inject(getFinalizeSignalsMigrationRequest(), context); - expect(response.status).toEqual(500); + it('returns an inline error if a migration failed', async () => { + const mockMigrations = [ + getSignalsMigrationSavedObjectMock({ status: 'failure' }), + getSignalsMigrationSavedObjectMock(), + ]; + (getMigrationSavedObjectsById as jest.Mock).mockResolvedValue(mockMigrations); + + const response = await server.inject(getFinalizeSignalsMigrationRequest()); + expect(response.status).toEqual(200); expect(response.body).toEqual({ - message: - 'The source and destination indexes have different document counts. Source [sourceIndex] has [1] documents, while destination [destinationIndex] has [2] documents.', - status_code: 500, + migrations: [ + expect.objectContaining({ + id: mockMigrations[0].id, + error: { + message: 'The migration was not successful.', + status_code: 400, + }, + status: 'failure', + }), + expect.objectContaining({ + id: mockMigrations[1].id, + }), + ], }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/finalize_signals_migration_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/finalize_signals_migration_route.ts index d2f619c218d92..2c02c0768dadf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/finalize_signals_migration_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/finalize_signals_migration_route.ts @@ -4,26 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ReindexResponse } from 'elasticsearch'; - import { IRouter } from 'src/core/server'; +import { SetupPlugins } from '../../../../plugin'; import { DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL } from '../../../../../common/constants'; import { finalizeSignalsMigrationSchema } from '../../../../../common/detection_engine/schemas/request/finalize_signals_migration_schema'; import { buildRouteValidation } from '../../../../utils/build_validation/route_validation'; import { BadRequestError } from '../../errors/bad_request_error'; -import { getIndexCount } from '../../index/get_index_count'; -import { decodeMigrationToken } from '../../migrations/helpers'; -import { applyMigrationCleanupPolicy } from '../../migrations/migration_cleanup'; -import { replaceSignalsIndexAlias } from '../../migrations/replace_signals_index_alias'; +import { isMigrationFailed, isMigrationPending } from '../../migrations/helpers'; +import { signalsMigrationService } from '../../migrations/migration_service'; import { buildSiemResponse, transformError } from '../utils'; +import { getMigrationSavedObjectsById } from '../../migrations/get_migration_saved_objects_by_id'; -interface TaskResponse { - completed: boolean; - response?: ReindexResponse; - task: { description?: string }; -} - -export const finalizeSignalsMigrationRoute = (router: IRouter) => { +export const finalizeSignalsMigrationRoute = ( + router: IRouter, + security: SetupPlugins['security'] +) => { router.post( { path: DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL, @@ -37,67 +32,68 @@ export const finalizeSignalsMigrationRoute = (router: IRouter) => { async (context, request, response) => { const siemResponse = buildSiemResponse(response); const esClient = context.core.elasticsearch.client.asCurrentUser; - const { migration_token: migrationToken } = request.body; + const soClient = context.core.savedObjects.client; + const { migration_ids: migrationIds } = request.body; try { const appClient = context.securitySolution?.getAppClient(); if (!appClient) { return siemResponse.error({ statusCode: 404 }); } - - const { destinationIndex, sourceIndex, taskId } = decodeMigrationToken(migrationToken); - const { body: task } = await esClient.tasks.get({ task_id: taskId }); - - if (!task.completed) { - return response.ok({ - body: { - completed: false, - index: sourceIndex, - migration_index: destinationIndex, - migration_task_id: taskId, - migration_token: migrationToken, - }, - }); - } - - const { description } = task.task; - if ( - !description || - !description.includes(destinationIndex) || - !description.includes(sourceIndex) - ) { - throw new BadRequestError( - `The specified task does not match the source and destination indexes. Task [${taskId}] did not specify source index [${sourceIndex}] and destination index [${destinationIndex}]` - ); - } - - const sourceCount = await getIndexCount({ esClient, index: sourceIndex }); - const destinationCount = await getIndexCount({ esClient, index: destinationIndex }); - if (sourceCount !== destinationCount) { - throw new Error( - `The source and destination indexes have different document counts. Source [${sourceIndex}] has [${sourceCount}] documents, while destination [${destinationIndex}] has [${destinationCount}] documents.` - ); - } - - const signalsIndex = appClient.getSignalsIndex(); - await replaceSignalsIndexAlias({ - alias: signalsIndex, + const user = await security?.authc.getCurrentUser(request); + const migrationService = signalsMigrationService({ esClient, - newIndex: destinationIndex, - oldIndex: sourceIndex, + soClient, + username: user?.username ?? 'elastic', }); + const migrations = await getMigrationSavedObjectsById({ + ids: migrationIds, + soClient, + }); + + const finalizeResults = await Promise.all( + migrations.map(async (migration) => { + try { + const finalizedMigration = await migrationService.finalize({ + migration, + signalsAlias: appClient.getSignalsIndex(), + }); + + if (isMigrationFailed(finalizedMigration)) { + throw new BadRequestError( + finalizedMigration.attributes.error ?? 'The migration was not successful.' + ); + } - await applyMigrationCleanupPolicy({ alias: signalsIndex, esClient, index: sourceIndex }); - await esClient.delete({ index: '.tasks', id: taskId }); + return { + id: finalizedMigration.id, + completed: !isMigrationPending(finalizedMigration), + destinationIndex: finalizedMigration.attributes.destinationIndex, + status: finalizedMigration.attributes.status, + sourceIndex: finalizedMigration.attributes.sourceIndex, + version: finalizedMigration.attributes.version, + updated: finalizedMigration.attributes.updated, + }; + } catch (err) { + const error = transformError(err); + return { + id: migration.id, + destinationIndex: migration.attributes.destinationIndex, + error: { + message: error.message, + status_code: error.statusCode, + }, + status: migration.attributes.status, + sourceIndex: migration.attributes.sourceIndex, + version: migration.attributes.version, + updated: migration.attributes.updated, + }; + } + }) + ); return response.ok({ - body: { - completed: true, - index: sourceIndex, - migration_index: destinationIndex, - migration_task_id: taskId, - migration_token: migrationToken, - }, + body: { migrations: finalizeResults }, }); } catch (err) { const error = transformError(err); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/get_signals_migration_status_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/get_signals_migration_status_route.test.ts new file mode 100644 index 0000000000000..9573c916d172c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/get_signals_migration_status_route.test.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 { getSignalsMigrationStatusRequest } from '../__mocks__/request_responses'; +import { getSignalsMigrationSavedObjectMock } from '../../migrations/saved_objects_schema.mock'; +import { serverMock } from '../__mocks__'; +import { getMigrationSavedObjectsByIndex } from '../../migrations/get_migration_saved_objects_by_index'; +import { getSignalVersionsByIndex } from '../../migrations/get_signal_versions_by_index'; +import { getSignalsMigrationStatusRoute } from './get_signals_migration_status_route'; +import { getSignalsIndicesInRange } from '../../migrations/get_signals_indices_in_range'; + +jest.mock('../../migrations/get_signals_indices_in_range'); +jest.mock('../../migrations/get_signal_versions_by_index'); +jest.mock('../../migrations/get_migration_saved_objects_by_index'); + +describe('get signals migration status', () => { + let server: ReturnType; + + beforeEach(() => { + server = serverMock.create(); + getSignalsMigrationStatusRoute(server.router); + + (getSignalsIndicesInRange as jest.Mock).mockResolvedValueOnce(['my-signals-index']); + (getSignalVersionsByIndex as jest.Mock).mockResolvedValueOnce({ + 'my-signals-index': [], + }); + }); + + it('returns statuses by index', async () => { + const migration = getSignalsMigrationSavedObjectMock(); + (getMigrationSavedObjectsByIndex as jest.Mock).mockResolvedValueOnce({ + 'my-signals-index': [migration], + }); + + const response = await server.inject(getSignalsMigrationStatusRequest()); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + indices: [ + { + index: 'my-signals-index', + is_outdated: false, + migrations: [ + expect.objectContaining({ + id: migration.id, + status: migration.attributes.status, + updated: migration.attributes.updated, + version: migration.attributes.version, + }), + ], + signal_versions: [], + version: 0, + }, + ], + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/get_signals_migration_status_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/get_signals_migration_status_route.ts index 85c38fcdeb01c..ed6546b0bf4f1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/get_signals_migration_status_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/get_signals_migration_status_route.ts @@ -6,12 +6,14 @@ import { IRouter } from 'src/core/server'; import { DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL } from '../../../../../common/constants'; -import { getMigrationStatusSchema } from '../../../../../common/detection_engine/schemas/request/get_migration_status_schema'; +import { getSignalsMigrationStatusSchema } from '../../../../../common/detection_engine/schemas/request/get_signals_migration_status_schema'; import { buildRouteValidation } from '../../../../utils/build_validation/route_validation'; import { getIndexAliases } from '../../index/get_index_aliases'; -import { getMigrationStatus } from '../../migrations/get_migration_status'; +import { getIndexVersionsByIndex } from '../../migrations/get_index_versions_by_index'; +import { getMigrationSavedObjectsByIndex } from '../../migrations/get_migration_saved_objects_by_index'; import { getSignalsIndicesInRange } from '../../migrations/get_signals_indices_in_range'; -import { indexIsOutdated } from '../../migrations/helpers'; +import { getSignalVersionsByIndex } from '../../migrations/get_signal_versions_by_index'; +import { isOutdated, signalsAreOutdated } from '../../migrations/helpers'; import { getTemplateVersion } from '../index/check_template_version'; import { buildSiemResponse, transformError } from '../utils'; @@ -20,7 +22,7 @@ export const getSignalsMigrationStatusRoute = (router: IRouter) => { { path: DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL, validate: { - query: buildRouteValidation(getMigrationStatusSchema), + query: buildRouteValidation(getSignalsMigrationStatusSchema), }, options: { tags: ['access:securitySolution'], @@ -29,13 +31,13 @@ export const getSignalsMigrationStatusRoute = (router: IRouter) => { async (context, request, response) => { const siemResponse = buildSiemResponse(response); const esClient = context.core.elasticsearch.client.asCurrentUser; + const soClient = context.core.savedObjects.client; try { const appClient = context.securitySolution?.getAppClient(); if (!appClient) { return siemResponse.error({ statusCode: 404 }); } - const { from } = request.query; const signalsAlias = appClient.getSignalsIndex(); @@ -47,13 +49,41 @@ export const getSignalsMigrationStatusRoute = (router: IRouter) => { index: signalsIndices, from, }); - const migrationStatuses = await getMigrationStatus({ esClient, index: indicesInRange }); - const enrichedStatuses = migrationStatuses.map((status) => ({ - ...status, - is_outdated: indexIsOutdated({ status, version: currentVersion }), - })); + const migrationsByIndex = await getMigrationSavedObjectsByIndex({ + index: indicesInRange, + soClient, + }); + const indexVersionsByIndex = await getIndexVersionsByIndex({ + esClient, + index: indicesInRange, + }); + const signalVersionsByIndex = await getSignalVersionsByIndex({ + esClient, + index: indicesInRange, + }); + + const indexStatuses = indicesInRange.map((index) => { + const version = indexVersionsByIndex[index] ?? 0; + const signalVersions = signalVersionsByIndex[index] ?? []; + const migrations = migrationsByIndex[index] ?? []; + + return { + index, + version, + signal_versions: signalVersions, + migrations: migrations.map((m) => ({ + id: m.id, + status: m.attributes.status, + version: m.attributes.version, + updated: m.attributes.updated, + })), + is_outdated: + isOutdated({ current: version, target: currentVersion }) || + signalsAreOutdated({ signalVersions, target: currentVersion }), + }; + }); - return response.ok({ body: { indices: enrichedStatuses } }); + return response.ok({ body: { indices: indexStatuses } }); } catch (err) { const error = transformError(err); return siemResponse.error({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/signals/delete_signals_migration.sh b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/signals/delete_signals_migration.sh new file mode 100755 index 0000000000000..ca7062719d51d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/signals/delete_signals_migration.sh @@ -0,0 +1,19 @@ +#!/bin/sh + +# +# 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. +# + +set -e +./check_env_variables.sh + +# Example: ./signals/delete_signals_migration.sh my-migration-id + curl -s -k \ + -H 'Content-Type: application/json' \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X DELETE ${KIBANA_URL}${SPACE_URL}/api/detection_engine/signals/migration \ + -d "{\"migration_ids\": [\"$1\"]}" \ + | jq . diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/signals/finalize_signals_migration.sh b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/signals/finalize_signals_migration.sh index 1bbc0eef50146..e73da83985a09 100755 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/signals/finalize_signals_migration.sh +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/signals/finalize_signals_migration.sh @@ -9,11 +9,11 @@ set -e ./check_env_variables.sh -# Example: ./signals/finalize_signals_migration.sh eyJkZXN0aW5hdGlvbkluZGV4IjoiZGVzdGluYXRpb25JbmRleCIsInNvdXJjZUluZGV4Ijoic291cmNlSW5kZXgiLCJ0YXNrSWQiOm51bGx9 +# Example: ./signals/finalize_signals_migration.sh my-migration-id curl -s -k \ -H 'Content-Type: application/json' \ -H 'kbn-xsrf: 123' \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ -X POST ${KIBANA_URL}${SPACE_URL}/api/detection_engine/signals/finalize_migration \ - -d "{\"migration_token\": \"$1\"}" \ + -d "{\"migration_ids\": [\"$1\"]}" \ | jq . diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts index 764604a793788..6718ff2d1f15f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts @@ -12,6 +12,7 @@ import { BulkItem, RuleAlertAttributes, SignalHit, + WrappedSignalHit, } from '../types'; import { Logger, @@ -240,6 +241,14 @@ export const sampleEmptyDocSearchResults = (): SignalSearchResponse => ({ }, }); +export const sampleWrappedSignalHit = (): WrappedSignalHit => { + return { + _index: 'myFakeSignalIndex', + _id: sampleIdGuid, + _source: sampleSignalHit(), + }; +}; + export const sampleDocWithAncestors = (): SignalSearchResponse => { const sampleDoc = sampleDocNoSortId(); delete sampleDoc.sort; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts index f81000091749a..6f04811feaef4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts @@ -11,7 +11,7 @@ import { sampleIdGuid, sampleDocWithAncestors, sampleRuleSO, - sampleDocNoSortIdNoVersion, + sampleWrappedSignalHit, } from './__mocks__/es_results'; import { buildBulkBody, @@ -776,10 +776,10 @@ describe('buildBulkBody', () => { describe('buildSignalFromSequence', () => { test('builds a basic signal from a sequence of building blocks', () => { - const block1 = sampleDocWithAncestors().hits.hits[0]; + const block1 = sampleWrappedSignalHit(); block1._source.new_key = 'new_key_value'; block1._source.new_key2 = 'new_key2_value'; - const block2 = sampleDocWithAncestors().hits.hits[0]; + const block2 = sampleWrappedSignalHit(); block2._source.new_key = 'new_key_value'; const blocks = [block1, block2]; const ruleSO = sampleRuleSO(); @@ -787,8 +787,7 @@ describe('buildSignalFromSequence', () => { // Timestamp will potentially always be different so remove it for the test // @ts-expect-error delete signal['@timestamp']; - const expected: Omit & { someKey: string; new_key: string } = { - someKey: 'someValue', + const expected: Omit & { new_key: string } = { new_key: 'new_key_value', event: { kind: 'signal', @@ -800,14 +799,14 @@ describe('buildSignalFromSequence', () => { parents: [ { id: sampleIdGuid, - rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + rule: '7a7065d7-6e8b-4aae-8d20-c93613dec9f9', type: 'signal', index: 'myFakeSignalIndex', depth: 1, }, { id: sampleIdGuid, - rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + rule: '7a7065d7-6e8b-4aae-8d20-c93613dec9f9', type: 'signal', index: 'myFakeSignalIndex', depth: 1, @@ -820,9 +819,15 @@ describe('buildSignalFromSequence', () => { index: 'myFakeSignalIndex', depth: 0, }, + { + id: '730ddf9e-5a00-4f85-9ddf-5878ca511a87', + type: 'event', + index: 'myFakeSignalIndex', + depth: 0, + }, { id: sampleIdGuid, - rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + rule: '7a7065d7-6e8b-4aae-8d20-c93613dec9f9', type: 'signal', index: 'myFakeSignalIndex', depth: 1, @@ -833,9 +838,15 @@ describe('buildSignalFromSequence', () => { index: 'myFakeSignalIndex', depth: 0, }, + { + id: '730ddf9e-5a00-4f85-9ddf-5878ca511a87', + type: 'event', + index: 'myFakeSignalIndex', + depth: 0, + }, { id: sampleIdGuid, - rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + rule: '7a7065d7-6e8b-4aae-8d20-c93613dec9f9', type: 'signal', index: 'myFakeSignalIndex', depth: 1, @@ -889,8 +900,8 @@ describe('buildSignalFromSequence', () => { }); test('builds a basic signal if there is no overlap between source events', () => { - const block1 = sampleDocNoSortIdNoVersion(); - const block2 = sampleDocNoSortIdNoVersion(); + const block1 = sampleWrappedSignalHit(); + const block2 = sampleWrappedSignalHit(); block2._source['@timestamp'] = '2021-05-20T22:28:46+0000'; block2._source.someKey = 'someOtherValue'; const ruleSO = sampleRuleSO(); @@ -909,30 +920,58 @@ describe('buildSignalFromSequence', () => { parents: [ { id: sampleIdGuid, + type: 'signal', + index: 'myFakeSignalIndex', + depth: 1, + rule: '7a7065d7-6e8b-4aae-8d20-c93613dec9f9', + }, + { + id: sampleIdGuid, + type: 'signal', + index: 'myFakeSignalIndex', + depth: 1, + rule: '7a7065d7-6e8b-4aae-8d20-c93613dec9f9', + }, + ], + ancestors: [ + { + id: 'd5e8eb51-a6a0-456d-8a15-4b79bfec3d71', type: 'event', index: 'myFakeSignalIndex', depth: 0, }, { - id: sampleIdGuid, + id: '730ddf9e-5a00-4f85-9ddf-5878ca511a87', type: 'event', index: 'myFakeSignalIndex', depth: 0, }, - ], - ancestors: [ { id: sampleIdGuid, + type: 'signal', + index: 'myFakeSignalIndex', + depth: 1, + rule: '7a7065d7-6e8b-4aae-8d20-c93613dec9f9', + }, + { + id: 'd5e8eb51-a6a0-456d-8a15-4b79bfec3d71', type: 'event', index: 'myFakeSignalIndex', depth: 0, }, { - id: sampleIdGuid, + id: '730ddf9e-5a00-4f85-9ddf-5878ca511a87', type: 'event', index: 'myFakeSignalIndex', depth: 0, }, + { + id: sampleIdGuid, + type: 'signal', + index: 'myFakeSignalIndex', + depth: 1, + rule: '7a7065d7-6e8b-4aae-8d20-c93613dec9f9', + }, ], status: 'open', rule: { @@ -972,7 +1011,7 @@ describe('buildSignalFromSequence', () => { throttle: 'no_actions', exceptions_list: getListArrayMock(), }, - depth: 1, + depth: 2, group: { id: '269c1f5754bff92fb8040283b687258e99b03e8b2ab1262cc20c82442e5de5ea', }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts index e6e6d2e0f5fa9..5f103d25e5ace 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts @@ -12,6 +12,7 @@ import { RuleAlertAttributes, BaseSignalHit, SignalSource, + WrappedSignalHit, } from './types'; import { buildRule, buildRuleWithoutOverrides, buildRuleWithOverrides } from './build_rule'; import { additionalSignalFields, buildSignal } from './build_signal'; @@ -94,7 +95,7 @@ export const buildSignalGroupFromSequence = ( sequence: EqlSequence, ruleSO: SavedObject, outputIndex: string -): BaseSignalHit[] => { +): WrappedSignalHit[] => { const wrappedBuildingBlocks = wrapBuildingBlocks( sequence.events.map((event) => { const signal = buildSignalFromEvent(event, ruleSO, false); @@ -132,7 +133,7 @@ export const buildSignalGroupFromSequence = ( }; export const buildSignalFromSequence = ( - events: BaseSignalHit[], + events: WrappedSignalHit[], ruleSO: SavedObject ): SignalHit => { const rule = buildRuleWithoutOverrides(ruleSO); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/rule_status_service.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/rule_status_service.test.ts index 449ecd11257d7..44b38a80bf1b7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/rule_status_service.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/rule_status_service.test.ts @@ -13,7 +13,7 @@ import { } from './rule_status_service'; import { exampleRuleStatus, exampleFindRuleStatusResponse } from './__mocks__/es_results'; -const expectIsoDateString = expect.stringMatching(/Z$/); +const expectIsoDateString = expect.stringMatching(/2.*Z$/); const buildStatuses = (n: number) => Array(n) .fill(exampleRuleStatus()) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts index 46722c69e53e3..67246a830ce90 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts @@ -53,9 +53,6 @@ describe('searchAfterAndBulkCreate', () => { took: 100, errors: false, items: [ - { - fakeItemValue: 'fakeItemKey', - }, { create: { status: 201, @@ -68,9 +65,6 @@ describe('searchAfterAndBulkCreate', () => { took: 100, errors: false, items: [ - { - fakeItemValue: 'fakeItemKey', - }, { create: { status: 201, @@ -83,9 +77,6 @@ describe('searchAfterAndBulkCreate', () => { took: 100, errors: false, items: [ - { - fakeItemValue: 'fakeItemKey', - }, { create: { status: 201, @@ -98,9 +89,6 @@ describe('searchAfterAndBulkCreate', () => { took: 100, errors: false, items: [ - { - fakeItemValue: 'fakeItemKey', - }, { create: { status: 201, @@ -164,9 +152,6 @@ describe('searchAfterAndBulkCreate', () => { took: 100, errors: false, items: [ - { - fakeItemValue: 'fakeItemKey', - }, { create: { status: 201, @@ -179,9 +164,6 @@ describe('searchAfterAndBulkCreate', () => { took: 100, errors: false, items: [ - { - fakeItemValue: 'fakeItemKey', - }, { create: { status: 201, @@ -194,9 +176,6 @@ describe('searchAfterAndBulkCreate', () => { took: 100, errors: false, items: [ - { - fakeItemValue: 'fakeItemKey', - }, { create: { status: 201, @@ -210,9 +189,6 @@ describe('searchAfterAndBulkCreate', () => { took: 100, errors: false, items: [ - { - fakeItemValue: 'fakeItemKey', - }, { create: { status: 201, @@ -225,9 +201,6 @@ describe('searchAfterAndBulkCreate', () => { took: 100, errors: false, items: [ - { - fakeItemValue: 'fakeItemKey', - }, { create: { status: 201, @@ -290,9 +263,6 @@ describe('searchAfterAndBulkCreate', () => { took: 100, errors: false, items: [ - { - fakeItemValue: 'fakeItemKey', - }, { create: { status: 201, @@ -503,9 +473,6 @@ describe('searchAfterAndBulkCreate', () => { took: 100, errors: false, items: [ - { - fakeItemValue: 'fakeItemKey', - }, { create: { status: 201, @@ -587,9 +554,6 @@ describe('searchAfterAndBulkCreate', () => { took: 100, errors: false, items: [ - { - fakeItemValue: 'fakeItemKey', - }, { create: { status: 201, @@ -767,9 +731,6 @@ describe('searchAfterAndBulkCreate', () => { took: 100, errors: false, items: [ - { - fakeItemValue: 'fakeItemKey', - }, { create: { status: 201, @@ -862,9 +823,6 @@ describe('searchAfterAndBulkCreate', () => { took: 100, errors: false, items: [ - { - fakeItemValue: 'fakeItemKey', - }, { create: { status: 201, @@ -877,9 +835,6 @@ describe('searchAfterAndBulkCreate', () => { took: 100, errors: false, items: [ - { - fakeItemValue: 'fakeItemKey', - }, { create: { status: 201, @@ -892,9 +847,6 @@ describe('searchAfterAndBulkCreate', () => { took: 100, errors: false, items: [ - { - fakeItemValue: 'fakeItemKey', - }, { create: { status: 201, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts index 32865e117cba9..b79f758cd7503 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts @@ -156,6 +156,7 @@ export const searchAfterAndBulkCreate = async ({ const { bulkCreateDuration: bulkDuration, createdItemsCount: createdCount, + createdItems, success: bulkSuccess, errors: bulkErrors, } = await singleBulkCreate({ @@ -183,6 +184,7 @@ export const searchAfterAndBulkCreate = async ({ createSearchAfterReturnType({ success: bulkSuccess, createdSignalsCount: createdCount, + createdSignals: createdItems, bulkCreateTimes: bulkDuration ? [bulkDuration] : undefined, errors: bulkErrors, }), diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts index dc68e3949eb36..9a40573095a1a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts @@ -518,6 +518,7 @@ describe('rules_notification_alert_type', () => { bulkCreateTimes: [], lastLookBackDate: null, createdSignalsCount: 0, + createdSignals: [], errors: ['Error that bubbled up.'], }; (searchAfterAndBulkCreate as jest.Mock).mockResolvedValue(result); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 8d4dd877996db..7fd99a17598ae 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -29,8 +29,8 @@ import { SignalRuleAlertTypeDefinition, RuleAlertAttributes, EqlSignalSearchResponse, - BaseSignalHit, ThresholdQueryBucket, + WrappedSignalHit, } from './types'; import { getGapBetweenRuns, @@ -91,6 +91,7 @@ export const signalRulesAlertType = ({ params: signalParamsSchema(), }, producer: SERVER_APP_ID, + minimumLicenseRequired: 'basic', async executor({ previousStartedAt, startedAt, @@ -265,6 +266,7 @@ export const signalRulesAlertType = ({ errors, bulkCreateDuration, createdItemsCount, + createdItems, } = await bulkCreateMlSignals({ actions, throttle, @@ -299,6 +301,7 @@ export const signalRulesAlertType = ({ success: success && filteredAnomalyResults._shards.failed === 0, errors: [...errors, ...searchErrors], createdSignalsCount: createdItemsCount, + createdSignals: createdItems, bulkCreateTimes: bulkCreateDuration ? [bulkCreateDuration] : [], }), ]); @@ -371,6 +374,7 @@ export const signalRulesAlertType = ({ success, bulkCreateDuration, createdItemsCount, + createdItems, errors, } = await bulkCreateThresholdSignals({ actions, @@ -406,6 +410,7 @@ export const signalRulesAlertType = ({ success, errors: [...errors, ...previousSearchErrors, ...searchErrors], createdSignalsCount: createdItemsCount, + createdSignals: createdItems, bulkCreateTimes: bulkCreateDuration ? [bulkCreateDuration] : [], }), ]); @@ -539,10 +544,10 @@ export const signalRulesAlertType = ({ 'transport.request', request ); - let newSignals: BaseSignalHit[] | undefined; + let newSignals: WrappedSignalHit[] | undefined; if (response.hits.sequences !== undefined) { newSignals = response.hits.sequences.reduce( - (acc: BaseSignalHit[], sequence) => + (acc: WrappedSignalHit[], sequence) => acc.concat(buildSignalGroupFromSequence(sequence, savedObject, outputIndex)), [] ); @@ -562,6 +567,7 @@ export const signalRulesAlertType = ({ const insertResult = await bulkInsertSignals(newSignals, logger, services, refresh); result.bulkCreateTimes.push(insertResult.bulkCreateDuration); result.createdSignalsCount += insertResult.createdItemsCount; + result.createdSignals = insertResult.createdItems; } result.success = true; } else { @@ -596,6 +602,7 @@ export const signalRulesAlertType = ({ scheduleNotificationActions({ alertInstance, signalsCount: result.createdSignalsCount, + signals: result.createdSignals, resultsLink, ruleParams: notificationRuleParams, }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts index 8c1d4210a7b36..943b70794a9b1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { countBy, isEmpty } from 'lodash'; +import { countBy, isEmpty, get } from 'lodash'; import { performance } from 'perf_hooks'; import { AlertServices } from '../../../../../alerts/server'; -import { SignalSearchResponse, BulkResponse, BaseSignalHit } from './types'; +import { SignalSearchResponse, BulkResponse, SignalHit, WrappedSignalHit } from './types'; import { RuleAlertAction } from '../../../../common/detection_engine/types'; import { RuleTypeParams, RefreshTypes } from '../types'; import { generateId, makeFloatString, errorAggregator } from './utils'; @@ -68,7 +68,7 @@ export const filterDuplicateRules = ( * @param ruleId The rule id * @param signals The candidate new signals */ -export const filterDuplicateSignals = (ruleId: string, signals: BaseSignalHit[]) => { +export const filterDuplicateSignals = (ruleId: string, signals: WrappedSignalHit[]) => { return signals.filter( (doc) => !doc._source.signal?.ancestors.some((ancestor) => ancestor.rule === ruleId) ); @@ -78,12 +78,14 @@ export interface SingleBulkCreateResponse { success: boolean; bulkCreateDuration?: string; createdItemsCount: number; + createdItems: SignalHit[]; errors: string[]; } export interface BulkInsertSignalsResponse { bulkCreateDuration: string; createdItemsCount: number; + createdItems: SignalHit[]; } // Bulk Index documents. @@ -111,7 +113,7 @@ export const singleBulkCreate = async ({ logger.debug(buildRuleMessage(`about to bulk create ${filteredEvents.hits.hits.length} events`)); if (filteredEvents.hits.hits.length === 0) { logger.debug(buildRuleMessage(`all events were duplicates`)); - return { success: true, createdItemsCount: 0, errors: [] }; + return { success: true, createdItemsCount: 0, createdItems: [], errors: [] }; } // index documents after creating an ID based on the // source documents' originating index, and the original @@ -164,7 +166,26 @@ export const singleBulkCreate = async ({ ); logger.debug(buildRuleMessage(`took property says bulk took: ${response.took} milliseconds`)); - const createdItemsCount = countBy(response.items, 'create.status')['201'] ?? 0; + const createdItems = filteredEvents.hits.hits + .map((doc) => + buildBulkBody({ + doc, + ruleParams, + id, + actions, + name, + createdAt, + createdBy, + updatedAt, + updatedBy, + interval, + enabled, + tags, + throttle, + }) + ) + .filter((_, index) => get(response.items[index], 'create.status') === 201); + const createdItemsCount = createdItems.length; const duplicateSignalsCount = countBy(response.items, 'create.status')['409']; const errorCountByMessage = errorAggregator(response, [409]); @@ -184,6 +205,7 @@ export const singleBulkCreate = async ({ success: false, bulkCreateDuration: makeFloatString(end - start), createdItemsCount, + createdItems, }; } else { return { @@ -191,13 +213,14 @@ export const singleBulkCreate = async ({ success: true, bulkCreateDuration: makeFloatString(end - start), createdItemsCount, + createdItems, }; } }; // Bulk Index new signals. export const bulkInsertSignals = async ( - signals: BaseSignalHit[], + signals: WrappedSignalHit[], logger: Logger, services: AlertServices, refresh: RefreshTypes @@ -234,6 +257,9 @@ export const bulkInsertSignals = async ( } const createdItemsCount = countBy(response.items, 'create.status')['201'] ?? 0; + const createdItems = signals + .map((doc) => doc._source) + .filter((_, index) => get(response.items[index], 'create.status') === 201); logger.debug(`bulk created ${createdItemsCount} signals`); - return { bulkCreateDuration: makeFloatString(end - start), createdItemsCount }; + return { bulkCreateDuration: makeFloatString(end - start), createdItems, createdItemsCount }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts index e90c45d40de95..b9398b4aa2af0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts @@ -59,6 +59,7 @@ export const createThreatSignals = async ({ searchAfterTimes: [], lastLookBackDate: null, createdSignalsCount: 0, + createdSignals: [], errors: [], }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.test.ts index 840d64381c793..4bb7b9e07578d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.test.ts @@ -5,6 +5,7 @@ */ import { SearchAfterAndBulkCreateReturnType } from '../types'; +import { sampleSignalHit } from '../__mocks__/es_results'; import { calculateAdditiveMax, @@ -50,6 +51,7 @@ describe('utils', () => { bulkCreateTimes: ['5', '15', '25'], lastLookBackDate: undefined, createdSignalsCount: 3, + createdSignals: Array(3).fill(sampleSignalHit()), errors: [], }; @@ -59,6 +61,7 @@ describe('utils', () => { bulkCreateTimes: ['5', '15', '25'], lastLookBackDate: undefined, createdSignalsCount: 3, + createdSignals: Array(3).fill(sampleSignalHit()), errors: [], }; const combinedResults = combineResults(existingResult, newResult); @@ -72,6 +75,7 @@ describe('utils', () => { bulkCreateTimes: ['5', '15', '25'], lastLookBackDate: undefined, createdSignalsCount: 3, + createdSignals: Array(3).fill(sampleSignalHit()), errors: [], }; @@ -81,6 +85,7 @@ describe('utils', () => { bulkCreateTimes: ['5', '15', '25'], lastLookBackDate: undefined, createdSignalsCount: 3, + createdSignals: Array(3).fill(sampleSignalHit()), errors: [], }; const combinedResults = combineResults(existingResult, newResult); @@ -94,6 +99,7 @@ describe('utils', () => { bulkCreateTimes: ['5', '15', '25'], lastLookBackDate: undefined, createdSignalsCount: 3, + createdSignals: Array(3).fill(sampleSignalHit()), errors: [], }; @@ -103,6 +109,7 @@ describe('utils', () => { bulkCreateTimes: ['5', '15', '25'], lastLookBackDate: new Date('2020-09-16T03:34:32.390Z'), createdSignalsCount: 3, + createdSignals: Array(3).fill(sampleSignalHit()), errors: [], }; const combinedResults = combineResults(existingResult, newResult); @@ -116,6 +123,7 @@ describe('utils', () => { bulkCreateTimes: ['5', '15', '25'], lastLookBackDate: undefined, createdSignalsCount: 3, + createdSignals: Array(3).fill(sampleSignalHit()), errors: [], }; @@ -125,6 +133,7 @@ describe('utils', () => { bulkCreateTimes: ['5', '15', '25'], lastLookBackDate: new Date('2020-09-16T03:34:32.390Z'), createdSignalsCount: 3, + createdSignals: Array(3).fill(sampleSignalHit()), errors: [], }; const combinedResults = combineResults(existingResult, newResult); @@ -143,6 +152,7 @@ describe('utils', () => { bulkCreateTimes: ['5', '15', '25'], lastLookBackDate: undefined, createdSignalsCount: 3, + createdSignals: Array(3).fill(sampleSignalHit()), errors: ['error 1', 'error 2', 'error 3'], }; @@ -152,6 +162,7 @@ describe('utils', () => { bulkCreateTimes: ['5', '15', '25'], lastLookBackDate: new Date('2020-09-16T03:34:32.390Z'), createdSignalsCount: 3, + createdSignals: Array(3).fill(sampleSignalHit()), errors: ['error 4', 'error 1', 'error 3', 'error 5'], }; const combinedResults = combineResults(existingResult, newResult); @@ -261,6 +272,7 @@ describe('utils', () => { bulkCreateTimes: ['5', '15', '25'], lastLookBackDate: undefined, createdSignalsCount: 3, + createdSignals: Array(3).fill(sampleSignalHit()), errors: [], }; const expectedResult: SearchAfterAndBulkCreateReturnType = { @@ -269,19 +281,21 @@ describe('utils', () => { bulkCreateTimes: ['25'], // max value from existingResult.bulkCreateTimes lastLookBackDate: undefined, createdSignalsCount: 3, + createdSignals: Array(3).fill(sampleSignalHit()), errors: [], }; const combinedResults = combineConcurrentResults(existingResult, []); expect(combinedResults).toEqual(expectedResult); }); - test('it should work with empty arrays for searchAfterTimes and bulkCreateTimes', () => { + test('it should work with empty arrays for searchAfterTimes and bulkCreateTimes and createdSignals', () => { const existingResult: SearchAfterAndBulkCreateReturnType = { success: true, searchAfterTimes: ['10', '20', '30'], bulkCreateTimes: ['5', '15', '25'], lastLookBackDate: undefined, createdSignalsCount: 3, + createdSignals: Array(3).fill(sampleSignalHit()), errors: [], }; const newResult: SearchAfterAndBulkCreateReturnType = { @@ -290,6 +304,7 @@ describe('utils', () => { bulkCreateTimes: [], lastLookBackDate: undefined, createdSignalsCount: 0, + createdSignals: [], errors: [], }; const expectedResult: SearchAfterAndBulkCreateReturnType = { @@ -298,6 +313,7 @@ describe('utils', () => { bulkCreateTimes: ['25'], // max value from existingResult.bulkCreateTimes lastLookBackDate: undefined, createdSignalsCount: 3, + createdSignals: Array(3).fill(sampleSignalHit()), errors: [], }; @@ -312,6 +328,7 @@ describe('utils', () => { bulkCreateTimes: ['5', '15', '25'], // max is 25 lastLookBackDate: undefined, createdSignalsCount: 3, + createdSignals: Array(3).fill(sampleSignalHit()), errors: [], }; const newResult1: SearchAfterAndBulkCreateReturnType = { @@ -320,6 +337,7 @@ describe('utils', () => { bulkCreateTimes: ['5', '15', '25'], lastLookBackDate: new Date('2020-09-16T03:34:32.390Z'), createdSignalsCount: 5, + createdSignals: Array(5).fill(sampleSignalHit()), errors: [], }; const newResult2: SearchAfterAndBulkCreateReturnType = { @@ -328,6 +346,7 @@ describe('utils', () => { bulkCreateTimes: ['50', '5', '15'], lastLookBackDate: new Date('2020-09-16T04:34:32.390Z'), createdSignalsCount: 8, + createdSignals: Array(8).fill(sampleSignalHit()), errors: [], }; @@ -337,6 +356,7 @@ describe('utils', () => { bulkCreateTimes: ['75'], // max value between newResult1 and newResult2 + max array value of existingResult (50 + 25 = 75) lastLookBackDate: new Date('2020-09-16T04:34:32.390Z'), // max lastLookBackDate createdSignalsCount: 16, // all the signals counted together (8 + 5 + 3) + createdSignals: Array(16).fill(sampleSignalHit()), errors: [], }; @@ -351,6 +371,7 @@ describe('utils', () => { bulkCreateTimes: ['5', '15', '25'], // max is 25 lastLookBackDate: undefined, createdSignalsCount: 3, + createdSignals: Array(3).fill(sampleSignalHit()), errors: [], }; const newResult1: SearchAfterAndBulkCreateReturnType = { @@ -359,6 +380,7 @@ describe('utils', () => { bulkCreateTimes: ['5', '15', '25'], lastLookBackDate: new Date('2020-09-16T03:34:32.390Z'), createdSignalsCount: 5, + createdSignals: Array(5).fill(sampleSignalHit()), errors: [], }; const newResult2: SearchAfterAndBulkCreateReturnType = { @@ -367,6 +389,7 @@ describe('utils', () => { bulkCreateTimes: ['50', '5', '15'], lastLookBackDate: new Date('2020-09-16T04:34:32.390Z'), createdSignalsCount: 8, + createdSignals: Array(8).fill(sampleSignalHit()), errors: [], }; @@ -376,6 +399,7 @@ describe('utils', () => { bulkCreateTimes: ['75'], // max value between newResult1 and newResult2 + max array value of existingResult (50 + 25 = 75) lastLookBackDate: new Date('2020-09-16T04:34:32.390Z'), // max lastLookBackDate createdSignalsCount: 16, // all the signals counted together (8 + 5 + 3) + createdSignals: Array(16).fill(sampleSignalHit()), errors: [], }; @@ -390,6 +414,7 @@ describe('utils', () => { bulkCreateTimes: ['5', '15', '25'], // max is 25 lastLookBackDate: undefined, createdSignalsCount: 3, + createdSignals: Array(3).fill(sampleSignalHit()), errors: [], }; const newResult1: SearchAfterAndBulkCreateReturnType = { @@ -398,6 +423,7 @@ describe('utils', () => { bulkCreateTimes: ['5', '15', '25'], lastLookBackDate: new Date('2020-09-16T03:34:32.390Z'), createdSignalsCount: 5, + createdSignals: Array(5).fill(sampleSignalHit()), errors: [], }; const newResult2: SearchAfterAndBulkCreateReturnType = { @@ -406,6 +432,7 @@ describe('utils', () => { bulkCreateTimes: ['50', '5', '15'], lastLookBackDate: null, createdSignalsCount: 8, + createdSignals: Array(8).fill(sampleSignalHit()), errors: [], }; @@ -415,6 +442,7 @@ describe('utils', () => { bulkCreateTimes: ['75'], // max value between newResult1 and newResult2 + max array value of existingResult (50 + 25 = 75) lastLookBackDate: new Date('2020-09-16T03:34:32.390Z'), // max lastLookBackDate createdSignalsCount: 16, // all the signals counted together (8 + 5 + 3) + createdSignals: Array(16).fill(sampleSignalHit()), errors: [], }; @@ -429,6 +457,7 @@ describe('utils', () => { bulkCreateTimes: ['5', '15', '25'], lastLookBackDate: undefined, createdSignalsCount: 3, + createdSignals: Array(3).fill(sampleSignalHit()), errors: [], }; @@ -438,6 +467,7 @@ describe('utils', () => { bulkCreateTimes: ['5', '15', '25'], lastLookBackDate: undefined, createdSignalsCount: 3, + createdSignals: Array(3).fill(sampleSignalHit()), errors: [], }; const combinedResults = combineConcurrentResults(existingResult, [newResult]); @@ -451,6 +481,7 @@ describe('utils', () => { bulkCreateTimes: ['5', '15', '25'], lastLookBackDate: undefined, createdSignalsCount: 3, + createdSignals: Array(3).fill(sampleSignalHit()), errors: [], }; @@ -460,6 +491,7 @@ describe('utils', () => { bulkCreateTimes: ['5', '15', '25'], lastLookBackDate: undefined, createdSignalsCount: 3, + createdSignals: Array(3).fill(sampleSignalHit()), errors: [], }; const combinedResults = combineConcurrentResults(existingResult, [newResult]); @@ -473,6 +505,7 @@ describe('utils', () => { bulkCreateTimes: ['5', '15', '25'], lastLookBackDate: undefined, createdSignalsCount: 3, + createdSignals: Array(3).fill(sampleSignalHit()), errors: [], }; @@ -482,6 +515,7 @@ describe('utils', () => { bulkCreateTimes: ['5', '15', '25'], lastLookBackDate: new Date('2020-09-16T03:34:32.390Z'), createdSignalsCount: 3, + createdSignals: Array(3).fill(sampleSignalHit()), errors: [], }; const combinedResults = combineConcurrentResults(existingResult, [newResult]); @@ -495,6 +529,7 @@ describe('utils', () => { bulkCreateTimes: ['5', '15', '25'], lastLookBackDate: undefined, createdSignalsCount: 3, + createdSignals: Array(3).fill(sampleSignalHit()), errors: [], }; @@ -504,6 +539,7 @@ describe('utils', () => { bulkCreateTimes: ['5', '15', '25'], lastLookBackDate: new Date('2020-09-16T03:34:32.390Z'), createdSignalsCount: 3, + createdSignals: Array(3).fill(sampleSignalHit()), errors: [], }; const combinedResults = combineConcurrentResults(existingResult, [newResult]); @@ -522,6 +558,7 @@ describe('utils', () => { bulkCreateTimes: ['5', '15', '25'], lastLookBackDate: undefined, createdSignalsCount: 3, + createdSignals: Array(3).fill(sampleSignalHit()), errors: ['error 1', 'error 2', 'error 3'], }; @@ -531,6 +568,7 @@ describe('utils', () => { bulkCreateTimes: ['5', '15', '25'], lastLookBackDate: new Date('2020-09-16T03:34:32.390Z'), createdSignalsCount: 3, + createdSignals: Array(3).fill(sampleSignalHit()), errors: ['error 4', 'error 1', 'error 3', 'error 5'], }; const combinedResults = combineConcurrentResults(existingResult, [newResult]); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts index d6c91fad6d9cb..653b01f11f552 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts @@ -71,6 +71,7 @@ export const combineResults = ( ), lastLookBackDate: newResult.lastLookBackDate, createdSignalsCount: currentResult.createdSignalsCount + newResult.createdSignalsCount, + createdSignals: [...currentResult.createdSignals, ...newResult.createdSignals], errors: [...new Set([...currentResult.errors, ...newResult.errors])], }); @@ -94,6 +95,7 @@ export const combineConcurrentResults = ( bulkCreateTimes: [maxBulkCreateTimes], lastLookBackDate, createdSignalsCount: accum.createdSignalsCount + item.createdSignalsCount, + createdSignals: [...accum.createdSignals, ...item.createdSignals], errors: [...new Set([...accum.errors, ...item.errors])], }; }, @@ -103,6 +105,7 @@ export const combineConcurrentResults = ( bulkCreateTimes: [], lastLookBackDate: undefined, createdSignalsCount: 0, + createdSignals: [], errors: [], } ); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts index 66f3a21dfe680..4167d056df885 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts @@ -123,6 +123,7 @@ export interface GetResponse { export type EventSearchResponse = SearchResponse; export type SignalSearchResponse = SearchResponse; export type SignalSourceHit = SignalSearchResponse['hits']['hits'][number]; +export type WrappedSignalHit = BaseHit; export type BaseSignalHit = BaseHit; export type EqlSignalSearchResponse = EqlSearchResponse; @@ -174,6 +175,7 @@ export interface SignalHit { '@timestamp': string; event: object; signal: Signal; + [key: string]: SearchTypes; } export interface AlertAttributes { @@ -242,6 +244,7 @@ export interface SearchAfterAndBulkCreateReturnType { bulkCreateTimes: string[]; lastLookBackDate: Date | null | undefined; createdSignalsCount: number; + createdSignals: SignalHit[]; errors: string[]; } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts index 894e934ff0247..dd936776f691a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts @@ -979,6 +979,7 @@ describe('utils', () => { const expected: SearchAfterAndBulkCreateReturnType = { bulkCreateTimes: [], createdSignalsCount: 0, + createdSignals: [], errors: [], lastLookBackDate: null, searchAfterTimes: [], @@ -996,6 +997,7 @@ describe('utils', () => { const expected: SearchAfterAndBulkCreateReturnType = { bulkCreateTimes: [], createdSignalsCount: 0, + createdSignals: [], errors: [], lastLookBackDate: new Date('2020-04-20T21:27:45.000Z'), searchAfterTimes: [], @@ -1147,6 +1149,7 @@ describe('utils', () => { const expected: SearchAfterAndBulkCreateReturnType = { bulkCreateTimes: [], createdSignalsCount: 0, + createdSignals: [], errors: [], lastLookBackDate: null, searchAfterTimes: [], @@ -1159,6 +1162,7 @@ describe('utils', () => { const searchAfterReturnType = createSearchAfterReturnType({ bulkCreateTimes: ['123'], createdSignalsCount: 5, + createdSignals: Array(5).fill(sampleSignalHit()), errors: ['error 1'], lastLookBackDate: new Date('2020-09-21T18:51:25.193Z'), searchAfterTimes: ['123'], @@ -1167,6 +1171,7 @@ describe('utils', () => { const expected: SearchAfterAndBulkCreateReturnType = { bulkCreateTimes: ['123'], createdSignalsCount: 5, + createdSignals: Array(5).fill(sampleSignalHit()), errors: ['error 1'], lastLookBackDate: new Date('2020-09-21T18:51:25.193Z'), searchAfterTimes: ['123'], @@ -1178,11 +1183,13 @@ describe('utils', () => { test('createSearchAfterReturnType can override select values', () => { const searchAfterReturnType = createSearchAfterReturnType({ createdSignalsCount: 5, + createdSignals: Array(5).fill(sampleSignalHit()), errors: ['error 1'], }); const expected: SearchAfterAndBulkCreateReturnType = { bulkCreateTimes: [], createdSignalsCount: 5, + createdSignals: Array(5).fill(sampleSignalHit()), errors: ['error 1'], lastLookBackDate: null, searchAfterTimes: [], @@ -1198,6 +1205,7 @@ describe('utils', () => { const expected: SearchAfterAndBulkCreateReturnType = { bulkCreateTimes: [], createdSignalsCount: 0, + createdSignals: [], errors: [], lastLookBackDate: null, searchAfterTimes: [], @@ -1251,6 +1259,7 @@ describe('utils', () => { createSearchAfterReturnType({ bulkCreateTimes: ['123'], createdSignalsCount: 3, + createdSignals: Array(3).fill(sampleSignalHit()), errors: ['error 1', 'error 2'], lastLookBackDate: new Date('2020-08-21T18:51:25.193Z'), searchAfterTimes: ['123'], @@ -1259,6 +1268,7 @@ describe('utils', () => { createSearchAfterReturnType({ bulkCreateTimes: ['456'], createdSignalsCount: 2, + createdSignals: Array(2).fill(sampleSignalHit()), errors: ['error 3'], lastLookBackDate: new Date('2020-09-21T18:51:25.193Z'), searchAfterTimes: ['567'], @@ -1268,6 +1278,7 @@ describe('utils', () => { const expected: SearchAfterAndBulkCreateReturnType = { bulkCreateTimes: ['123', '456'], // concatenates the prev and next together createdSignalsCount: 5, // Adds the 3 and 2 together + createdSignals: Array(5).fill(sampleSignalHit()), errors: ['error 1', 'error 2', 'error 3'], // concatenates the prev and next together lastLookBackDate: new Date('2020-09-21T18:51:25.193Z'), // takes the next lastLookBackDate searchAfterTimes: ['123', '567'], // concatenates the searchAfterTimes together diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts index e2b39b8d0a8c8..2114f21d9cead 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts @@ -18,10 +18,10 @@ import { BulkResponseErrorAggregation, isValidUnit, SignalHit, - BaseSignalHit, SearchAfterAndBulkCreateReturnType, SignalSearchResponse, Signal, + WrappedSignalHit, } from './types'; import { BuildRuleMessage } from './rule_messages'; import { parseScheduleDates } from '../../../../common/detection_engine/parse_schedule_dates'; @@ -247,7 +247,10 @@ export const generateBuildingBlockIds = (buildingBlocks: SignalHit[]): string[] ); }; -export const wrapBuildingBlocks = (buildingBlocks: SignalHit[], index: string): BaseSignalHit[] => { +export const wrapBuildingBlocks = ( + buildingBlocks: SignalHit[], + index: string +): WrappedSignalHit[] => { const blockIds = generateBuildingBlockIds(buildingBlocks); return buildingBlocks.map((block, idx) => { return { @@ -260,7 +263,7 @@ export const wrapBuildingBlocks = (buildingBlocks: SignalHit[], index: string): }); }; -export const wrapSignal = (signal: SignalHit, index: string): BaseSignalHit => { +export const wrapSignal = (signal: SignalHit, index: string): WrappedSignalHit => { return { _id: generateSignalId(signal.signal), _index: index, @@ -589,6 +592,7 @@ export const createSearchAfterReturnType = ({ bulkCreateTimes, lastLookBackDate, createdSignalsCount, + createdSignals, errors, }: { success?: boolean | undefined; @@ -596,6 +600,7 @@ export const createSearchAfterReturnType = ({ bulkCreateTimes?: string[] | undefined; lastLookBackDate?: Date | undefined; createdSignalsCount?: number | undefined; + createdSignals?: SignalHit[] | undefined; errors?: string[] | undefined; } = {}): SearchAfterAndBulkCreateReturnType => { return { @@ -604,6 +609,7 @@ export const createSearchAfterReturnType = ({ bulkCreateTimes: bulkCreateTimes ?? [], lastLookBackDate: lastLookBackDate ?? null, createdSignalsCount: createdSignalsCount ?? 0, + createdSignals: createdSignals ?? [], errors: errors ?? [], }; }; @@ -618,6 +624,7 @@ export const mergeReturns = ( bulkCreateTimes: existingBulkCreateTimes, lastLookBackDate: existingLastLookBackDate, createdSignalsCount: existingCreatedSignalsCount, + createdSignals: existingCreatedSignals, errors: existingErrors, } = prev; @@ -627,6 +634,7 @@ export const mergeReturns = ( bulkCreateTimes: newBulkCreateTimes, lastLookBackDate: newLastLookBackDate, createdSignalsCount: newCreatedSignalsCount, + createdSignals: newCreatedSignals, errors: newErrors, } = next; @@ -636,6 +644,7 @@ export const mergeReturns = ( bulkCreateTimes: [...existingBulkCreateTimes, ...newBulkCreateTimes], lastLookBackDate: newLastLookBackDate ?? existingLastLookBackDate, createdSignalsCount: existingCreatedSignalsCount + newCreatedSignalsCount, + createdSignals: [...existingCreatedSignals, ...newCreatedSignals], errors: [...new Set([...existingErrors, ...newErrors])], }; }); diff --git a/x-pack/plugins/security_solution/server/routes/index.ts b/x-pack/plugins/security_solution/server/routes/index.ts index 1bd92c5c2f079..0204869904397 100644 --- a/x-pack/plugins/security_solution/server/routes/index.ts +++ b/x-pack/plugins/security_solution/server/routes/index.ts @@ -15,6 +15,7 @@ import { deleteRulesRoute } from '../lib/detection_engine/routes/rules/delete_ru import { updateRulesRoute } from '../lib/detection_engine/routes/rules/update_rules_route'; import { patchRulesRoute } from '../lib/detection_engine/routes/rules/patch_rules_route'; import { createSignalsMigrationRoute } from '../lib/detection_engine/routes/signals/create_signals_migration_route'; +import { deleteSignalsMigrationRoute } from '../lib/detection_engine/routes/signals/delete_signals_migration_route'; import { finalizeSignalsMigrationRoute } from '../lib/detection_engine/routes/signals/finalize_signals_migration_route'; import { getSignalsMigrationStatusRoute } from '../lib/detection_engine/routes/signals/get_signals_migration_status_route'; import { querySignalsRoute } from '../lib/detection_engine/routes/signals/query_signals_route'; @@ -83,11 +84,12 @@ export const initRoutes = ( // Detection Engine Signals routes that have the REST endpoints of /api/detection_engine/signals // POST /api/detection_engine/signals/status // Example usage can be found in security_solution/server/lib/detection_engine/scripts/signals - getSignalsMigrationStatusRoute(router); - createSignalsMigrationRoute(router); - finalizeSignalsMigrationRoute(router); setSignalsStatusRoute(router); querySignalsRoute(router); + getSignalsMigrationStatusRoute(router); + createSignalsMigrationRoute(router, security); + finalizeSignalsMigrationRoute(router, security); + deleteSignalsMigrationRoute(router, security); // Detection Engine index routes that have the REST endpoints of /api/detection_engine/index // All REST index creation, policy management for spaces diff --git a/x-pack/plugins/security_solution/server/saved_objects.ts b/x-pack/plugins/security_solution/server/saved_objects.ts index fd14c1519d5f1..92f0ab3b46eff 100644 --- a/x-pack/plugins/security_solution/server/saved_objects.ts +++ b/x-pack/plugins/security_solution/server/saved_objects.ts @@ -11,6 +11,7 @@ import { type as pinnedEventType } from './lib/pinned_event/saved_object_mapping import { type as timelineType } from './lib/timeline/saved_object_mappings'; import { type as ruleStatusType } from './lib/detection_engine/rules/saved_object_mappings'; import { type as ruleActionsType } from './lib/detection_engine/rule_actions/saved_object_mappings'; +import { type as signalsMigrationType } from './lib/detection_engine/migrations/saved_objects'; import { exceptionsArtifactType, manifestType, @@ -24,6 +25,7 @@ const types = [ timelineType, exceptionsArtifactType, manifestType, + signalsMigrationType, ]; export const savedObjectTypes = types.map((type) => type.name); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/alert_type.ts index a873cab69f23b..164ce993eebac 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/alert_type.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/alert_type.ts @@ -9,12 +9,7 @@ import { schema } from '@kbn/config-schema'; import { Logger } from 'src/core/server'; import { STACK_ALERTS_FEATURE_ID } from '../../../common'; import { getGeoContainmentExecutor } from './geo_containment'; -import { - ActionGroup, - AlertServices, - ActionVariable, - AlertTypeState, -} from '../../../../alerts/server'; +import { AlertType } from '../../../../alerts/server'; import { Query } from '../../../../../../src/plugins/data/common/query'; export const GEO_CONTAINMENT_ID = '.geo-containment'; @@ -117,40 +112,7 @@ export interface GeoContainmentParams { boundaryIndexQuery?: Query; } -export function getAlertType( - logger: Logger -): { - defaultActionGroupId: string; - actionGroups: ActionGroup[]; - executor: ({ - previousStartedAt: currIntervalStartTime, - startedAt: currIntervalEndTime, - services, - params, - alertId, - state, - }: { - previousStartedAt: Date | null; - startedAt: Date; - services: AlertServices; - params: GeoContainmentParams; - alertId: string; - state: AlertTypeState; - }) => Promise; - validate?: { - params?: { - validate: (object: unknown) => GeoContainmentParams; - }; - }; - name: string; - producer: string; - id: string; - actionVariables?: { - context?: ActionVariable[]; - state?: ActionVariable[]; - params?: ActionVariable[]; - }; -} { +export function getAlertType(logger: Logger): AlertType { const alertTypeName = i18n.translate('xpack.stackAlerts.geoContainment.alertTypeTitle', { defaultMessage: 'Geo tracking containment', }); @@ -173,5 +135,6 @@ export function getAlertType( params: ParamsSchema, }, actionVariables, + minimumLicenseRequired: 'gold', }; } diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/index.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/index.ts index 2fa2bed9d8419..02116d0701bfa 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/index.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/index.ts @@ -6,7 +6,7 @@ import { Logger } from 'src/core/server'; import { AlertingSetup } from '../../types'; -import { getAlertType } from './alert_type'; +import { GeoContainmentParams, getAlertType } from './alert_type'; interface RegisterParams { logger: Logger; @@ -15,5 +15,5 @@ interface RegisterParams { export function register(params: RegisterParams) { const { logger, alerts } = params; - alerts.registerType(getAlertType(logger)); + alerts.registerType(getAlertType(logger)); } diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/alert_type.ts index 0c40f5b5f3866..93a6c0d29cf3c 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/alert_type.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/alert_type.ts @@ -9,12 +9,7 @@ import { schema } from '@kbn/config-schema'; import { Logger } from 'src/core/server'; import { STACK_ALERTS_FEATURE_ID } from '../../../common'; import { getGeoThresholdExecutor } from './geo_threshold'; -import { - ActionGroup, - AlertServices, - ActionVariable, - AlertTypeState, -} from '../../../../alerts/server'; +import { AlertType } from '../../../../alerts/server'; import { Query } from '../../../../../../src/plugins/data/common/query'; export const GEO_THRESHOLD_ID = '.geo-threshold'; @@ -177,40 +172,7 @@ export interface GeoThresholdParams { boundaryIndexQuery?: Query; } -export function getAlertType( - logger: Logger -): { - defaultActionGroupId: string; - actionGroups: ActionGroup[]; - executor: ({ - previousStartedAt: currIntervalStartTime, - startedAt: currIntervalEndTime, - services, - params, - alertId, - state, - }: { - previousStartedAt: Date | null; - startedAt: Date; - services: AlertServices; - params: GeoThresholdParams; - alertId: string; - state: AlertTypeState; - }) => Promise; - validate?: { - params?: { - validate: (object: unknown) => GeoThresholdParams; - }; - }; - name: string; - producer: string; - id: string; - actionVariables?: { - context?: ActionVariable[]; - state?: ActionVariable[]; - params?: ActionVariable[]; - }; -} { +export function getAlertType(logger: Logger): AlertType { const alertTypeName = i18n.translate('xpack.stackAlerts.geoThreshold.alertTypeTitle', { defaultMessage: 'Geo tracking threshold', }); @@ -233,5 +195,6 @@ export function getAlertType( params: ParamsSchema, }, actionVariables, + minimumLicenseRequired: 'gold', }; } diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts index 9de0940771525..9600395c78218 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts @@ -143,6 +143,7 @@ export function getAlertType( ...alertParamsVariables, ], }, + minimumLicenseRequired: 'basic', executor, producer: STACK_ALERTS_FEATURE_ID, }; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 2fde72f94598e..ec907b42a65dc 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -3693,6 +3693,8 @@ "visTypeTable.aggTable.rawLabel": "生", "visTypeTable.directives.tableCellFilter.filterForValueTooltip": "値でフィルタリング", "visTypeTable.directives.tableCellFilter.filterOutValueTooltip": "値を除外", + "visTypeTable.tableCellFilter.filterForValueText": "値でフィルタリング", + "visTypeTable.tableCellFilter.filterOutValueText": "値を除外", "visTypeTable.function.help": "表ビジュアライゼーション", "visTypeTable.params.defaultPercentageCol": "非表示", "visTypeTable.params.PercentageColLabel": "パーセンテージ列", @@ -4582,13 +4584,6 @@ "xpack.actions.actionTypeRegistry.get.missingActionTypeErrorMessage": "アクションタイプ \"{id}\" は登録されていません。", "xpack.actions.actionTypeRegistry.register.duplicateActionTypeErrorMessage": "アクションタイプ \"{id}\" は既に登録されています。", "xpack.actions.appName": "アクション", - "xpack.actions.builtin.case.common.externalIncidentAdded": "({date}に{user}が追加)", - "xpack.actions.builtin.case.common.externalIncidentCreated": "({date}に{user}が作成)", - "xpack.actions.builtin.case.common.externalIncidentDefault": "({date}に{user}が作成)", - "xpack.actions.builtin.case.common.externalIncidentUpdated": "({date}に{user}が更新)", - "xpack.actions.builtin.case.configuration.apiWhitelistError": "コネクターアクションの構成エラー:{message}", - "xpack.actions.builtin.case.configuration.emptyMapping": "[casesConfiguration.mapping]:空以外の値が必要ですが空でした", - "xpack.actions.builtin.case.connectorApiNullError": "コネクター[apiUrl]が必要です", "xpack.actions.builtin.case.jiraTitle": "Jira", "xpack.actions.builtin.case.resilientTitle": "IBM Resilient", "xpack.actions.builtin.configuration.apiAllowedHostsError": "コネクターアクションの構成エラー:{message}", @@ -4597,7 +4592,6 @@ "xpack.actions.builtin.esIndex.errorIndexingErrorMessage": "エラーインデックス作成ドキュメント", "xpack.actions.builtin.esIndexTitle": "インデックス", "xpack.actions.builtin.jira.configuration.apiAllowedHostsError": "コネクターアクションの構成エラー:{message}", - "xpack.actions.builtin.jira.configuration.emptyMapping": "[incidentConfiguration.mapping]:空以外の値が必要ですが空でした", "xpack.actions.builtin.pagerduty.invalidTimestampErrorMessage": "タイムスタンプ\"{timestamp}\"の解析エラー", "xpack.actions.builtin.pagerduty.missingDedupkeyErrorMessage": "eventActionが「{eventAction}」のときにはDedupKeyが必要です", "xpack.actions.builtin.pagerduty.pagerdutyConfigurationError": "pagerduty アクションの設定エラー: {message}", @@ -4608,7 +4602,6 @@ "xpack.actions.builtin.pagerdutyTitle": "PagerDuty", "xpack.actions.builtin.serverLog.errorLoggingErrorMessage": "メッセージのロギングエラー", "xpack.actions.builtin.serverLogTitle": "サーバーログ", - "xpack.actions.builtin.servicenow.configuration.emptyMapping": "[incidentConfiguration.mapping]:空以外の値が必要ですが空でした", "xpack.actions.builtin.servicenowTitle": "ServiceNow", "xpack.actions.builtin.slack.errorPostingErrorMessage": "slack メッセージの投稿エラー", "xpack.actions.builtin.slack.errorPostingRetryDateErrorMessage": "slack メッセージの投稿エラー、 {retryString} に再試行", @@ -16347,14 +16340,11 @@ "xpack.securitySolution.case.configureCases.caseClosureOptionsManual": "セキュリティケースを手動で閉じる", "xpack.securitySolution.case.configureCases.caseClosureOptionsNewIncident": "新しいインシデントを外部システムにプッシュするときにセキュリティケースを自動的に閉じる", "xpack.securitySolution.case.configureCases.caseClosureOptionsTitle": "ケースのクローズ", - "xpack.securitySolution.case.configureCases.fieldMappingDesc": "データをサードパーティにプッシュするときにセキュリティケースフィールドをマップします。フィールドマッピングのためには、外部のインシデント管理システムへの接続を確立する必要があります。", "xpack.securitySolution.case.configureCases.fieldMappingEditAppend": "末尾に追加", "xpack.securitySolution.case.configureCases.fieldMappingEditNothing": "何もしない", "xpack.securitySolution.case.configureCases.fieldMappingEditOverwrite": "上書き", "xpack.securitySolution.case.configureCases.fieldMappingFirstCol": "セキュリティケースフィールド", - "xpack.securitySolution.case.configureCases.fieldMappingSecondCol": "外部インシデントフィールド", "xpack.securitySolution.case.configureCases.fieldMappingThirdCol": "編集時と更新時", - "xpack.securitySolution.case.configureCases.fieldMappingTitle": "フィールドマッピング", "xpack.securitySolution.case.configureCases.headerTitle": "ケースを構成", "xpack.securitySolution.case.configureCases.incidentManagementSystemDesc": "オプションとして、セキュリティケースを選択した外部のインシデント管理システムに接続できます。そうすると、選択したサードパーティシステム内でケースデータをインシデントとしてプッシュできます。", "xpack.securitySolution.case.configureCases.incidentManagementSystemLabel": "インシデント管理システム", @@ -19249,16 +19239,7 @@ "xpack.triggersActionsUI.common.expressionItems.threshold.popoverTitle": "タイミング", "xpack.triggersActionsUI.components.addMessageVariables.addVariablePopoverButton": "変数を追加", "xpack.triggersActionsUI.components.addMessageVariables.addVariableTitle": "アラート変数を追加", - "xpack.triggersActionsUI.components.builtinActionTypes.addNewConnector": "新しいコネクターを追加", - "xpack.triggersActionsUI.components.builtinActionTypes.cancelButton": "キャンセル", - "xpack.triggersActionsUI.components.builtinActionTypes.caseClosureOptionsClosedIncident": "新しいインシデントが外部システムで閉じたときにセキュリティケースを自動的に閉じる", - "xpack.triggersActionsUI.components.builtinActionTypes.caseClosureOptionsDesc": "セキュリティケースの終了のしかたを定義します。自動ケース終了のためには、外部のインシデント管理システムへの接続を確立する必要がいります。", - "xpack.triggersActionsUI.components.builtinActionTypes.caseClosureOptionsLabel": "ケース終了オプション", - "xpack.triggersActionsUI.components.builtinActionTypes.caseClosureOptionsManual": "セキュリティケースを手動で閉じる", - "xpack.triggersActionsUI.components.builtinActionTypes.caseClosureOptionsNewIncident": "新しいインシデントを外部システムにプッシュするときにセキュリティケースを自動的に閉じる", - "xpack.triggersActionsUI.components.builtinActionTypes.caseClosureOptionsTitle": "ケースのクローズ", "xpack.triggersActionsUI.components.builtinActionTypes.common.requiredDescriptionTextField": "説明が必要です。", - "xpack.triggersActionsUI.components.builtinActionTypes.common.requiredTitleTextField": "タイトルが必要です。", "xpack.triggersActionsUI.components.builtinActionTypes.emailAction.actionTypeTitle": "メールに送信", "xpack.triggersActionsUI.components.builtinActionTypes.emailAction.configureAccountsHelpLabel": "電子メールアカウントを構成しています。", "xpack.triggersActionsUI.components.builtinActionTypes.emailAction.selectMessageText": "サーバーからメールを送信します。", @@ -19277,17 +19258,6 @@ "xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSubjectText": "件名が必要です。", "xpack.triggersActionsUI.components.builtinActionTypes.error.requiredUserText": "パスワードの使用時にはユーザー名が必要です。", "xpack.triggersActionsUI.components.builtinActionTypes.error.requiredWebhookBodyText": "本文が必要です。", - "xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingDesc": "データをサードパーティにプッシュするときにセキュリティケースフィールドをマップします。フィールドマッピングのためには、外部のインシデント管理システムへの接続を確立する必要があります。", - "xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingEditAppend": "末尾に追加", - "xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingEditNothing": "何もしない", - "xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingEditOverwrite": "上書き", - "xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingFirstCol": "セキュリティケースフィールド", - "xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingSecondCol": "外部インシデントフィールド", - "xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingThirdCol": "編集時と更新時", - "xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingTitle": "フィールドマッピング", - "xpack.triggersActionsUI.components.builtinActionTypes.incidentManagementSystemDesc": "オプションとして、セキュリティケースを選択した外部のインシデント管理システムに接続できます。そうすると、選択したサードパーティシステム内でケースデータをインシデントとしてプッシュできます。", - "xpack.triggersActionsUI.components.builtinActionTypes.incidentManagementSystemLabel": "インシデント管理システム", - "xpack.triggersActionsUI.components.builtinActionTypes.incidentManagementSystemTitle": "外部のインシデント管理システムに接続", "xpack.triggersActionsUI.components.builtinActionTypes.indexAction.actionTypeTitle": "データをインデックスする", "xpack.triggersActionsUI.components.builtinActionTypes.indexAction.chooseLabel": "選択...", "xpack.triggersActionsUI.components.builtinActionTypes.indexAction.configureIndexHelpLabel": "インデックスコネクターを構成しています。", @@ -19322,21 +19292,16 @@ "xpack.triggersActionsUI.components.builtinActionTypes.jira.requiredDescriptionTextField": "説明が必要です。", "xpack.triggersActionsUI.components.builtinActionTypes.jira.requiredEmailTextField": "電子メールアドレスまたはユーザー名が必要です", "xpack.triggersActionsUI.components.builtinActionTypes.jira.requiredProjectKeyTextField": "プロジェクトキーが必要です", - "xpack.triggersActionsUI.components.builtinActionTypes.jira.savedObjectIdFieldHelp": "JIRAは、このアクションを、Kibanaの保存されたオブジェクトのIDに関連付けます。", - "xpack.triggersActionsUI.components.builtinActionTypes.jira.savedObjectIdFieldLabel": "オブジェクトID(任意)", "xpack.triggersActionsUI.components.builtinActionTypes.jira.searchIssuesComboBoxAriaLabel": "親問題を選択", "xpack.triggersActionsUI.components.builtinActionTypes.jira.searchIssuesComboBoxPlaceholder": "親問題を選択", "xpack.triggersActionsUI.components.builtinActionTypes.jira.searchIssuesLoading": "読み込み中…", "xpack.triggersActionsUI.components.builtinActionTypes.jira.selectMessageText": "Jiraでデータを更新するか、新しい問題にプッシュ", "xpack.triggersActionsUI.components.builtinActionTypes.jira.severitySelectFieldLabel": "優先度", - "xpack.triggersActionsUI.components.builtinActionTypes.jira.titleFieldLabel": "まとめ", "xpack.triggersActionsUI.components.builtinActionTypes.jira.unableToGetFieldsMessage": "フィールドを取得できません", "xpack.triggersActionsUI.components.builtinActionTypes.jira.unableToGetIssueMessage": "ID {id}の問題を取得できません", "xpack.triggersActionsUI.components.builtinActionTypes.jira.unableToGetIssuesMessage": "問題を取得できません", "xpack.triggersActionsUI.components.builtinActionTypes.jira.unableToGetIssueTypesMessage": "問題タイプを取得できません", "xpack.triggersActionsUI.components.builtinActionTypes.jira.urgencySelectFieldLabel": "問題タイプ", - "xpack.triggersActionsUI.components.builtinActionTypes.mappingFieldNotMapped": "マップされません", - "xpack.triggersActionsUI.components.builtinActionTypes.noConnector": "コネクターを選択していません", "xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.actionTypeTitle": "PagerDuty に送信", "xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.apiUrlTextFieldLabel": "API URL(任意)", "xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.classFieldLabel": "クラス (任意)", @@ -19377,11 +19342,8 @@ "xpack.triggersActionsUI.components.builtinActionTypes.resilient.requiredApiKeySecretTextField": "APIキーシークレットが必要です", "xpack.triggersActionsUI.components.builtinActionTypes.resilient.requiredApiUrlTextField": "URLが必要です。", "xpack.triggersActionsUI.components.builtinActionTypes.resilient.requiredOrgIdTextField": "組織IDが必要です", - "xpack.triggersActionsUI.components.builtinActionTypes.resilient.savedObjectIdFieldHelp": "IBM Resilientは、このアクションを、Kibanaの保存されたオブジェクトのIDに関連付けます。", - "xpack.triggersActionsUI.components.builtinActionTypes.resilient.savedObjectIdFieldLabel": "オブジェクトID(任意)", "xpack.triggersActionsUI.components.builtinActionTypes.resilient.selectMessageText": "Resilientでデータを更新するか、または新しいインシデントにプッシュします。", "xpack.triggersActionsUI.components.builtinActionTypes.resilient.severity": "深刻度", - "xpack.triggersActionsUI.components.builtinActionTypes.resilient.titleFieldLabel": "名前", "xpack.triggersActionsUI.components.builtinActionTypes.resilient.unableToGetIncidentTypesMessage": "インシデントタイプを取得できません", "xpack.triggersActionsUI.components.builtinActionTypes.resilient.unableToGetSeverityMessage": "深刻度を取得できません", "xpack.triggersActionsUI.components.builtinActionTypes.resilient.urgencySelectFieldLabel": "インシデントタイプ", @@ -19406,8 +19368,6 @@ "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredEmailTextField": "電子メールが必要です。", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredPasswordTextField": "パスワードが必要です。", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredUsernameTextField": "ユーザー名が必要です。", - "xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.savedObjectIdFieldHelp": "ServiceNowは、このアクションを、Kibanaの保存されたオブジェクトのIDに関連付けます。", - "xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.savedObjectIdFieldLabel": "オブジェクトID(任意)", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.selectMessageText": "ServiceNowでインシデントを作成します。", "xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.severitySelectFieldLabel": "深刻度", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectHighOptionLabel": "高", @@ -19424,10 +19384,6 @@ "xpack.triggersActionsUI.components.builtinActionTypes.slackAction.selectMessageText": "Slack チャネルにメッセージを送信します。", "xpack.triggersActionsUI.components.builtinActionTypes.slackAction.webhookUrlHelpLabel": "Slack Web フック URL を作成", "xpack.triggersActionsUI.components.builtinActionTypes.slackAction.webhookUrlTextFieldLabel": "Web フック URL", - "xpack.triggersActionsUI.components.builtinActionTypes.updateConnector": "コネクターを更新", - "xpack.triggersActionsUI.components.builtinActionTypes.updateSelectedConnector": "{ connectorName }を更新", - "xpack.triggersActionsUI.components.builtinActionTypes.warningMessage": "選択したコネクターが削除されました。別のコネクターを選択するか、新しいコネクターを作成してください。", - "xpack.triggersActionsUI.components.builtinActionTypes.warningTitle": "警告", "xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.actionTypeTitle": "Web フックデータ", "xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.addHeader": "ヘッダーを追加", "xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.addHeaderButton": "追加", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e236de9314cb2..bb69818f52006 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -3694,6 +3694,8 @@ "visTypeTable.aggTable.rawLabel": "原始", "visTypeTable.directives.tableCellFilter.filterForValueTooltip": "筛留值", "visTypeTable.directives.tableCellFilter.filterOutValueTooltip": "筛除值", + "visTypeTable.tableCellFilter.filterForValueText": "筛留值", + "visTypeTable.tableCellFilter.filterOutValueText": "筛除值", "visTypeTable.function.help": "表可视化", "visTypeTable.params.defaultPercentageCol": "不显示", "visTypeTable.params.PercentageColLabel": "百分比列", @@ -4584,13 +4586,6 @@ "xpack.actions.actionTypeRegistry.get.missingActionTypeErrorMessage": "未注册操作类型“{id}”。", "xpack.actions.actionTypeRegistry.register.duplicateActionTypeErrorMessage": "操作类型“{id}”已注册。", "xpack.actions.appName": "操作", - "xpack.actions.builtin.case.common.externalIncidentAdded": "(由 {user} 于 {date}添加)", - "xpack.actions.builtin.case.common.externalIncidentCreated": "(由 {user} 于 {date}创建)", - "xpack.actions.builtin.case.common.externalIncidentDefault": "(由 {user} 于 {date}创建)", - "xpack.actions.builtin.case.common.externalIncidentUpdated": "(由 {user} 于 {date}更新)", - "xpack.actions.builtin.case.configuration.apiWhitelistError": "配置连接器操作时出错:{message}", - "xpack.actions.builtin.case.configuration.emptyMapping": "[casesConfiguration.mapping]:应为非空,但却为空", - "xpack.actions.builtin.case.connectorApiNullError": "需要指定连接器 [apiUrl]", "xpack.actions.builtin.case.jiraTitle": "Jira", "xpack.actions.builtin.case.resilientTitle": "IBM Resilient", "xpack.actions.builtin.configuration.apiAllowedHostsError": "配置连接器操作时出错:{message}", @@ -4599,7 +4594,6 @@ "xpack.actions.builtin.esIndex.errorIndexingErrorMessage": "索引文档时出错", "xpack.actions.builtin.esIndexTitle": "索引", "xpack.actions.builtin.jira.configuration.apiAllowedHostsError": "配置连接器操作时出错:{message}", - "xpack.actions.builtin.jira.configuration.emptyMapping": "[incidentConfiguration.mapping]:应为非空,但却为空", "xpack.actions.builtin.pagerduty.invalidTimestampErrorMessage": "解析时间戳“{timestamp}”时出错", "xpack.actions.builtin.pagerduty.missingDedupkeyErrorMessage": "当 eventAction 是“{eventAction}”时需要 DedupKey", "xpack.actions.builtin.pagerduty.pagerdutyConfigurationError": "配置 pagerduty 操作时出错:{message}", @@ -4610,7 +4604,6 @@ "xpack.actions.builtin.pagerdutyTitle": "PagerDuty", "xpack.actions.builtin.serverLog.errorLoggingErrorMessage": "记录消息时出错", "xpack.actions.builtin.serverLogTitle": "服务器日志", - "xpack.actions.builtin.servicenow.configuration.emptyMapping": "[incidentConfiguration.mapping]:应为非空,但却为空", "xpack.actions.builtin.servicenowTitle": "ServiceNow", "xpack.actions.builtin.slack.errorPostingErrorMessage": "发布 slack 消息时出错", "xpack.actions.builtin.slack.errorPostingRetryDateErrorMessage": "发布 slack 消息时出错,在 {retryString} 重试", @@ -16364,14 +16357,11 @@ "xpack.securitySolution.case.configureCases.caseClosureOptionsManual": "手动关闭 Security 案例", "xpack.securitySolution.case.configureCases.caseClosureOptionsNewIncident": "将新事件推送到外部系统时自动关闭 Security 案例", "xpack.securitySolution.case.configureCases.caseClosureOptionsTitle": "案例关闭", - "xpack.securitySolution.case.configureCases.fieldMappingDesc": "将数据推送到第三方时映射 Security 案例字段。要映射字段,需要与外部事件管理系统建立连接。", "xpack.securitySolution.case.configureCases.fieldMappingEditAppend": "追加", "xpack.securitySolution.case.configureCases.fieldMappingEditNothing": "无内容", "xpack.securitySolution.case.configureCases.fieldMappingEditOverwrite": "覆盖", "xpack.securitySolution.case.configureCases.fieldMappingFirstCol": "Security 案例字段", - "xpack.securitySolution.case.configureCases.fieldMappingSecondCol": "外部事件字段", "xpack.securitySolution.case.configureCases.fieldMappingThirdCol": "编辑和更新时", - "xpack.securitySolution.case.configureCases.fieldMappingTitle": "字段映射", "xpack.securitySolution.case.configureCases.headerTitle": "配置案例", "xpack.securitySolution.case.configureCases.incidentManagementSystemDesc": "您可能会根据需要将 Security 案例连接到选择的外部事件管理系统。这将允许您将案例数据作为事件推送到所选第三方系统。", "xpack.securitySolution.case.configureCases.incidentManagementSystemLabel": "事件管理系统", @@ -19267,16 +19257,7 @@ "xpack.triggersActionsUI.common.expressionItems.threshold.popoverTitle": "当", "xpack.triggersActionsUI.components.addMessageVariables.addVariablePopoverButton": "添加变量", "xpack.triggersActionsUI.components.addMessageVariables.addVariableTitle": "添加告警变量", - "xpack.triggersActionsUI.components.builtinActionTypes.addNewConnector": "添加新连接器", - "xpack.triggersActionsUI.components.builtinActionTypes.cancelButton": "取消", - "xpack.triggersActionsUI.components.builtinActionTypes.caseClosureOptionsClosedIncident": "在外部系统中关闭事件时自动关闭 Security 案例", - "xpack.triggersActionsUI.components.builtinActionTypes.caseClosureOptionsDesc": "定义关闭 Security 案例的方式。要自动关闭案例,需要与外部事件管理系统建立连接。", - "xpack.triggersActionsUI.components.builtinActionTypes.caseClosureOptionsLabel": "案例关闭选项", - "xpack.triggersActionsUI.components.builtinActionTypes.caseClosureOptionsManual": "手动关闭 Security 案例", - "xpack.triggersActionsUI.components.builtinActionTypes.caseClosureOptionsNewIncident": "将新事件推送到外部系统时自动关闭 Security 案例", - "xpack.triggersActionsUI.components.builtinActionTypes.caseClosureOptionsTitle": "案例关闭", "xpack.triggersActionsUI.components.builtinActionTypes.common.requiredDescriptionTextField": "描述必填。", - "xpack.triggersActionsUI.components.builtinActionTypes.common.requiredTitleTextField": "标题必填。", "xpack.triggersActionsUI.components.builtinActionTypes.emailAction.actionTypeTitle": "发送到电子邮件", "xpack.triggersActionsUI.components.builtinActionTypes.emailAction.configureAccountsHelpLabel": "配置电子邮件帐户。", "xpack.triggersActionsUI.components.builtinActionTypes.emailAction.selectMessageText": "从您的服务器发送电子邮件。", @@ -19295,17 +19276,6 @@ "xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSubjectText": "“主题”必填。", "xpack.triggersActionsUI.components.builtinActionTypes.error.requiredUserText": "使用密码时,“用户名”必填。", "xpack.triggersActionsUI.components.builtinActionTypes.error.requiredWebhookBodyText": "“正文”必填。", - "xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingDesc": "将数据推送到第三方时映射 Security 案例字段。要映射字段,需要与外部事件管理系统建立连接。", - "xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingEditAppend": "追加", - "xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingEditNothing": "无内容", - "xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingEditOverwrite": "覆盖", - "xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingFirstCol": "Security 案例字段", - "xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingSecondCol": "外部事件字段", - "xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingThirdCol": "编辑和更新时", - "xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingTitle": "字段映射", - "xpack.triggersActionsUI.components.builtinActionTypes.incidentManagementSystemDesc": "您可能会根据需要将 Security 案例连接到选择的外部事件管理系统。这将允许您将案例数据作为事件推送到所选第三方系统。", - "xpack.triggersActionsUI.components.builtinActionTypes.incidentManagementSystemLabel": "事件管理系统", - "xpack.triggersActionsUI.components.builtinActionTypes.incidentManagementSystemTitle": "连接到外部事件管理系统", "xpack.triggersActionsUI.components.builtinActionTypes.indexAction.actionTypeTitle": "索引数据", "xpack.triggersActionsUI.components.builtinActionTypes.indexAction.chooseLabel": "选择……", "xpack.triggersActionsUI.components.builtinActionTypes.indexAction.configureIndexHelpLabel": "配置索引连接器。", @@ -19340,21 +19310,16 @@ "xpack.triggersActionsUI.components.builtinActionTypes.jira.requiredDescriptionTextField": "“描述”必填。", "xpack.triggersActionsUI.components.builtinActionTypes.jira.requiredEmailTextField": "“电子邮件”或“用户名”必填", "xpack.triggersActionsUI.components.builtinActionTypes.jira.requiredProjectKeyTextField": "“项目键”必填", - "xpack.triggersActionsUI.components.builtinActionTypes.jira.savedObjectIdFieldHelp": "JIRA 将此操作与 Kibana 已保存对象的 ID 关联。", - "xpack.triggersActionsUI.components.builtinActionTypes.jira.savedObjectIdFieldLabel": "对象 ID(可选)", "xpack.triggersActionsUI.components.builtinActionTypes.jira.searchIssuesComboBoxAriaLabel": "选择父问题", "xpack.triggersActionsUI.components.builtinActionTypes.jira.searchIssuesComboBoxPlaceholder": "选择父问题", "xpack.triggersActionsUI.components.builtinActionTypes.jira.searchIssuesLoading": "正在加载……", "xpack.triggersActionsUI.components.builtinActionTypes.jira.selectMessageText": "将数据推送或更新到 Jira 中的新问题", "xpack.triggersActionsUI.components.builtinActionTypes.jira.severitySelectFieldLabel": "优先级", - "xpack.triggersActionsUI.components.builtinActionTypes.jira.titleFieldLabel": "摘要", "xpack.triggersActionsUI.components.builtinActionTypes.jira.unableToGetFieldsMessage": "无法获取字段", "xpack.triggersActionsUI.components.builtinActionTypes.jira.unableToGetIssueMessage": "无法获取 ID 为 {id} 的问题", "xpack.triggersActionsUI.components.builtinActionTypes.jira.unableToGetIssuesMessage": "无法获取问题", "xpack.triggersActionsUI.components.builtinActionTypes.jira.unableToGetIssueTypesMessage": "无法获取问题类型", "xpack.triggersActionsUI.components.builtinActionTypes.jira.urgencySelectFieldLabel": "问题类型", - "xpack.triggersActionsUI.components.builtinActionTypes.mappingFieldNotMapped": "未映射", - "xpack.triggersActionsUI.components.builtinActionTypes.noConnector": "未选择连接器", "xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.actionTypeTitle": "发送到 PagerDuty", "xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.apiUrlTextFieldLabel": "API URL(可选)", "xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.classFieldLabel": "类(可选)", @@ -19395,11 +19360,8 @@ "xpack.triggersActionsUI.components.builtinActionTypes.resilient.requiredApiKeySecretTextField": "“API 密钥密码”必填", "xpack.triggersActionsUI.components.builtinActionTypes.resilient.requiredApiUrlTextField": "“URL”必填。", "xpack.triggersActionsUI.components.builtinActionTypes.resilient.requiredOrgIdTextField": "“组织 ID”必填", - "xpack.triggersActionsUI.components.builtinActionTypes.resilient.savedObjectIdFieldHelp": "IBM Resilient 将此操作与 Kibana 已保存对象的 ID 关联。", - "xpack.triggersActionsUI.components.builtinActionTypes.resilient.savedObjectIdFieldLabel": "对象 ID(可选)", "xpack.triggersActionsUI.components.builtinActionTypes.resilient.selectMessageText": "将数据推送或更新到 Resilient 中的新事件。", "xpack.triggersActionsUI.components.builtinActionTypes.resilient.severity": "严重性", - "xpack.triggersActionsUI.components.builtinActionTypes.resilient.titleFieldLabel": "名称", "xpack.triggersActionsUI.components.builtinActionTypes.resilient.unableToGetIncidentTypesMessage": "无法获取事件类型", "xpack.triggersActionsUI.components.builtinActionTypes.resilient.unableToGetSeverityMessage": "无法获取严重性", "xpack.triggersActionsUI.components.builtinActionTypes.resilient.urgencySelectFieldLabel": "事件类型", @@ -19424,8 +19386,6 @@ "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredEmailTextField": "“电子邮件”必填。", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredPasswordTextField": "“密码”必填。", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredUsernameTextField": "“用户名”必填。", - "xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.savedObjectIdFieldHelp": "ServiceNow 将此操作与 Kibana 已保存对象的 ID 关联。", - "xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.savedObjectIdFieldLabel": "对象 ID(可选)", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.selectMessageText": "在 ServiceNow 中创建事件。", "xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.severitySelectFieldLabel": "严重性", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectHighOptionLabel": "高", @@ -19442,10 +19402,6 @@ "xpack.triggersActionsUI.components.builtinActionTypes.slackAction.selectMessageText": "向 Slack 频道或用户发送消息。", "xpack.triggersActionsUI.components.builtinActionTypes.slackAction.webhookUrlHelpLabel": "创建 Slack webhook URL", "xpack.triggersActionsUI.components.builtinActionTypes.slackAction.webhookUrlTextFieldLabel": "Webhook URL", - "xpack.triggersActionsUI.components.builtinActionTypes.updateConnector": "更新连接器", - "xpack.triggersActionsUI.components.builtinActionTypes.updateSelectedConnector": "更新 { connectorName }", - "xpack.triggersActionsUI.components.builtinActionTypes.warningMessage": "选定的连接器已删除。选择不同的连接器或创建新的连接器。", - "xpack.triggersActionsUI.components.builtinActionTypes.warningTitle": "警告", "xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.actionTypeTitle": "Webhook 数据", "xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.addHeader": "添加标头", "xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.addHeaderButton": "添加", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/field_mapping.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/field_mapping.tsx deleted file mode 100644 index a3382513d2bcb..0000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/field_mapping.tsx +++ /dev/null @@ -1,140 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { useCallback, useMemo } from 'react'; -import { EuiFormRow, EuiFlexItem, EuiFlexGroup, EuiSuperSelectOption } from '@elastic/eui'; -import styled from 'styled-components'; - -import { FieldMappingRow } from './field_mapping_row'; -import * as i18n from './translations'; - -import { setActionTypeToMapping, setThirdPartyToMapping } from './utils'; -import { ThirdPartyField as ConnectorConfigurationThirdPartyField } from './types'; -import { CasesConfigurationMapping } from './types'; -import { createDefaultMapping } from './utils'; - -const FieldRowWrapper = styled.div` - margin-top: 8px; - font-size: 14px; -`; - -const actionTypeOptions: Array> = [ - { - value: 'nothing', - inputDisplay: <>{i18n.FIELD_MAPPING_EDIT_NOTHING}, - 'data-test-subj': 'edit-update-option-nothing', - }, - { - value: 'overwrite', - inputDisplay: <>{i18n.FIELD_MAPPING_EDIT_OVERWRITE}, - 'data-test-subj': 'edit-update-option-overwrite', - }, - { - value: 'append', - inputDisplay: <>{i18n.FIELD_MAPPING_EDIT_APPEND}, - 'data-test-subj': 'edit-update-option-append', - }, -]; - -const getThirdPartyOptions = ( - caseField: string, - thirdPartyFields: Record -): Array> => - (Object.keys(thirdPartyFields) as string[]).reduce>>( - (acc, key) => { - if (thirdPartyFields[key].validSourceFields.includes(caseField)) { - return [ - ...acc, - { - value: key, - inputDisplay: {thirdPartyFields[key].label}, - 'data-test-subj': `dropdown-mapping-${key}`, - }, - ]; - } - return acc; - }, - [ - { - value: 'not_mapped', - inputDisplay: i18n.MAPPING_FIELD_NOT_MAPPED, - 'data-test-subj': 'dropdown-mapping-not_mapped', - }, - ] - ); - -export interface FieldMappingProps { - disabled: boolean; - mapping: CasesConfigurationMapping[] | null; - onChangeMapping: (newMapping: CasesConfigurationMapping[]) => void; - connectorConfiguration: Record; -} - -const FieldMappingComponent: React.FC = ({ - disabled, - mapping, - onChangeMapping, - connectorConfiguration, -}) => { - const onChangeActionType = useCallback( - (caseField: string, newActionType: string) => { - const myMapping = mapping ?? defaultMapping; - onChangeMapping(setActionTypeToMapping(caseField, newActionType, myMapping)); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [mapping] - ); - - const onChangeThirdParty = useCallback( - (caseField: string, newThirdPartyField: string) => { - const myMapping = mapping ?? defaultMapping; - onChangeMapping(setThirdPartyToMapping(caseField, newThirdPartyField, myMapping)); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [mapping] - ); - - const selectedConnector = connectorConfiguration ?? { fields: {} }; - const defaultMapping = useMemo(() => createDefaultMapping(selectedConnector.fields), [ - selectedConnector.fields, - ]); - - return ( - <> - - - - {i18n.FIELD_MAPPING_FIRST_COL} - - - {i18n.FIELD_MAPPING_SECOND_COL} - - - {i18n.FIELD_MAPPING_THIRD_COL} - - - - - {(mapping ?? defaultMapping).map((item) => ( - - ))} - - - ); -}; - -export const FieldMapping = React.memo(FieldMappingComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/field_mapping_row.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/field_mapping_row.tsx deleted file mode 100644 index beca8f1fbbc77..0000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/field_mapping_row.tsx +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { useMemo } from 'react'; -import { - EuiFlexItem, - EuiFlexGroup, - EuiSuperSelect, - EuiIcon, - EuiSuperSelectOption, -} from '@elastic/eui'; - -import { capitalize } from 'lodash'; - -export interface RowProps { - id: string; - disabled: boolean; - securitySolutionField: string; - thirdPartyOptions: Array>; - actionTypeOptions: Array>; - onChangeActionType: (caseField: string, newActionType: string) => void; - onChangeThirdParty: (caseField: string, newThirdPartyField: string) => void; - selectedActionType: string; - selectedThirdParty: string; -} - -const FieldMappingRowComponent: React.FC = ({ - id, - disabled, - securitySolutionField, - thirdPartyOptions, - actionTypeOptions, - onChangeActionType, - onChangeThirdParty, - selectedActionType, - selectedThirdParty, -}) => { - const securitySolutionFieldCapitalized = useMemo(() => capitalize(securitySolutionField), [ - securitySolutionField, - ]); - return ( - - - - - {securitySolutionFieldCapitalized} - - - - - - - - - - - - - - ); -}; - -export const FieldMappingRow = React.memo(FieldMappingRowComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/translations.ts deleted file mode 100644 index 665ccbcfa114d..0000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/translations.ts +++ /dev/null @@ -1,190 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; - -export const INCIDENT_MANAGEMENT_SYSTEM_TITLE = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.incidentManagementSystemTitle', - { - defaultMessage: 'Connect to external incident management system', - } -); - -export const INCIDENT_MANAGEMENT_SYSTEM_DESC = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.incidentManagementSystemDesc', - { - defaultMessage: - 'You may optionally connect Security cases to an external incident management system of your choosing. This will allow you to push case data as an incident in your chosen third-party system.', - } -); - -export const INCIDENT_MANAGEMENT_SYSTEM_LABEL = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.incidentManagementSystemLabel', - { - defaultMessage: 'Incident management system', - } -); - -export const NO_CONNECTOR = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.noConnector', - { - defaultMessage: 'No connector selected', - } -); - -export const ADD_NEW_CONNECTOR = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.addNewConnector', - { - defaultMessage: 'Add new connector', - } -); - -export const CASE_CLOSURE_OPTIONS_TITLE = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.caseClosureOptionsTitle', - { - defaultMessage: 'Case Closures', - } -); - -export const CASE_CLOSURE_OPTIONS_DESC = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.caseClosureOptionsDesc', - { - defaultMessage: - 'Define how you wish Security cases to be closed. Automated case closures require an established connection to an external incident management system.', - } -); - -export const CASE_CLOSURE_OPTIONS_LABEL = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.caseClosureOptionsLabel', - { - defaultMessage: 'Case closure options', - } -); - -export const CASE_CLOSURE_OPTIONS_MANUAL = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.caseClosureOptionsManual', - { - defaultMessage: 'Manually close Security cases', - } -); - -export const CASE_CLOSURE_OPTIONS_NEW_INCIDENT = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.caseClosureOptionsNewIncident', - { - defaultMessage: - 'Automatically close Security cases when pushing new incident to external system', - } -); - -export const CASE_CLOSURE_OPTIONS_CLOSED_INCIDENT = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.caseClosureOptionsClosedIncident', - { - defaultMessage: 'Automatically close Security cases when incident is closed in external system', - } -); - -export const FIELD_MAPPING_TITLE = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingTitle', - { - defaultMessage: 'Field mappings', - } -); - -export const FIELD_MAPPING_DESC = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingDesc', - { - defaultMessage: - 'Map Security case fields when pushing data to a third-party. Field mappings require an established connection to an external incident management system.', - } -); - -export const FIELD_MAPPING_FIRST_COL = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingFirstCol', - { - defaultMessage: 'Security case field', - } -); - -export const FIELD_MAPPING_SECOND_COL = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingSecondCol', - { - defaultMessage: 'External incident field', - } -); - -export const FIELD_MAPPING_THIRD_COL = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingThirdCol', - { - defaultMessage: 'On edit and update', - } -); - -export const FIELD_MAPPING_EDIT_NOTHING = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingEditNothing', - { - defaultMessage: 'Nothing', - } -); - -export const FIELD_MAPPING_EDIT_OVERWRITE = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingEditOverwrite', - { - defaultMessage: 'Overwrite', - } -); - -export const FIELD_MAPPING_EDIT_APPEND = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingEditAppend', - { - defaultMessage: 'Append', - } -); - -export const CANCEL = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.cancelButton', - { - defaultMessage: 'Cancel', - } -); - -export const WARNING_NO_CONNECTOR_TITLE = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.warningTitle', - { - defaultMessage: 'Warning', - } -); - -export const WARNING_NO_CONNECTOR_MESSAGE = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.warningMessage', - { - defaultMessage: - 'The selected connector has been deleted. Either select a different connector or create a new one.', - } -); - -export const MAPPING_FIELD_NOT_MAPPED = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.mappingFieldNotMapped', - { - defaultMessage: 'Not mapped', - } -); - -export const UPDATE_CONNECTOR = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.updateConnector', - { - defaultMessage: 'Update connector', - } -); - -export const UPDATE_SELECTED_CONNECTOR = (connectorName: string): string => { - return i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.updateSelectedConnector', - { - values: { connectorName }, - defaultMessage: 'Update { connectorName }', - } - ); -}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/types.ts deleted file mode 100644 index 3571db39b596a..0000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/types.ts +++ /dev/null @@ -1,21 +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 { ActionType } from '../../../../types'; - -export { ActionType }; - -export interface ThirdPartyField { - label: string; - validSourceFields: string[]; - defaultSourceField: string; - defaultActionType: string; -} -export interface CasesConfigurationMapping { - source: string; - target: string; - actionType: string; -} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/utils.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/utils.ts deleted file mode 100644 index b14b1b76427c6..0000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/utils.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { CasesConfigurationMapping } from './types'; - -export const setActionTypeToMapping = ( - caseField: string, - newActionType: string, - mapping: CasesConfigurationMapping[] -): CasesConfigurationMapping[] => { - const findItemIndex = mapping.findIndex((item) => item.source === caseField); - - if (findItemIndex >= 0) { - return [ - ...mapping.slice(0, findItemIndex), - { ...mapping[findItemIndex], actionType: newActionType }, - ...mapping.slice(findItemIndex + 1), - ]; - } - - return [...mapping]; -}; - -export const setThirdPartyToMapping = ( - caseField: string, - newThirdPartyField: string, - mapping: CasesConfigurationMapping[] -): CasesConfigurationMapping[] => - mapping.map((item) => { - if (item.source !== caseField && item.target === newThirdPartyField) { - return { ...item, target: 'not_mapped' }; - } else if (item.source === caseField) { - return { ...item, target: newThirdPartyField }; - } - return item; - }); - -export const createDefaultMapping = (fields: Record): CasesConfigurationMapping[] => - Object.keys(fields).map( - (key) => - ({ - source: fields[key].defaultSourceField, - target: key, - actionType: fields[key].defaultActionType, - } as CasesConfigurationMapping) - ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx index b10341fa00f1b..95c2db0948ea3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx @@ -78,22 +78,22 @@ describe('jira connector validation', () => { describe('jira action params validation', () => { test('action params validation succeeds when action params is valid', () => { const actionParams = { - subActionParams: { title: 'some title {{test}}' }, + subActionParams: { incident: { summary: 'some title {{test}}' }, comments: [] }, }; expect(actionTypeModel.validateParams(actionParams)).toEqual({ - errors: { title: [] }, + errors: { summary: [] }, }); }); test('params validation fails when body is not valid', () => { const actionParams = { - subActionParams: { title: '' }, + subActionParams: { incident: { summary: '' }, comments: [] }, }; expect(actionTypeModel.validateParams(actionParams)).toEqual({ errors: { - title: ['Summary is required.'], + summary: ['Summary is required.'], }, }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx index 20374cfbe3a3b..954623939cd3b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx @@ -61,11 +61,15 @@ export function getActionType(): ActionTypeModel { const validationResult = { errors: {} }; const errors = { - title: new Array(), + summary: new Array(), }; validationResult.errors = errors; - if (!actionParams.subActionParams?.title?.length) { - errors.title.push(i18n.SUMMARY_REQUIRED); + if ( + actionParams.subActionParams && + actionParams.subActionParams.incident && + !actionParams.subActionParams.incident.summary?.length + ) { + errors.summary.push(i18n.SUMMARY_REQUIRED); } return validationResult; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx index e3f9bb99b48b6..35e0b099fe004 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx @@ -74,7 +74,6 @@ describe('JiraActionConnectorFields renders', () => { consumer={'case'} /> ); - expect(wrapper.find('[data-test-subj="case-jira-mappings"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="apiUrlFromInput"]').length > 0).toBeTruthy(); expect( wrapper.find('[data-test-subj="connector-jira-project-key-form-input"]').length > 0 diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx index f32b521797f58..fea398002fddd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx @@ -17,25 +17,19 @@ import { EuiTitle, } from '@elastic/eui'; -import { isEmpty } from 'lodash'; import { ActionConnectorFieldsProps } from '../../../../types'; -import { CasesConfigurationMapping, FieldMapping, createDefaultMapping } from '../case_mappings'; import * as i18n from './translations'; import { JiraActionConnector } from './types'; -import { connectorConfiguration } from './config'; const JiraConnectorFields: React.FC> = ({ action, editActionSecrets, editActionConfig, errors, - consumer, readOnly, }) => { - // TODO: remove incidentConfiguration later, when Case Jira will move their fields to the level of action execution - const { apiUrl, projectKey, incidentConfiguration, isCaseOwned } = action.config; - const mapping = incidentConfiguration ? incidentConfiguration.mapping : []; + const { apiUrl, projectKey } = action.config; const isApiUrlInvalid: boolean = errors.apiUrl.length > 0 && apiUrl != null; @@ -45,38 +39,27 @@ const JiraConnectorFields: React.FC 0 && email != null; const isApiTokenInvalid: boolean = errors.apiToken.length > 0 && apiToken != null; - // TODO: remove this block later, when Case ServiceNow will move their fields to the level of action execution - if (consumer === 'case') { - if (isEmpty(mapping)) { - editActionConfig('incidentConfiguration', { - mapping: createDefaultMapping(connectorConfiguration.fields as any), - }); - } - if (!isCaseOwned) { - editActionConfig('isCaseOwned', true); - } - } - const handleOnChangeActionConfig = useCallback( (key: string, value: string) => editActionConfig(key, value), - // eslint-disable-next-line react-hooks/exhaustive-deps - [] + [editActionConfig] ); const handleOnChangeSecretConfig = useCallback( (key: string, value: string) => editActionSecrets(key, value), - // eslint-disable-next-line react-hooks/exhaustive-deps - [] + [editActionSecrets] ); - const handleOnChangeMappingConfig = useCallback( - (newMapping: CasesConfigurationMapping[]) => - editActionConfig('incidentConfiguration', { - ...action.config.incidentConfiguration, - mapping: newMapping, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [action.config] + const handleResetField = useCallback( + (checkValue, fieldName: string, actionField: 'config' | 'secrets') => { + if (!checkValue) { + if (actionField === 'config') { + handleOnChangeActionConfig(fieldName, ''); + } else { + handleOnChangeSecretConfig(fieldName, ''); + } + } + }, + [handleOnChangeActionConfig, handleOnChangeSecretConfig] ); return ( @@ -98,11 +81,7 @@ const JiraConnectorFields: React.FC handleOnChangeActionConfig('apiUrl', evt.target.value)} - onBlur={() => { - if (!apiUrl) { - editActionConfig('apiUrl', ''); - } - }} + onBlur={() => handleResetField(apiUrl, 'apiUrl', 'config')} /> @@ -124,11 +103,7 @@ const JiraConnectorFields: React.FC handleOnChangeActionConfig('projectKey', evt.target.value)} - onBlur={() => { - if (!projectKey) { - editActionConfig('projectKey', ''); - } - }} + onBlur={() => handleResetField(projectKey, 'projectKey', 'config')} /> @@ -165,11 +140,7 @@ const JiraConnectorFields: React.FC handleOnChangeSecretConfig('email', evt.target.value)} - onBlur={() => { - if (!email) { - editActionSecrets('email', ''); - } - }} + onBlur={() => handleResetField(email, 'email', 'secrets')} /> @@ -192,30 +163,11 @@ const JiraConnectorFields: React.FC handleOnChangeSecretConfig('apiToken', evt.target.value)} - onBlur={() => { - if (!apiToken) { - editActionSecrets('apiToken', ''); - } - }} + onBlur={() => handleResetField(apiToken, 'apiToken', 'secrets')} /> - {consumer === 'case' && ( // TODO: remove this block later, when Case Jira will move their fields to the level of action execution - <> - - - - - - - - )} ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx index e7bec0b4b4452..5da629efefdc6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx @@ -21,15 +21,16 @@ const useGetFieldsByIssueTypeMock = useGetFieldsByIssueType as jest.Mock; const actionParams = { subAction: 'pushToService', subActionParams: { - title: 'sn title', - description: 'some description', + incident: { + summary: 'sn title', + description: 'some description', + issueType: '10006', + labels: ['kibana'], + priority: 'High', + externalId: null, + parent: null, + }, comments: [{ commentId: '1', comment: 'comment for jira' }], - issueType: '10006', - labels: ['kibana'], - priority: 'High', - savedObjectId: '123', - externalId: null, - parent: null, }, }; @@ -84,7 +85,7 @@ describe('JiraParamsFields renders', () => { const wrapper = mountWithIntl( {}} index={0} messageVariables={[{ name: AlertProvidedActionVariables.alertId, description: '' }]} @@ -97,28 +98,10 @@ describe('JiraParamsFields renders', () => { expect(wrapper.find('[data-test-subj="prioritySelect"]').first().prop('value')).toStrictEqual( 'High' ); - expect(wrapper.find('[data-test-subj="titleInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="summaryInput"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="descriptionTextArea"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="labelsComboBox"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="commentsTextArea"]').length > 0).toBeTruthy(); - - // ensure savedObjectIdInput isnt rendered - expect(wrapper.find('[data-test-subj="savedObjectIdInput"]').length === 0).toBeTruthy(); - }); - - test('the savedObjectId fields is rendered if we cant find an alertId in the messageVariables', () => { - const wrapper = mountWithIntl( - {}} - index={0} - messageVariables={[]} - actionConnector={connector} - /> - ); - - expect(wrapper.find('[data-test-subj="savedObjectIdInput"]').length > 0).toBeTruthy(); }); test('it shows loading when loading issue types', () => { @@ -126,7 +109,7 @@ describe('JiraParamsFields renders', () => { const wrapper = mountWithIntl( {}} index={0} messageVariables={[]} @@ -148,7 +131,7 @@ describe('JiraParamsFields renders', () => { const wrapper = mountWithIntl( {}} index={0} messageVariables={[]} @@ -170,7 +153,7 @@ describe('JiraParamsFields renders', () => { const wrapper = mountWithIntl( {}} index={0} messageVariables={[]} @@ -196,7 +179,7 @@ describe('JiraParamsFields renders', () => { const wrapper = mountWithIntl( {}} index={0} messageVariables={[]} @@ -222,7 +205,7 @@ describe('JiraParamsFields renders', () => { const wrapper = mountWithIntl( {}} index={0} messageVariables={[]} @@ -231,7 +214,7 @@ describe('JiraParamsFields renders', () => { ); expect(wrapper.find('[data-test-subj="issueTypeSelect"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="titleInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="summaryInput"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="commentsTextArea"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="prioritySelect"]').exists()).toBeFalsy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx index aaa9b697f32ec..91bab3bc3eb97 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, useEffect, useState, useMemo } from 'react'; -import { map } from 'lodash/fp'; -import { isSome } from 'fp-ts/lib/Option'; +import React, { Fragment, useCallback, useEffect, useMemo, useRef } from 'react'; + import { i18n } from '@kbn/i18n'; import { EuiFormRow, @@ -14,8 +13,6 @@ import { EuiSelectOption, EuiHorizontalRule, EuiSelect, - EuiFormControlLayout, - EuiIconTip, EuiFlexGroup, EuiFlexItem, EuiSpacer, @@ -28,36 +25,30 @@ import { JiraActionParams } from './types'; import { useGetIssueTypes } from './use_get_issue_types'; import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type'; import { SearchIssues } from './search_issues'; -import { extractActionVariable } from '../extract_action_variable'; -import { AlertProvidedActionVariables } from '../../../lib/action_variables'; import { useKibana } from '../../../../common/lib/kibana'; const JiraParamsFields: React.FunctionComponent> = ({ + actionConnector, actionParams, editAction, - index, errors, + index, messageVariables, - actionConnector, }) => { const { http, notifications: { toasts }, } = useKibana().services; - const { title, description, comments, issueType, priority, labels, parent, savedObjectId } = - actionParams.subActionParams || {}; - - const [issueTypesSelectOptions, setIssueTypesSelectOptions] = useState([]); - const [firstLoad, setFirstLoad] = useState(false); - const [prioritiesSelectOptions, setPrioritiesSelectOptions] = useState([]); - - const isActionBeingConfiguredByAnAlert = messageVariables - ? isSome(extractActionVariable(messageVariables, AlertProvidedActionVariables.alertId)) - : false; - - useEffect(() => { - setFirstLoad(true); - }, []); + const { incident, comments } = useMemo( + () => + actionParams.subActionParams ?? + (({ + incident: {}, + comments: [], + } as unknown) as JiraActionParams['subActionParams']), + [actionParams.subActionParams] + ); + const actionConnectorRef = useRef(actionConnector?.id ?? ''); const { isLoading: isLoadingIssueTypes, issueTypes } = useGetIssueTypes({ http, @@ -69,8 +60,29 @@ const JiraParamsFields: React.FunctionComponent { + const newProps = + key !== 'comments' + ? { + incident: { ...incident, [key]: value }, + comments, + } + : { incident, [key]: value }; + editAction('subActionParams', newProps, index); + }, + [comments, editAction, incident, index] + ); + const editComment = useCallback( + (key, value) => { + if (value.length > 0) { + editSubActionProperty(key, [{ commentId: '1', comment: value }]); + } + }, + [editSubActionProperty] + ); const hasLabels = useMemo(() => Object.prototype.hasOwnProperty.call(fields, 'labels'), [fields]); const hasDescription = useMemo( @@ -81,98 +93,71 @@ const JiraParamsFields: React.FunctionComponent Object.prototype.hasOwnProperty.call(fields, 'parent'), [fields]); - - useEffect(() => { - const options = issueTypes.map((type) => ({ + const issueTypesSelectOptions: EuiSelectOption[] = useMemo(() => { + const doesIssueTypeExist = + incident.issueType != null && issueTypes.length + ? issueTypes.some((t) => t.id === incident.issueType) + : true; + if ((!incident.issueType || !doesIssueTypeExist) && issueTypes.length > 0) { + editSubActionProperty('issueType', issueTypes[0].id ?? ''); + } + return issueTypes.map((type) => ({ value: type.id ?? '', text: type.name ?? '', })); - - setIssueTypesSelectOptions(options); - }, [issueTypes]); - - useEffect(() => { - if (issueType != null && fields != null) { - const priorities = fields.priority?.allowedValues ?? []; - const options = map( - (p) => ({ + }, [editSubActionProperty, incident, issueTypes]); + const prioritiesSelectOptions: EuiSelectOption[] = useMemo(() => { + if (incident.issueType != null && fields != null) { + const priorities = fields.priority != null ? fields.priority.allowedValues : []; + const doesPriorityExist = priorities.some((p) => p.name === incident.priority); + if ((!incident.priority || !doesPriorityExist) && priorities.length > 0) { + editSubActionProperty('priority', priorities[0].name ?? ''); + } + return priorities.map((p: { id: string; name: string }) => { + return { value: p.name, text: p.name, - }), - priorities - ); - setPrioritiesSelectOptions(options); + }; + }); } - }, [fields, issueType]); + return []; + }, [editSubActionProperty, fields, incident.issueType, incident.priority]); - const labelOptions = useMemo(() => (labels ? labels.map((label: string) => ({ label })) : []), [ - labels, - ]); - - const editSubActionProperty = (key: string, value: any) => { - const newProps = { ...actionParams.subActionParams, [key]: value }; - editAction('subActionParams', newProps, index); - }; + const labelOptions = useMemo( + () => (incident.labels ? incident.labels.map((label: string) => ({ label })) : []), + [incident.labels] + ); - // Reset parameters when changing connector useEffect(() => { - if (!firstLoad) { - return; + if (actionConnector != null && actionConnectorRef.current !== actionConnector.id) { + actionConnectorRef.current = actionConnector.id; + editAction( + 'subActionParams', + { + incident: {}, + comments: [], + }, + index + ); } - - setIssueTypesSelectOptions([]); - editAction('subActionParams', { title, comments, description: '', savedObjectId }, index); // eslint-disable-next-line react-hooks/exhaustive-deps }, [actionConnector]); - - // Reset fields when changing connector or issue type - useEffect(() => { - if (!firstLoad) { - return; - } - - setPrioritiesSelectOptions([]); - editAction( - 'subActionParams', - { title, issueType, comments, description: '', savedObjectId }, - index - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [issueType, savedObjectId]); - useEffect(() => { if (!actionParams.subAction) { editAction('subAction', 'pushToService', index); } - if (!savedObjectId && isActionBeingConfiguredByAnAlert) { - editSubActionProperty('savedObjectId', `${AlertProvidedActionVariables.alertId}`); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - actionConnector, - actionParams.subAction, - index, - savedObjectId, - issueTypesSelectOptions, - issueType, - ]); - - // Set default issue type - useEffect(() => { - if (!issueType && issueTypesSelectOptions.length > 0) { - editSubActionProperty('issueType', issueTypesSelectOptions[0].value as string); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [issueTypes, issueTypesSelectOptions]); - - // Set default priority - useEffect(() => { - if (!priority && prioritiesSelectOptions.length > 0) { - editSubActionProperty('priority', prioritiesSelectOptions[0].value as string); + if (!actionParams.subActionParams) { + editAction( + 'subActionParams', + { + incident: {}, + comments: [], + }, + index + ); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [actionConnector, issueType, prioritiesSelectOptions]); - + }, [actionParams]); return ( <> @@ -191,10 +176,8 @@ const JiraParamsFields: React.FunctionComponent { - editSubActionProperty('issueType', e.target.value); - }} + value={incident.issueType ?? undefined} + onChange={(e) => editSubActionProperty('issueType', e.target.value)} /> @@ -212,7 +195,7 @@ const JiraParamsFields: React.FunctionComponent { editSubActionProperty('priority', e.target.value); }} @@ -259,12 +242,12 @@ const JiraParamsFields: React.FunctionComponent 0 && title !== undefined} + error={errors.summary} + isInvalid={errors.summary.length > 0 && incident.summary !== undefined} label={i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.jira.titleFieldLabel', + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.summaryFieldLabel', { - defaultMessage: 'Summary', + defaultMessage: 'Summary (required)', } )} > @@ -272,51 +255,12 @@ const JiraParamsFields: React.FunctionComponent - {!isActionBeingConfiguredByAnAlert && ( - - - - - } - > - - - - - - - )} {hasLabels && ( <> @@ -326,7 +270,7 @@ const JiraParamsFields: React.FunctionComponent @@ -350,7 +294,7 @@ const JiraParamsFields: React.FunctionComponent { - if (!labels) { + if (!incident.labels) { editSubActionProperty('labels', []); } }} @@ -369,11 +313,11 @@ const JiraParamsFields: React.FunctionComponent { - editSubActionProperty(key, [{ commentId: '1', comment: value }]); - }} + editAction={editComment} messageVariables={messageVariables} paramsProperty={'comments'} - inputTargetValue={comments && comments.length > 0 ? comments[0].comment : ''} + inputTargetValue={comments && comments.length > 0 ? comments[0].comment : undefined} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.jira.commentsTextAreaFieldLabel', { - defaultMessage: 'Additional comments (optional)', + defaultMessage: 'Additional comments', } )} errors={errors.comments as string[]} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/types.ts index e72aa1f7fc037..50483f46e1084 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/types.ts @@ -4,40 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CasesConfigurationMapping } from '../case_mappings'; import { UserConfiguredActionConnector } from '../../../../types'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ExecutorSubActionPushParams } from '../../../../../../actions/server/builtin_action_types/jira/types'; export type JiraActionConnector = UserConfiguredActionConnector; export interface JiraActionParams { subAction: string; - subActionParams: { - savedObjectId: string; - title: string; - description: string; - comments: Array<{ commentId: string; comment: string }>; - externalId: string | null; - issueType: string; - priority: string; - labels: string[]; - parent: string | null; - }; -} - -interface IncidentConfiguration { - mapping: CasesConfigurationMapping[]; + subActionParams: ExecutorSubActionPushParams; } export interface JiraConfig { apiUrl: string; projectKey: string; - incidentConfiguration?: IncidentConfiguration; - isCaseOwned?: boolean; } export interface JiraSecrets { email: string; apiToken: string; } - -// to remove diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.test.tsx index 17e9b42e7878e..2857e5dabd506 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.test.tsx @@ -78,22 +78,22 @@ describe('resilient connector validation', () => { describe('resilient action params validation', () => { test('action params validation succeeds when action params is valid', () => { const actionParams = { - subActionParams: { title: 'some title {{test}}' }, + subActionParams: { incident: { name: 'some title {{test}}' }, comments: [] }, }; expect(actionTypeModel.validateParams(actionParams)).toEqual({ - errors: { title: [] }, + errors: { name: [] }, }); }); test('params validation fails when body is not valid', () => { const actionParams = { - subActionParams: { title: '' }, + subActionParams: { incident: { name: '' }, comments: [] }, }; expect(actionTypeModel.validateParams(actionParams)).toEqual({ errors: { - title: ['Name is required.'], + name: ['Name is required.'], }, }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx index 251274a08ba6c..92b361897f8e3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx @@ -69,12 +69,15 @@ export function getActionType(): ActionTypeModel< validateParams: (actionParams: ResilientActionParams): ValidationResult => { const validationResult = { errors: {} }; const errors = { - title: new Array(), + name: new Array(), }; validationResult.errors = errors; - - if (!actionParams.subActionParams?.title?.length) { - errors.title.push(i18n.NAME_REQUIRED); + if ( + actionParams.subActionParams && + actionParams.subActionParams.incident && + !actionParams.subActionParams.incident.name?.length + ) { + errors.name.push(i18n.NAME_REQUIRED); } return validationResult; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx index a285c96219033..833f48fa50d9e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx @@ -75,7 +75,6 @@ describe('ResilientActionConnectorFields renders', () => { /> ); - expect(wrapper.find('[data-test-subj="case-resilient-mappings"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="apiUrlFromInput"]').length > 0).toBeTruthy(); expect( wrapper.find('[data-test-subj="connector-resilient-orgId-form-input"]').length > 0 diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx index cf7596442a02b..25a5295e70432 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx @@ -17,25 +17,18 @@ import { EuiTitle, } from '@elastic/eui'; -import { isEmpty } from 'lodash'; import { ActionConnectorFieldsProps } from '../../../../types'; import * as i18n from './translations'; import { ResilientActionConnector } from './types'; -import { connectorConfiguration } from './config'; -import { FieldMapping, CasesConfigurationMapping, createDefaultMapping } from '../case_mappings'; const ResilientConnectorFields: React.FC> = ({ action, editActionSecrets, editActionConfig, errors, - consumer, readOnly, }) => { - // TODO: remove incidentConfiguration later, when Case Resilient will move their fields to the level of action execution - const { apiUrl, orgId, incidentConfiguration, isCaseOwned } = action.config; - const mapping = incidentConfiguration ? incidentConfiguration.mapping : []; - + const { apiUrl, orgId } = action.config; const isApiUrlInvalid: boolean = errors.apiUrl.length > 0 && apiUrl != null; const { apiKeyId, apiKeySecret } = action.secrets; @@ -44,39 +37,14 @@ const ResilientConnectorFields: React.FC 0 && apiKeyId != null; const isApiKeySecretInvalid: boolean = errors.apiKeySecret.length > 0 && apiKeySecret != null; - // TODO: remove this block later, when Case ServiceNow will move their fields to the level of action execution - if (consumer === 'case') { - if (isEmpty(mapping)) { - editActionConfig('incidentConfiguration', { - mapping: createDefaultMapping(connectorConfiguration.fields as any), - }); - } - - if (!isCaseOwned) { - editActionConfig('isCaseOwned', true); - } - } - const handleOnChangeActionConfig = useCallback( (key: string, value: string) => editActionConfig(key, value), - // eslint-disable-next-line react-hooks/exhaustive-deps - [] + [editActionConfig] ); const handleOnChangeSecretConfig = useCallback( (key: string, value: string) => editActionSecrets(key, value), - // eslint-disable-next-line react-hooks/exhaustive-deps - [] - ); - - const handleOnChangeMappingConfig = useCallback( - (newMapping: CasesConfigurationMapping[]) => - editActionConfig('incidentConfiguration', { - ...action.config.incidentConfiguration, - mapping: newMapping, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [action.config] + [editActionSecrets] ); return ( @@ -201,21 +169,6 @@ const ResilientConnectorFields: React.FC - {consumer === 'case' && ( // TODO: remove this block later, when Case Resilient will move their fields to the level of action execution - <> - - - - - - - - )} ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.test.tsx index 5a57006cdf112..d868764d625c0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.test.tsx @@ -20,13 +20,14 @@ const useGetSeverityMock = useGetSeverity as jest.Mock; const actionParams = { subAction: 'pushToService', subActionParams: { - title: 'title', - description: 'some description', + incident: { + name: 'title', + description: 'some description', + incidentTypes: [1001], + severityCode: 6, + externalId: null, + }, comments: [{ commentId: '1', comment: 'comment for resilient' }], - incidentTypes: [1001], - severityCode: 6, - savedObjectId: '123', - externalId: null, }, }; const connector = { @@ -80,7 +81,7 @@ describe('ResilientParamsFields renders', () => { const wrapper = mountWithIntl( {}} index={0} messageVariables={[{ name: AlertProvidedActionVariables.alertId, description: '' }]} @@ -91,35 +92,16 @@ describe('ResilientParamsFields renders', () => { expect(wrapper.find('[data-test-subj="severitySelect"]').first().prop('value')).toStrictEqual( 6 ); - expect(wrapper.find('[data-test-subj="titleInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="nameInput"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="descriptionTextArea"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="commentsTextArea"]').length > 0).toBeTruthy(); - - // ensure savedObjectIdInput isnt rendered - expect(wrapper.find('[data-test-subj="savedObjectIdInput"]').length === 0).toBeTruthy(); - }); - - test('the savedObjectId fields is rendered if we cant find an alertId in the messageVariables', () => { - const wrapper = mountWithIntl( - {}} - index={0} - messageVariables={[]} - actionConnector={connector} - /> - ); - - expect(wrapper.find('[data-test-subj="savedObjectIdInput"]').length > 0).toBeTruthy(); }); - test('it shows loading when loading incident types', () => { useGetIncidentTypesMock.mockReturnValue({ ...useGetIncidentTypesResponse, isLoading: true }); const wrapper = mountWithIntl( {}} index={0} messageVariables={[]} @@ -141,7 +123,7 @@ describe('ResilientParamsFields renders', () => { const wrapper = mountWithIntl( {}} index={0} messageVariables={[]} @@ -160,7 +142,7 @@ describe('ResilientParamsFields renders', () => { const wrapper = mountWithIntl( {}} index={0} messageVariables={[]} @@ -182,7 +164,7 @@ describe('ResilientParamsFields renders', () => { const wrapper = mountWithIntl( {}} index={0} messageVariables={[]} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx index 8c384903b86e4..badb7479905d5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, useEffect, useState } from 'react'; +import React, { Fragment, useCallback, useEffect, useMemo, useRef } from 'react'; import { EuiFormRow, EuiComboBox, @@ -13,11 +13,8 @@ import { EuiTitle, EuiComboBoxOptionOption, EuiSelectOption, - EuiFormControlLayout, - EuiIconTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { isSome } from 'fp-ts/lib/Option'; import { ActionParamsProps } from '../../../../types'; import { ResilientActionParams } from './types'; @@ -26,44 +23,30 @@ import { TextFieldWithMessageVariables } from '../../text_field_with_message_var import { useGetIncidentTypes } from './use_get_incident_types'; import { useGetSeverity } from './use_get_severity'; -import { extractActionVariable } from '../extract_action_variable'; -import { AlertProvidedActionVariables } from '../../../lib/action_variables'; import { useKibana } from '../../../../common/lib/kibana'; const ResilientParamsFields: React.FunctionComponent> = ({ + actionConnector, actionParams, editAction, - index, errors, + index, messageVariables, - actionConnector, }) => { const { http, notifications: { toasts }, } = useKibana().services; - const [firstLoad, setFirstLoad] = useState(false); - const { title, description, comments, incidentTypes, severityCode, savedObjectId } = - actionParams.subActionParams || {}; - - const isActionBeingConfiguredByAnAlert = messageVariables - ? isSome(extractActionVariable(messageVariables, AlertProvidedActionVariables.alertId)) - : false; - - const [incidentTypesComboBoxOptions, setIncidentTypesComboBoxOptions] = useState< - Array> - >([]); - - const [selectedIncidentTypesComboBoxOptions, setSelectedIncidentTypesComboBoxOptions] = useState< - Array> - >([]); - - const [severitySelectOptions, setSeveritySelectOptions] = useState([]); - - useEffect(() => { - setFirstLoad(true); - }, []); - + const actionConnectorRef = useRef(actionConnector?.id ?? ''); + const { incident, comments } = useMemo( + () => + actionParams.subActionParams ?? + (({ + incident: {}, + comments: [], + } as unknown) as ResilientActionParams['subActionParams']), + [actionParams.subActionParams] + ); const { isLoading: isLoadingIncidentTypes, incidentTypes: allIncidentTypes, @@ -78,71 +61,107 @@ const ResilientParamsFields: React.FunctionComponent { - const newProps = { ...actionParams.subActionParams, [key]: value }; - editAction('subActionParams', newProps, index); - }; - - useEffect(() => { - const options = severity.map((s) => ({ + const severitySelectOptions: EuiSelectOption[] = useMemo(() => { + return severity.map((s) => ({ value: s.id.toString(), text: s.name, })); + }, [severity]); - setSeveritySelectOptions(options); - }, [actionConnector, severity]); - - // Reset parameters when changing connector - useEffect(() => { - if (!firstLoad) { - return; - } - - setIncidentTypesComboBoxOptions([]); - setSelectedIncidentTypesComboBoxOptions([]); - setSeveritySelectOptions([]); - editAction('subActionParams', { title, comments, description: '', savedObjectId }, index); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [actionConnector]); - - useEffect(() => { - if (!actionParams.subAction) { - editAction('subAction', 'pushToService', index); - } - if (!savedObjectId && isActionBeingConfiguredByAnAlert) { - editSubActionProperty('savedObjectId', `${AlertProvidedActionVariables.alertId}`); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [actionConnector, savedObjectId]); - - useEffect(() => { - setIncidentTypesComboBoxOptions( + const incidentTypesComboBoxOptions: Array> = useMemo( + () => allIncidentTypes ? allIncidentTypes.map((type: { id: number; name: string }) => ({ label: type.name, value: type.id.toString(), })) - : [] - ); - + : [], + [allIncidentTypes] + ); + const selectedIncidentTypesComboBoxOptions: Array< + EuiComboBoxOptionOption + > = useMemo(() => { const allIncidentTypesAsObject = allIncidentTypes.reduce( (acc, type) => ({ ...acc, [type.id.toString()]: type.name }), {} as Record ); + return incident.incidentTypes + ? incident.incidentTypes + .map((type) => ({ + label: allIncidentTypesAsObject[type.toString()], + value: type.toString(), + })) + .filter((type) => type.label != null) + : []; + }, [allIncidentTypes, incident.incidentTypes]); - setSelectedIncidentTypesComboBoxOptions( - incidentTypes - ? incidentTypes - .map((type) => ({ - label: allIncidentTypesAsObject[type.toString()], - value: type.toString(), - })) - .filter((type) => type.label != null) - : [] - ); + const editSubActionProperty = useCallback( + (key: string, value: any) => { + const newProps = + key !== 'comments' + ? { + incident: { ...incident, [key]: value }, + comments, + } + : { incident, [key]: value }; + editAction('subActionParams', newProps, index); + }, + [comments, editAction, incident, index] + ); + const editComment = useCallback( + (key, value) => { + if (value.length > 0) { + editSubActionProperty(key, [{ commentId: '1', comment: value }]); + } + }, + [editSubActionProperty] + ); + + const incidentTypesOnChange = useCallback( + (selectedOptions: Array<{ label: string; value?: string }>) => { + editSubActionProperty( + 'incidentTypes', + selectedOptions.map((selectedOption) => selectedOption.value ?? selectedOption.label) + ); + }, + [editSubActionProperty] + ); + const incidentTypesOnBlur = useCallback(() => { + if (!incident.incidentTypes) { + editSubActionProperty('incidentTypes', []); + } + }, [editSubActionProperty, incident.incidentTypes]); + + useEffect(() => { + if (actionConnector != null && actionConnectorRef.current !== actionConnector.id) { + actionConnectorRef.current = actionConnector.id; + editAction( + 'subActionParams', + { + incident: {}, + comments: [], + }, + index + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actionConnector]); + useEffect(() => { + if (!actionParams.subAction) { + editAction('subAction', 'pushToService', index); + } + if (!actionParams.subActionParams) { + editAction( + 'subActionParams', + { + incident: {}, + comments: [], + }, + index + ); + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [actionConnector, allIncidentTypes]); + }, [actionParams]); return ( @@ -154,9 +173,7 @@ const ResilientParamsFields: React.FunctionComponent ) => { - setSelectedIncidentTypesComboBoxOptions( - selectedOptions.map((selectedOption) => ({ - label: selectedOption.label, - value: selectedOption.value, - })) - ); - - editSubActionProperty( - 'incidentTypes', - selectedOptions.map((selectedOption) => selectedOption.value ?? selectedOption.label) - ); - }} - onBlur={() => { - if (!incidentTypes) { - editSubActionProperty('incidentTypes', []); - } - }} + onChange={incidentTypesOnChange} + onBlur={incidentTypesOnBlur} isClearable={true} /> @@ -192,108 +193,60 @@ const ResilientParamsFields: React.FunctionComponent editSubActionProperty('severityCode', e.target.value)} options={severitySelectOptions} - value={severityCode} - onChange={(e) => { - editSubActionProperty('severityCode', e.target.value); - }} + value={incident.severityCode ?? undefined} /> 0 && title !== undefined} + error={errors.name} + isInvalid={errors.name.length > 0 && incident.name !== undefined} label={i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.titleFieldLabel', - { - defaultMessage: 'Name', - } + 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.nameFieldLabel', + { defaultMessage: 'Name (required)' } )} > - {!isActionBeingConfiguredByAnAlert && ( - - - - } - > - - - - - - )} { - editSubActionProperty(key, [{ commentId: 'alert-comment', comment: value }]); - }} + editAction={editComment} messageVariables={messageVariables} paramsProperty={'comments'} - inputTargetValue={comments && comments.length > 0 ? comments[0].comment : ''} + inputTargetValue={comments && comments.length > 0 ? comments[0].comment : undefined} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.commentsTextAreaFieldLabel', - { - defaultMessage: 'Additional comments (optional)', - } + { defaultMessage: 'Additional comments' } )} errors={errors.comments as string[]} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/types.ts index 38019205fbfc9..d2ff5cd921652 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/types.ts @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CasesConfigurationMapping } from '../case_mappings'; import { UserConfiguredActionConnector } from '../../../../types'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ExecutorSubActionPushParams } from '../../../../../../actions/server/builtin_action_types/resilient/types'; export type ResilientActionConnector = UserConfiguredActionConnector< ResilientConfig, @@ -14,26 +15,12 @@ export type ResilientActionConnector = UserConfiguredActionConnector< export interface ResilientActionParams { subAction: string; - subActionParams: { - savedObjectId: string; - title: string; - description: string; - externalId: string | null; - incidentTypes: number[]; - severityCode: number; - comments: Array<{ commentId: string; comment: string }>; - }; -} - -interface IncidentConfiguration { - mapping: CasesConfigurationMapping[]; + subActionParams: ExecutorSubActionPushParams; } export interface ResilientConfig { apiUrl: string; orgId: string; - incidentConfiguration?: IncidentConfiguration; - isCaseOwned?: boolean; } export interface ResilientSecrets { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx index c29ddbf385de6..4f0c5e06e1428 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx @@ -75,22 +75,22 @@ describe('servicenow connector validation', () => { describe('servicenow action params validation', () => { test('action params validation succeeds when action params is valid', () => { const actionParams = { - subActionParams: { title: 'some title {{test}}' }, + subActionParams: { incident: { short_description: 'some title {{test}}' }, comments: [] }, }; expect(actionTypeModel.validateParams(actionParams)).toEqual({ - errors: { title: [] }, + errors: { short_description: [] }, }); }); test('params validation fails when body is not valid', () => { const actionParams = { - subActionParams: { title: '' }, + subActionParams: { incident: { short_description: '' }, comments: [] }, }; expect(actionTypeModel.validateParams(actionParams)).toEqual({ errors: { - title: ['Short description is required.'], + short_description: ['Short description is required.'], }, }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx index 8eca7f3ef3120..9e84034669483 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx @@ -64,11 +64,15 @@ export function getActionType(): ActionTypeModel< validateParams: (actionParams: ServiceNowActionParams): ValidationResult => { const validationResult = { errors: {} }; const errors = { - title: new Array(), + short_description: new Array(), }; validationResult.errors = errors; - if (!actionParams.subActionParams?.title?.length) { - errors.title.push(i18n.TITLE_REQUIRED); + if ( + actionParams.subActionParams && + actionParams.subActionParams.incident && + !actionParams.subActionParams.incident.short_description?.length + ) { + errors.short_description.push(i18n.TITLE_REQUIRED); } return validationResult; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx index de48e62d88aa1..61cfdd7bc8ee0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx @@ -69,7 +69,6 @@ describe('ServiceNowActionConnectorFields renders', () => { consumer={'case'} /> ); - expect(wrapper.find('[data-test-subj="case-servicenow-mappings"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="apiUrlFromInput"]').length > 0).toBeTruthy(); expect( wrapper.find('[data-test-subj="connector-servicenow-password-form-input"]').length > 0 diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index 328667ae49c69..4d37a9438fa16 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -18,24 +18,18 @@ import { EuiTitle, } from '@elastic/eui'; -import { isEmpty } from 'lodash'; import { FormattedMessage } from '@kbn/i18n/react'; import { ActionConnectorFieldsProps } from '../../../../types'; -import { CasesConfigurationMapping, FieldMapping, createDefaultMapping } from '../case_mappings'; import * as i18n from './translations'; import { ServiceNowActionConnector } from './types'; -import { connectorConfiguration } from './config'; import { useKibana } from '../../../../common/lib/kibana'; const ServiceNowConnectorFields: React.FC< ActionConnectorFieldsProps > = ({ action, editActionSecrets, editActionConfig, errors, consumer, readOnly }) => { const { docLinks } = useKibana().services; - - // TODO: remove incidentConfiguration later, when Case ServiceNow will move their fields to the level of action execution - const { apiUrl, incidentConfiguration, isCaseOwned } = action.config; - const mapping = incidentConfiguration ? incidentConfiguration.mapping : []; + const { apiUrl } = action.config; const isApiUrlInvalid: boolean = errors.apiUrl.length > 0 && apiUrl != null; @@ -44,40 +38,15 @@ const ServiceNowConnectorFields: React.FC< const isUsernameInvalid: boolean = errors.username.length > 0 && username != null; const isPasswordInvalid: boolean = errors.password.length > 0 && password != null; - // TODO: remove this block later, when Case ServiceNow will move their fields to the level of action execution - if (consumer === 'case') { - if (isEmpty(mapping)) { - editActionConfig('incidentConfiguration', { - mapping: createDefaultMapping(connectorConfiguration.fields as any), - }); - } - if (!isCaseOwned) { - editActionConfig('isCaseOwned', true); - } - } - const handleOnChangeActionConfig = useCallback( (key: string, value: string) => editActionConfig(key, value), - // eslint-disable-next-line react-hooks/exhaustive-deps - [] + [editActionConfig] ); const handleOnChangeSecretConfig = useCallback( (key: string, value: string) => editActionSecrets(key, value), - // eslint-disable-next-line react-hooks/exhaustive-deps - [] + [editActionSecrets] ); - - const handleOnChangeMappingConfig = useCallback( - (newMapping: CasesConfigurationMapping[]) => - editActionConfig('incidentConfiguration', { - ...action.config.incidentConfiguration, - mapping: newMapping, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [action.config] - ); - return ( <> @@ -185,21 +154,6 @@ const ServiceNowConnectorFields: React.FC< - {consumer === 'case' && ( // TODO: remove this block later, when Case ServiceNow will move their fields to the level of action execution - <> - - - - - - - - )} ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.test.tsx index b3521b82abb38..eff754fd99bab 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.test.tsx @@ -13,21 +13,23 @@ describe('ServiceNowParamsFields renders', () => { const actionParams = { subAction: 'pushToService', subActionParams: { - title: 'sn title', - description: 'some description', - comment: 'comment for sn', - severity: '1', - urgency: '2', - impact: '3', - savedObjectId: '123', - externalId: null, + incident: { + short_description: 'sn title', + description: 'some description', + severity: '1', + urgency: '2', + impact: '3', + savedObjectId: '123', + externalId: null, + }, + comments: [{ commentId: '1', comment: 'comment for sn' }], }, }; const wrapper = mountWithIntl( {}} index={0} messageVariables={[{ name: AlertProvidedActionVariables.alertId, description: '' }]} @@ -38,40 +40,8 @@ describe('ServiceNowParamsFields renders', () => { '1' ); expect(wrapper.find('[data-test-subj="impactSelect"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="titleInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="short_descriptionInput"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="descriptionTextArea"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="commentTextArea"]').length > 0).toBeTruthy(); - - // ensure savedObjectIdInput isnt rendered - expect(wrapper.find('[data-test-subj="savedObjectIdInput"]').length === 0).toBeTruthy(); - }); - - test('the savedObjectId fields is rendered if we cant find an alertId in the messageVariables', () => { - const actionParams = { - subAction: 'pushToService', - subActionParams: { - title: 'sn title', - description: 'some description', - comment: 'comment for sn', - severity: '1', - urgency: '2', - impact: '3', - savedObjectId: '123', - externalId: null, - }, - }; - - const wrapper = mountWithIntl( - {}} - index={0} - messageVariables={[]} - /> - ); - - // ensure savedObjectIdInput isnt rendered - expect(wrapper.find('[data-test-subj="savedObjectIdInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="commentsTextArea"]').length > 0).toBeTruthy(); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.tsx index 240df24735414..f6e41ab8f35b1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, useEffect } from 'react'; +import React, { Fragment, useCallback, useEffect, useMemo, useRef } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, @@ -13,80 +13,103 @@ import { EuiFlexItem, EuiSpacer, EuiTitle, - EuiFormControlLayout, - EuiIconTip, } from '@elastic/eui'; -import { isSome } from 'fp-ts/lib/Option'; import { ActionParamsProps } from '../../../../types'; import { ServiceNowActionParams } from './types'; import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables'; import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables'; -import { extractActionVariable } from '../extract_action_variable'; -import { AlertProvidedActionVariables } from '../../../lib/action_variables'; + +const selectOptions = [ + { + value: '1', + text: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectHighOptionLabel', + { defaultMessage: 'High' } + ), + }, + { + value: '2', + text: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectMediumOptionLabel', + { defaultMessage: 'Medium' } + ), + }, + { + value: '3', + text: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectLawOptionLabel', + { defaultMessage: 'Low' } + ), + }, +]; const ServiceNowParamsFields: React.FunctionComponent< ActionParamsProps -> = ({ actionParams, editAction, index, errors, messageVariables }) => { - const { title, description, comment, severity, urgency, impact, savedObjectId } = - actionParams.subActionParams || {}; - - const isActionBeingConfiguredByAnAlert = messageVariables - ? isSome(extractActionVariable(messageVariables, AlertProvidedActionVariables.alertId)) - : false; +> = ({ actionConnector, actionParams, editAction, index, errors, messageVariables }) => { + const actionConnectorRef = useRef(actionConnector?.id ?? ''); + const { incident, comments } = useMemo( + () => + actionParams.subActionParams ?? + (({ + incident: {}, + comments: [], + } as unknown) as ServiceNowActionParams['subActionParams']), + [actionParams.subActionParams] + ); - const selectOptions = [ - { - value: '1', - text: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectHighOptionLabel', - { - defaultMessage: 'High', - } - ), - }, - { - value: '2', - text: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectMediumOptionLabel', - { - defaultMessage: 'Medium', - } - ), - }, - { - value: '3', - text: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectLawOptionLabel', - { - defaultMessage: 'Low', - } - ), + const editSubActionProperty = useCallback( + (key: string, value: any) => { + const newProps = + key !== 'comments' + ? { + incident: { ...incident, [key]: value }, + comments, + } + : { incident, [key]: value }; + editAction('subActionParams', newProps, index); }, - ]; + [comments, editAction, incident, index] + ); - const editSubActionProperty = (key: string, value: {}) => { - const newProps = { ...actionParams.subActionParams, [key]: value }; - editAction('subActionParams', newProps, index); - }; + const editComment = useCallback( + (key, value) => { + if (value.length > 0) { + editSubActionProperty(key, [{ commentId: '1', comment: value }]); + } + }, + [editSubActionProperty] + ); + useEffect(() => { + if (actionConnector != null && actionConnectorRef.current !== actionConnector.id) { + actionConnectorRef.current = actionConnector.id; + editAction( + 'subActionParams', + { + incident: {}, + comments: [], + }, + index + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actionConnector]); useEffect(() => { if (!actionParams.subAction) { editAction('subAction', 'pushToService', index); } - if (!savedObjectId && isActionBeingConfiguredByAnAlert) { - editSubActionProperty('savedObjectId', `${AlertProvidedActionVariables.alertId}`); - } - if (!urgency) { - editSubActionProperty('urgency', '3'); - } - if (!impact) { - editSubActionProperty('impact', '3'); - } - if (!severity) { - editSubActionProperty('severity', '3'); + if (!actionParams.subActionParams) { + editAction( + 'subActionParams', + { + incident: {}, + comments: [], + }, + index + ); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [title, description, comment, severity, impact, urgency]); + }, [actionParams]); return ( @@ -94,9 +117,7 @@ const ServiceNowParamsFields: React.FunctionComponent<

{i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.title', - { - defaultMessage: 'Incident', - } + { defaultMessage: 'Incident' } )}

@@ -105,19 +126,16 @@ const ServiceNowParamsFields: React.FunctionComponent< fullWidth label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.urgencySelectFieldLabel', - { - defaultMessage: 'Urgency', - } + { defaultMessage: 'Urgency' } )} > { - editSubActionProperty('urgency', e.target.value); - }} + value={incident.urgency ?? undefined} + onChange={(e) => editSubActionProperty('urgency', e.target.value)} /> @@ -127,19 +145,16 @@ const ServiceNowParamsFields: React.FunctionComponent< fullWidth label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.severitySelectFieldLabel', - { - defaultMessage: 'Severity', - } + { defaultMessage: 'Severity' } )} > { - editSubActionProperty('severity', e.target.value); - }} + value={incident.severity ?? undefined} + onChange={(e) => editSubActionProperty('severity', e.target.value)} /> @@ -148,19 +163,16 @@ const ServiceNowParamsFields: React.FunctionComponent< fullWidth label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.impactSelectFieldLabel', - { - defaultMessage: 'Impact', - } + { defaultMessage: 'Impact' } )} > { - editSubActionProperty('impact', e.target.value); - }} + value={incident.impact ?? undefined} + onChange={(e) => editSubActionProperty('impact', e.target.value)} /> @@ -168,88 +180,45 @@ const ServiceNowParamsFields: React.FunctionComponent< 0 && title !== undefined} + error={errors.short_description} + isInvalid={errors.short_description.length > 0 && incident.short_description !== undefined} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.titleFieldLabel', - { - defaultMessage: 'Short description', - } + { defaultMessage: 'Short description (required)' } )} > - {!isActionBeingConfiguredByAnAlert && ( - - - - } - > - - - - - - )} 0 ? comments[0].comment : undefined} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.commentsTextAreaFieldLabel', - { - defaultMessage: 'Additional comments (optional)', - } + { defaultMessage: 'Additional comments' } )} - errors={errors.comment as string[]} + errors={errors.comments as string[]} />
); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts index 91a5c0a54397b..c84a916c0fef4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts @@ -155,7 +155,7 @@ export const DESCRIPTION_REQUIRED = i18n.translate( ); export const TITLE_REQUIRED = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.common.requiredTitleTextField', + 'xpack.triggersActionsUI.components.builtinActionTypes.common.requiredShortDescTextField', { defaultMessage: 'Short description is required.', } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts index 92753dfcba76c..ae03680a80534 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CasesConfigurationMapping } from '../case_mappings'; import { UserConfiguredActionConnector } from '../../../../types'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ExecutorSubActionPushParams } from '../../../../../../actions/server/builtin_action_types/servicenow/types'; export type ServiceNowActionConnector = UserConfiguredActionConnector< ServiceNowConfig, @@ -14,26 +15,11 @@ export type ServiceNowActionConnector = UserConfiguredActionConnector< export interface ServiceNowActionParams { subAction: string; - subActionParams: { - savedObjectId: string; - title: string; - description: string; - comment: string; - externalId: string | null; - severity: string; - urgency: string; - impact: string; - }; -} - -interface IncidentConfiguration { - mapping: CasesConfigurationMapping[]; + subActionParams: ExecutorSubActionPushParams; } export interface ServiceNowConfig { apiUrl: string; - incidentConfiguration?: IncidentConfiguration; - isCaseOwned?: boolean; } export interface ServiceNowSecrets { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.test.ts index daf51dcd43812..ef4abcc758e44 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.test.ts @@ -321,5 +321,7 @@ function getAlertType(actionVariables: ActionVariables): AlertType { recoveryActionGroup: { id: 'recovered', name: 'Recovered' }, authorizedConsumers: {}, producer: ALERTS_FEATURE_ID, + minimumLicenseRequired: 'basic', + enabledInLicense: true, }; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts index 32b663c5693fc..538c6be89ab4b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts @@ -51,6 +51,8 @@ describe('loadAlertTypes', () => { recoveryActionGroup: { id: 'recovered', name: 'Recovered' }, defaultActionGroupId: 'default', authorizedConsumers: {}, + minimumLicenseRequired: 'basic', + enabledInLicense: true, }, ]; http.get.mockResolvedValueOnce(resolvedValue); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_type_compare.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_type_compare.test.ts new file mode 100644 index 0000000000000..e364661361814 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_type_compare.test.ts @@ -0,0 +1,182 @@ +/* + * 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 { AlertTypeModel } from '../../types'; +import { alertTypeGroupCompare, alertTypeCompare } from './alert_type_compare'; +import { IsEnabledResult, IsDisabledResult } from './check_alert_type_enabled'; + +test('should sort groups by containing enabled alert types first and then by name', async () => { + const alertTypes: Array< + [ + string, + Array<{ + id: string; + name: string; + checkEnabledResult: IsEnabledResult | IsDisabledResult; + alertTypeItem: AlertTypeModel; + }> + ] + > = [ + [ + 'abc', + [ + { + id: '1', + name: 'test2', + checkEnabledResult: { isEnabled: false, message: 'gold license' }, + alertTypeItem: { + id: 'my-alert-type', + iconClass: 'test', + name: 'test-alert', + description: 'Alert when testing', + documentationUrl: 'https://localhost.local/docs', + validate: () => { + return { errors: {} }; + }, + alertParamsExpression: () => null, + requiresAppContext: false, + }, + }, + ], + ], + [ + 'bcd', + [ + { + id: '2', + name: 'abc', + checkEnabledResult: { isEnabled: false, message: 'platinum license' }, + alertTypeItem: { + id: 'my-alert-type', + iconClass: 'test', + name: 'test-alert', + description: 'Alert when testing', + documentationUrl: 'https://localhost.local/docs', + validate: () => { + return { errors: {} }; + }, + alertParamsExpression: () => null, + requiresAppContext: false, + }, + }, + { + id: '3', + name: 'cdf', + checkEnabledResult: { isEnabled: true }, + alertTypeItem: { + id: 'disabled-alert-type', + iconClass: 'test', + name: 'test-alert', + description: 'Alert when testing', + documentationUrl: 'https://localhost.local/docs', + validate: () => { + return { errors: {} }; + }, + alertParamsExpression: () => null, + requiresAppContext: false, + }, + }, + ], + ], + [ + 'cde', + [ + { + id: '4', + name: 'cde', + checkEnabledResult: { isEnabled: true }, + alertTypeItem: { + id: 'my-alert-type', + iconClass: 'test', + name: 'test-alert', + description: 'Alert when testing', + documentationUrl: 'https://localhost.local/docs', + validate: () => { + return { errors: {} }; + }, + alertParamsExpression: () => null, + requiresAppContext: false, + }, + }, + ], + ], + ]; + + const groups = new Map(); + groups.set('abc', 'ABC'); + groups.set('bcd', 'BCD'); + groups.set('cde', 'CDE'); + + const result = [...alertTypes].sort((right, left) => alertTypeGroupCompare(right, left, groups)); + expect(result[0]).toEqual(alertTypes[1]); + expect(result[1]).toEqual(alertTypes[2]); + expect(result[2]).toEqual(alertTypes[0]); +}); + +test('should sort alert types by enabled first and then by name', async () => { + const alertTypes: Array<{ + id: string; + name: string; + checkEnabledResult: IsEnabledResult | IsDisabledResult; + alertTypeItem: AlertTypeModel; + }> = [ + { + id: '1', + name: 'bcd', + checkEnabledResult: { isEnabled: false, message: 'gold license' }, + alertTypeItem: { + id: 'my-alert-type', + iconClass: 'test', + name: 'test-alert', + description: 'Alert when testing', + documentationUrl: 'https://localhost.local/docs', + validate: () => { + return { errors: {} }; + }, + alertParamsExpression: () => null, + requiresAppContext: false, + }, + }, + { + id: '2', + name: 'abc', + checkEnabledResult: { isEnabled: false, message: 'platinum license' }, + alertTypeItem: { + id: 'my-alert-type', + iconClass: 'test', + name: 'test-alert', + description: 'Alert when testing', + documentationUrl: 'https://localhost.local/docs', + validate: () => { + return { errors: {} }; + }, + alertParamsExpression: () => null, + requiresAppContext: false, + }, + }, + { + id: '3', + name: 'cdf', + checkEnabledResult: { isEnabled: true }, + alertTypeItem: { + id: 'disabled-alert-type', + iconClass: 'test', + name: 'test-alert', + description: 'Alert when testing', + documentationUrl: 'https://localhost.local/docs', + validate: () => { + return { errors: {} }; + }, + alertParamsExpression: () => null, + requiresAppContext: false, + }, + }, + ]; + const result = [...alertTypes].sort(alertTypeCompare); + expect(result[0]).toEqual(alertTypes[2]); + expect(result[1]).toEqual(alertTypes[1]); + expect(result[2]).toEqual(alertTypes[0]); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_type_compare.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_type_compare.ts new file mode 100644 index 0000000000000..68df0220a4bec --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_type_compare.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AlertTypeModel } from '../../types'; +import { IsEnabledResult, IsDisabledResult } from './check_alert_type_enabled'; + +export function alertTypeGroupCompare( + left: [ + string, + Array<{ + id: string; + name: string; + checkEnabledResult: IsEnabledResult | IsDisabledResult; + alertTypeItem: AlertTypeModel; + }> + ], + right: [ + string, + Array<{ + id: string; + name: string; + checkEnabledResult: IsEnabledResult | IsDisabledResult; + alertTypeItem: AlertTypeModel; + }> + ], + groupNames: Map | undefined +) { + const groupNameA = left[0]; + const groupNameB = right[0]; + const leftAlertTypesList = left[1]; + const rightAlertTypesList = right[1]; + + const hasEnabledAlertTypeInListLeft = + leftAlertTypesList.find((alertTypeItem) => alertTypeItem.checkEnabledResult.isEnabled) !== + undefined; + + const hasEnabledAlertTypeInListRight = + rightAlertTypesList.find((alertTypeItem) => alertTypeItem.checkEnabledResult.isEnabled) !== + undefined; + + if (hasEnabledAlertTypeInListLeft && !hasEnabledAlertTypeInListRight) { + return -1; + } + if (!hasEnabledAlertTypeInListLeft && hasEnabledAlertTypeInListRight) { + return 1; + } + + return groupNames + ? groupNames.get(groupNameA)!.localeCompare(groupNames.get(groupNameB)!) + : groupNameA.localeCompare(groupNameB); +} + +export function alertTypeCompare( + a: { + id: string; + name: string; + checkEnabledResult: IsEnabledResult | IsDisabledResult; + alertTypeItem: AlertTypeModel; + }, + b: { + id: string; + name: string; + checkEnabledResult: IsEnabledResult | IsDisabledResult; + alertTypeItem: AlertTypeModel; + } +) { + if (a.checkEnabledResult.isEnabled === true && b.checkEnabledResult.isEnabled === false) { + return -1; + } + if (a.checkEnabledResult.isEnabled === false && b.checkEnabledResult.isEnabled === true) { + return 1; + } + return a.name.localeCompare(b.name); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/check_alert_type_enabled.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/lib/check_alert_type_enabled.test.tsx new file mode 100644 index 0000000000000..fa70e2fae1384 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/check_alert_type_enabled.test.tsx @@ -0,0 +1,67 @@ +/* + * 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 { AlertType } from '../../types'; +import { checkAlertTypeEnabled } from './check_alert_type_enabled'; + +describe('checkAlertTypeEnabled', () => { + test(`returns isEnabled:true when alert type isn't provided`, async () => { + expect(checkAlertTypeEnabled()).toMatchInlineSnapshot(` + Object { + "isEnabled": true, + } + `); + }); + + test('returns isEnabled:true when alert type is enabled', async () => { + const alertType: AlertType = { + id: 'test', + name: 'Test', + actionVariables: { + context: [{ name: 'var1', description: 'val1' }], + state: [{ name: 'var2', description: 'val2' }], + params: [{ name: 'var3', description: 'val3' }], + }, + producer: 'test', + actionGroups: [{ id: 'default', name: 'Default' }], + recoveryActionGroup: { id: 'recovered', name: 'Recovered' }, + defaultActionGroupId: 'default', + authorizedConsumers: {}, + minimumLicenseRequired: 'basic', + enabledInLicense: true, + }; + expect(checkAlertTypeEnabled(alertType)).toMatchInlineSnapshot(` + Object { + "isEnabled": true, + } + `); + }); + + test('returns isEnabled:false when alert type is disabled by license', async () => { + const alertType: AlertType = { + id: 'test', + name: 'Test', + actionVariables: { + context: [{ name: 'var1', description: 'val1' }], + state: [{ name: 'var2', description: 'val2' }], + params: [{ name: 'var3', description: 'val3' }], + }, + producer: 'test', + actionGroups: [{ id: 'default', name: 'Default' }], + recoveryActionGroup: { id: 'recovered', name: 'Recovered' }, + defaultActionGroupId: 'default', + authorizedConsumers: {}, + minimumLicenseRequired: 'gold', + enabledInLicense: false, + }; + expect(checkAlertTypeEnabled(alertType)).toMatchInlineSnapshot(` + Object { + "isEnabled": false, + "message": "This alert type requires a Gold license.", + } + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/check_alert_type_enabled.tsx b/x-pack/plugins/triggers_actions_ui/public/application/lib/check_alert_type_enabled.tsx new file mode 100644 index 0000000000000..a4d5c1e01da41 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/check_alert_type_enabled.tsx @@ -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 { upperFirst } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { AlertType } from '../../types'; + +export interface IsEnabledResult { + isEnabled: true; +} +export interface IsDisabledResult { + isEnabled: false; + message: string; +} + +const getLicenseCheckResult = (alertType: AlertType) => { + return { + isEnabled: false, + message: i18n.translate( + 'xpack.triggersActionsUI.checkAlertTypeEnabled.alertTypeDisabledByLicenseMessage', + { + defaultMessage: 'This alert type requires a {minimumLicenseRequired} license.', + values: { + minimumLicenseRequired: upperFirst(alertType.minimumLicenseRequired), + }, + } + ), + }; +}; + +export function checkAlertTypeEnabled(alertType?: AlertType): IsEnabledResult | IsDisabledResult { + if (alertType?.enabledInLicense === false) { + return getLicenseCheckResult(alertType); + } + + return { isEnabled: true }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx index 9de3ae21a8ef7..d7d508cbcf121 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx @@ -174,9 +174,7 @@ describe('action_form', () => { id: '.servicenow', actionTypeId: '.servicenow', name: 'Non consumer connector', - config: { - isCaseOwned: true, - }, + config: {}, isPreconfigured: false, }, { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx index 3cf6e18e89621..116944ce0ca2b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx @@ -85,6 +85,19 @@ export const ConnectorEditFlyout = ({ Option> >(none); const [isExecutingAction, setIsExecutinAction] = useState(false); + const handleSetTab = useCallback( + () => + setTab((prevTab) => { + if (prevTab === EditConectorTabs.Configuration) { + return EditConectorTabs.Test; + } + if (testExecutionResult !== none) { + setTestExecutionResult(none); + } + return EditConectorTabs.Configuration; + }), + [testExecutionResult] + ); const closeFlyout = useCallback(() => { setConnector('connector', { ...initialConnector, secrets: {} }); @@ -223,7 +236,7 @@ export const ConnectorEditFlyout = ({ setTab(EditConectorTabs.Configuration)} + onClick={handleSetTab} data-test-subj="configureConnectorTab" isSelected={EditConectorTabs.Configuration === selectedTab} > @@ -232,7 +245,7 @@ export const ConnectorEditFlyout = ({ })} setTab(EditConectorTabs.Test)} + onClick={handleSetTab} data-test-subj="testConnectorTab" isSelected={EditConectorTabs.Test === selectedTab} > diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx index c10653d14d409..e25e703de5f7e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx @@ -7,7 +7,7 @@ import * as React from 'react'; import uuid from 'uuid'; import { shallow } from 'enzyme'; import { AlertDetails } from './alert_details'; -import { Alert, ActionType, AlertTypeModel } from '../../../../types'; +import { Alert, ActionType, AlertTypeModel, AlertType } from '../../../../types'; import { EuiTitle, EuiBadge, EuiFlexItem, EuiSwitch, EuiButtonEmpty, EuiText } from '@elastic/eui'; import { ViewInApp } from './view_in_app'; import { @@ -54,15 +54,17 @@ describe('alert_details', () => { it('renders the alert name as a title', () => { const alert = mockAlert(); - const alertType = { + const alertType: AlertType = { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], recoveryActionGroup, actionVariables: { context: [], state: [], params: [] }, defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', producer: ALERTS_FEATURE_ID, authorizedConsumers, + enabledInLicense: true, }; expect( @@ -80,15 +82,17 @@ describe('alert_details', () => { it('renders the alert type badge', () => { const alert = mockAlert(); - const alertType = { + const alertType: AlertType = { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], recoveryActionGroup, actionVariables: { context: [], state: [], params: [] }, defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', producer: ALERTS_FEATURE_ID, authorizedConsumers, + enabledInLicense: true, }; expect( @@ -109,15 +113,17 @@ describe('alert_details', () => { }, }, }); - const alertType = { + const alertType: AlertType = { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], recoveryActionGroup, actionVariables: { context: [], state: [], params: [] }, defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', producer: ALERTS_FEATURE_ID, authorizedConsumers, + enabledInLicense: true, }; expect( @@ -144,15 +150,17 @@ describe('alert_details', () => { ], }); - const alertType = { + const alertType: AlertType = { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], recoveryActionGroup, actionVariables: { context: [], state: [], params: [] }, defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', producer: ALERTS_FEATURE_ID, authorizedConsumers, + enabledInLicense: true, }; const actionTypes: ActionType[] = [ @@ -199,7 +207,7 @@ describe('alert_details', () => { }, ], }); - const alertType = { + const alertType: AlertType = { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], @@ -207,7 +215,9 @@ describe('alert_details', () => { actionVariables: { context: [], state: [], params: [] }, defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, + minimumLicenseRequired: 'basic', authorizedConsumers, + enabledInLicense: true, }; const actionTypes: ActionType[] = [ { @@ -259,7 +269,7 @@ describe('alert_details', () => { it('links to the app that created the alert', () => { const alert = mockAlert(); - const alertType = { + const alertType: AlertType = { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], @@ -268,6 +278,8 @@ describe('alert_details', () => { defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, authorizedConsumers, + minimumLicenseRequired: 'basic', + enabledInLicense: true, }; expect( @@ -280,7 +292,7 @@ describe('alert_details', () => { it('links to the Edit flyout', () => { const alert = mockAlert(); - const alertType = { + const alertType: AlertType = { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], @@ -289,6 +301,8 @@ describe('alert_details', () => { defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, authorizedConsumers, + minimumLicenseRequired: 'basic', + enabledInLicense: true, }; expect( @@ -310,7 +324,7 @@ describe('disable button', () => { enabled: true, }); - const alertType = { + const alertType: AlertType = { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], @@ -319,6 +333,8 @@ describe('disable button', () => { defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, authorizedConsumers, + minimumLicenseRequired: 'basic', + enabledInLicense: true, }; const enableButton = shallow( @@ -339,7 +355,7 @@ describe('disable button', () => { enabled: false, }); - const alertType = { + const alertType: AlertType = { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], @@ -348,6 +364,8 @@ describe('disable button', () => { defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, authorizedConsumers, + minimumLicenseRequired: 'basic', + enabledInLicense: true, }; const enableButton = shallow( @@ -368,7 +386,7 @@ describe('disable button', () => { enabled: true, }); - const alertType = { + const alertType: AlertType = { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], @@ -377,6 +395,8 @@ describe('disable button', () => { defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, authorizedConsumers, + minimumLicenseRequired: 'basic', + enabledInLicense: true, }; const disableAlert = jest.fn(); @@ -406,7 +426,7 @@ describe('disable button', () => { enabled: false, }); - const alertType = { + const alertType: AlertType = { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], @@ -415,6 +435,8 @@ describe('disable button', () => { defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, authorizedConsumers, + minimumLicenseRequired: 'basic', + enabledInLicense: true, }; const enableAlert = jest.fn(); @@ -447,7 +469,7 @@ describe('mute button', () => { muteAll: false, }); - const alertType = { + const alertType: AlertType = { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], @@ -456,6 +478,8 @@ describe('mute button', () => { defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, authorizedConsumers, + minimumLicenseRequired: 'basic', + enabledInLicense: true, }; const enableButton = shallow( @@ -477,7 +501,7 @@ describe('mute button', () => { muteAll: true, }); - const alertType = { + const alertType: AlertType = { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], @@ -486,6 +510,8 @@ describe('mute button', () => { defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, authorizedConsumers, + minimumLicenseRequired: 'basic', + enabledInLicense: true, }; const enableButton = shallow( @@ -507,7 +533,7 @@ describe('mute button', () => { muteAll: false, }); - const alertType = { + const alertType: AlertType = { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], @@ -516,6 +542,8 @@ describe('mute button', () => { defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, authorizedConsumers, + minimumLicenseRequired: 'basic', + enabledInLicense: true, }; const muteAlert = jest.fn(); @@ -546,7 +574,7 @@ describe('mute button', () => { muteAll: true, }); - const alertType = { + const alertType: AlertType = { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], @@ -555,6 +583,8 @@ describe('mute button', () => { defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, authorizedConsumers, + minimumLicenseRequired: 'basic', + enabledInLicense: true, }; const unmuteAlert = jest.fn(); @@ -585,7 +615,7 @@ describe('mute button', () => { muteAll: false, }); - const alertType = { + const alertType: AlertType = { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], @@ -594,6 +624,8 @@ describe('mute button', () => { defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, authorizedConsumers, + minimumLicenseRequired: 'basic', + enabledInLicense: true, }; const enableButton = shallow( @@ -622,7 +654,7 @@ describe('edit button', () => { }, ]; alertTypeRegistry.has.mockReturnValue(true); - const alertTypeR = ({ + const alertTypeR: AlertTypeModel = { id: 'my-alert-type', iconClass: 'test', name: 'test-alert', @@ -631,9 +663,9 @@ describe('edit button', () => { validate: () => { return { errors: {} }; }, - alertParamsExpression: () => {}, + alertParamsExpression: jest.fn(), requiresAppContext: false, - } as unknown) as AlertTypeModel; + }; alertTypeRegistry.get.mockReturnValue(alertTypeR); useKibanaMock().services.alertTypeRegistry = alertTypeRegistry; @@ -651,7 +683,7 @@ describe('edit button', () => { ], }); - const alertType = { + const alertType: AlertType = { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], @@ -660,6 +692,8 @@ describe('edit button', () => { defaultActionGroupId: 'default', producer: 'alerting', authorizedConsumers, + minimumLicenseRequired: 'basic', + enabledInLicense: true, }; expect( @@ -694,7 +728,7 @@ describe('edit button', () => { ], }); - const alertType = { + const alertType: AlertType = { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], @@ -703,6 +737,8 @@ describe('edit button', () => { defaultActionGroupId: 'default', producer: 'alerting', authorizedConsumers, + minimumLicenseRequired: 'basic', + enabledInLicense: true, }; expect( @@ -730,7 +766,7 @@ describe('edit button', () => { actions: [], }); - const alertType = { + const alertType: AlertType = { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], @@ -739,6 +775,8 @@ describe('edit button', () => { defaultActionGroupId: 'default', producer: 'alerting', authorizedConsumers, + minimumLicenseRequired: 'basic', + enabledInLicense: true, }; expect( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx index 0c1b00d78d198..5bb8d47988eed 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx @@ -26,6 +26,7 @@ import { EuiButton, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { AlertExecutionStatusErrorReasons } from '../../../../../../alerts/common'; import { hasAllPrivilege, hasExecuteActionsCapability } from '../../../lib/capabilities'; import { getAlertingSectionBreadcrumb, getAlertDetailsBreadcrumb } from '../../../lib/breadcrumb'; import { getCurrentDocTitle } from '../../../lib/doc_title'; @@ -66,6 +67,7 @@ export const AlertDetails: React.FunctionComponent = ({ actionTypeRegistry, setBreadcrumbs, chrome, + http, } = useKibana().services; const [{}, dispatch] = useReducer(alertReducer, { alert }); const setInitialAlert = (value: Alert) => { @@ -139,6 +141,7 @@ export const AlertDetails: React.FunctionComponent = ({ iconType="pencil" onClick={() => setEditFlyoutVisibility(true)} name="edit" + disabled={!alertType.enabledInLicense} > = ({ { @@ -235,7 +238,7 @@ export const AlertDetails: React.FunctionComponent = ({ { if (isMuted) { @@ -272,12 +275,31 @@ export const AlertDetails: React.FunctionComponent = ({ {alert.executionStatus.error?.message} - setDissmissAlertErrors(true)}> - - + + + setDissmissAlertErrors(true)}> + + + + {alert.executionStatus.error?.reason === + AlertExecutionStatusErrorReasons.License && ( + + + + + + )} + diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.test.tsx index be68036c0f743..d65f1d3af1754 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.test.tsx @@ -311,6 +311,8 @@ function mockAlertType(overloads: Partial = {}): AlertType { recoveryActionGroup: { id: 'recovered', name: 'Recovered' }, authorizedConsumers: {}, producer: 'alerts', + minimumLicenseRequired: 'basic', + enabledInLicense: true, ...overloads, }; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx index b24d552bd5c48..e3fe9cd86356a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx @@ -151,6 +151,8 @@ function mockAlertType(overloads: Partial = {}): AlertType { recoveryActionGroup: { id: 'recovered', name: 'Recovered' }, authorizedConsumers: {}, producer: 'alerts', + minimumLicenseRequired: 'basic', + enabledInLicense: true, ...overloads, }; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx index 117abddb4d9d7..2790ea8aa6bfa 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx @@ -56,6 +56,7 @@ describe('alert_add', () => { }, ], defaultActionGroupId: 'testActionGroup', + minimumLicenseRequired: 'basic', recoveryActionGroup: { id: 'recovered', name: 'Recovered' }, producer: ALERTS_FEATURE_ID, authorizedConsumers: { @@ -128,8 +129,7 @@ describe('alert_add', () => { wrapper = mountWithIntl( {}} + onClose={() => {}} initialValues={initialValues} reloadAlerts={() => { return new Promise(() => {}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx index 5ab2c7f5a586c..c432f68e71ef4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx @@ -26,10 +26,9 @@ import { useKibana } from '../../../common/lib/kibana'; export interface AlertAddProps> { consumer: string; - addFlyoutVisible: boolean; alertTypeRegistry: AlertTypeRegistryContract; actionTypeRegistry: ActionTypeRegistryContract; - setAddFlyoutVisibility: React.Dispatch>; + onClose: () => void; alertTypeId?: string; canChangeTrigger?: boolean; initialValues?: Partial; @@ -39,10 +38,9 @@ export interface AlertAddProps> { const AlertAdd = ({ consumer, - addFlyoutVisible, alertTypeRegistry, actionTypeRegistry, - setAddFlyoutVisibility, + onClose, canChangeTrigger, alertTypeId, initialValues, @@ -92,9 +90,9 @@ const AlertAdd = ({ }, [alertTypeId]); const closeFlyout = useCallback(() => { - setAddFlyoutVisibility(false); setAlert(initialAlert); - }, [initialAlert, setAddFlyoutVisibility]); + onClose(); + }, [initialAlert, onClose]); const saveAlertAndCloseFlyout = async () => { const savedAlert = await onSaveAlert(); @@ -107,10 +105,6 @@ const AlertAdd = ({ } }; - if (!addFlyoutVisible) { - return null; - } - const alertType = alert.alertTypeId ? alertTypeRegistry.get(alert.alertTypeId) : null; const errors = { ...(alertType ? alertType.validate(alert.params).errors : []), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx index 26aca1bb5e4a0..d41ca915f34c1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx @@ -9,7 +9,7 @@ import { ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; -import { ValidationResult, Alert } from '../../../types'; +import { ValidationResult, Alert, AlertType } from '../../../types'; import { AlertForm } from './alert_form'; import { coreMock } from 'src/core/public/mocks'; import { ALERTS_FEATURE_ID, RecoveredActionGroup } from '../../../../../alerts/common'; @@ -63,6 +63,20 @@ describe('alert_form', () => { alertParamsExpression: () => , requiresAppContext: true, }; + + const disabledByLicenseAlertType = { + id: 'disabled-by-license', + iconClass: 'test', + name: 'test-alert', + description: 'Alert when testing', + documentationUrl: 'https://localhost.local/docs', + validate: (): ValidationResult => { + return { errors: {} }; + }, + alertParamsExpression: () => , + requiresAppContext: false, + }; + const useKibanaMock = useKibana as jest.Mocked; describe('alert_form create alert', () => { @@ -71,7 +85,7 @@ describe('alert_form', () => { async function setup() { const mocks = coreMock.createSetup(); const { loadAlertTypes } = jest.requireMock('../../lib/alert_api'); - const alertTypes = [ + const alertTypes: AlertType[] = [ { id: 'my-alert-type', name: 'Test', @@ -82,12 +96,41 @@ describe('alert_form', () => { }, ], defaultActionGroupId: 'testActionGroup', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, producer: ALERTS_FEATURE_ID, authorizedConsumers: { [ALERTS_FEATURE_ID]: { read: true, all: true }, test: { read: true, all: true }, }, + actionVariables: { + params: [], + state: [], + }, + enabledInLicense: true, + }, + { + id: 'disabled-by-license', + name: 'Test', + actionGroups: [ + { + id: 'testActionGroup', + name: 'Test Action Group', + }, + ], + defaultActionGroupId: 'testActionGroup', + minimumLicenseRequired: 'gold', + recoveryActionGroup: RecoveredActionGroup, + producer: ALERTS_FEATURE_ID, + authorizedConsumers: { + [ALERTS_FEATURE_ID]: { read: true, all: true }, + test: { read: true, all: true }, + }, + actionVariables: { + params: [], + state: [], + }, + enabledInLicense: false, }, ]; loadAlertTypes.mockResolvedValue(alertTypes); @@ -105,7 +148,11 @@ describe('alert_form', () => { delete: true, }, }; - alertTypeRegistry.list.mockReturnValue([alertType, alertTypeNonEditable]); + alertTypeRegistry.list.mockReturnValue([ + alertType, + alertTypeNonEditable, + disabledByLicenseAlertType, + ]); alertTypeRegistry.has.mockReturnValue(true); actionTypeRegistry.list.mockReturnValue([actionType]); actionTypeRegistry.has.mockReturnValue(true); @@ -185,6 +232,15 @@ describe('alert_form', () => { expect(alertDocumentationLink.exists()).toBeTruthy(); expect(alertDocumentationLink.first().prop('href')).toBe('https://localhost.local/docs'); }); + + it('renders alert types disabled by license', async () => { + await setup(); + const actionOption = wrapper.find(`[data-test-subj="disabled-by-license-SelectOption"]`); + expect(actionOption.exists()).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="disabled-by-license-disabledTooltip"]').exists() + ).toBeTruthy(); + }); }); describe('alert_form create alert non alerting consumer and producer', () => { @@ -204,6 +260,7 @@ describe('alert_form', () => { }, ], defaultActionGroupId: 'testActionGroup', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, producer: ALERTS_FEATURE_ID, authorizedConsumers: { @@ -221,6 +278,7 @@ describe('alert_form', () => { }, ], defaultActionGroupId: 'testActionGroup', + minimumLicenseRequired: 'basic', recoveryActionGroup: RecoveredActionGroup, producer: 'test', authorizedConsumers: { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index 3259e405e3f70..3210d53841993 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -31,6 +31,7 @@ import { EuiText, EuiNotificationBadge, EuiErrorBoundary, + EuiToolTip, } from '@elastic/eui'; import { capitalize, isObject } from 'lodash'; import { KibanaFeature } from '../../../../../features/public'; @@ -65,7 +66,11 @@ import './alert_form.scss'; import { useKibana } from '../../../common/lib/kibana'; import { recoveredActionGroupMessage } from '../../constants'; import { getDefaultsForActionParams } from '../../lib/get_defaults_for_action_params'; +import { IsEnabledResult, IsDisabledResult } from '../../lib/check_alert_type_enabled'; import { AlertNotifyWhen } from './alert_notify_when'; +import { checkAlertTypeEnabled } from '../../lib/check_alert_type_enabled'; +import { alertTypeCompare, alertTypeGroupCompare } from '../../lib/alert_type_compare'; +import { VIEW_LICENSE_OPTIONS_LINK } from '../../../common/constants'; const ENTER_KEY = 13; @@ -182,6 +187,7 @@ export const AlertForm = ({ const [inputText, setInputText] = useState(); const [solutions, setSolutions] = useState | undefined>(undefined); const [solutionsFilter, setSolutionFilter] = useState([]); + let hasDisabledByLicenseAlertTypes: boolean = false; // load alert types useEffect(() => { @@ -354,17 +360,30 @@ export const AlertForm = ({ const alertTypesByProducer = filteredAlertTypes.reduce( ( - result: Record>, + result: Record< + string, + Array<{ + id: string; + name: string; + checkEnabledResult: IsEnabledResult | IsDisabledResult; + alertTypeItem: AlertTypeModel; + }> + >, alertTypeValue ) => { const producer = alertTypeValue.alertType.producer; if (producer) { + const checkEnabledResult = checkAlertTypeEnabled(alertTypeValue.alertType); + if (!checkEnabledResult.isEnabled) { + hasDisabledByLicenseAlertTypes = true; + } (result[producer] = result[producer] || []).push({ name: typeof alertTypeValue.alertTypeModel.name === 'string' ? alertTypeValue.alertTypeModel.name : alertTypeValue.alertTypeModel.name.props.defaultMessage, id: alertTypeValue.alertTypeModel.id, + checkEnabledResult, alertTypeItem: alertTypeValue.alertTypeModel, }); } @@ -374,9 +393,7 @@ export const AlertForm = ({ ); const alertTypeNodes = Object.entries(alertTypesByProducer) - .sort(([a], [b]) => - solutions ? solutions.get(a)!.localeCompare(solutions.get(b)!) : a.localeCompare(b) - ) + .sort((a, b) => alertTypeGroupCompare(a, b, solutions)) .map(([solution, items], groupIndex) => ( {items - .sort((a, b) => a.name.toString().localeCompare(b.name.toString())) - .map((item, index) => ( - - - {item.name} - -

{item.alertTypeItem.description}

-
- - } - onClick={() => { - setAlertProperty('alertTypeId', item.id); - setActions([]); - setAlertTypeModel(item.alertTypeItem); - setAlertProperty('params', {}); - if (alertTypesIndex && alertTypesIndex.has(item.id)) { - setDefaultActionGroupId(alertTypesIndex.get(item.id)!.defaultActionGroupId); + .sort((a, b) => alertTypeCompare(a, b)) + .map((item, index) => { + const alertTypeListItemHtml = ( + + {item.name} + +

{item.alertTypeItem.description}

+
+
+ ); + return ( + + + {alertTypeListItemHtml} + + ) } - }} - /> - - ))} + isDisabled={!item.checkEnabledResult.isEnabled} + onClick={() => { + setAlertProperty('alertTypeId', item.id); + setActions([]); + setAlertTypeModel(item.alertTypeItem); + setAlertProperty('params', {}); + if (alertTypesIndex && alertTypesIndex.has(item.id)) { + setDefaultActionGroupId(alertTypesIndex.get(item.id)!.defaultActionGroupId); + } + }} + /> +
+ ); + })}
@@ -710,6 +743,23 @@ export const AlertForm = ({ + + + + + ) + } label={
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_reducer.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_reducer.ts index b86e0d1555315..0e9eb474e10ac 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_reducer.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_reducer.ts @@ -152,7 +152,11 @@ export const alertReducer = ( keyof AlertAction, SavedObjectAttribute >; - if (index === undefined || isEqual(alert.actions[index][key], value)) { + if ( + index === undefined || + alert.actions[index] == null || + isEqual(alert.actions[index][key], value) + ) { return state; } else { const oldAction = alert.actions.splice(index, 1)[0]; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.scss b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.scss new file mode 100644 index 0000000000000..fda7d6aa0b622 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.scss @@ -0,0 +1,7 @@ +.actAlertsList__tableRowDisabled { + background-color: $euiColorLightestShade; + + .actAlertsList__tableCellDisabled { + color: $euiColorDarkShade; + } +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx index cb4d6d8097463..7df5c6e157106 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx @@ -26,6 +26,7 @@ jest.mock('../../../lib/action_connector_api', () => ({ jest.mock('../../../lib/alert_api', () => ({ loadAlerts: jest.fn(), loadAlertTypes: jest.fn(), + health: jest.fn(() => ({ isSufficientlySecure: true, hasPermanentEncryptionKey: true })), })); jest.mock('react-router-dom', () => ({ useHistory: () => ({ @@ -60,6 +61,7 @@ const alertTypeFromApi = { actionVariables: { context: [], state: [] }, defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, + minimumLicenseRequired: 'basic', authorizedConsumers: { [ALERTS_FEATURE_ID]: { read: true, all: true }, }, @@ -114,7 +116,16 @@ describe('alerts_list component empty', () => { expect( wrapper.find('[data-test-subj="createFirstAlertButton"]').find('EuiButton') ).toHaveLength(1); - expect(wrapper.find('AlertAdd')).toHaveLength(1); + expect(wrapper.find('AlertAdd').exists()).toBeFalsy(); + + wrapper.find('button[data-test-subj="createFirstAlertButton"]').simulate('click'); + + // When the AlertAdd component is rendered, it waits for the healthcheck to resolve + await new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + wrapper.update(); + expect(wrapper.find('AlertAdd').exists()).toEqual(true); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx index aaaf843d43eab..1369e6e8f3b82 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx @@ -26,6 +26,7 @@ import { EuiButtonEmpty, EuiHealth, EuiText, + EuiToolTip, } from '@elastic/eui'; import { useHistory } from 'react-router-dom'; @@ -57,6 +58,8 @@ import { import { hasAllPrivilege } from '../../../lib/capabilities'; import { alertsStatusesTranslationsMapping } from '../translations'; import { useKibana } from '../../../../common/lib/kibana'; +import { checkAlertTypeEnabled } from '../../../lib/check_alert_type_enabled'; +import './alerts_list.scss'; const ENTER_KEY = 13; @@ -255,7 +258,10 @@ export const AlertsList: React.FunctionComponent = () => { truncateText: true, 'data-test-subj': 'alertsTableCell-name', render: (name: string, alert: AlertTableItem) => { - return ( + const checkEnabledResult = checkAlertTypeEnabled( + alertTypesState.data.get(alert.alertTypeId) + ); + const link = ( { @@ -265,6 +271,17 @@ export const AlertsList: React.FunctionComponent = () => { {name} ); + return checkEnabledResult.isEnabled ? ( + link + ) : ( + + {link} + + ); }, }, { @@ -572,11 +589,17 @@ export const AlertsList: React.FunctionComponent = () => { } itemId="id" columns={alertsTableColumns} - rowProps={() => ({ + rowProps={(item: AlertTableItem) => ({ 'data-test-subj': 'alert-row', + className: !alertTypesState.data.get(item.alertTypeId)?.enabledInLicense + ? 'actAlertsList__tableRowDisabled' + : '', })} - cellProps={() => ({ + cellProps={(item: AlertTableItem) => ({ 'data-test-subj': 'cell', + className: !alertTypesState.data.get(item.alertTypeId)?.enabledInLicense + ? 'actAlertsList__tableCellDisabled' + : '', })} data-test-subj="alertsList" pagination={{ @@ -653,14 +676,17 @@ export const AlertsList: React.FunctionComponent = () => { ) : ( noPermissionPrompt )} - + {alertFlyoutVisible && ( + { + setAlertFlyoutVisibility(false); + }} + actionTypeRegistry={actionTypeRegistry} + alertTypeRegistry={alertTypeRegistry} + reloadAlerts={loadAlertsData} + /> + )} ); }; @@ -704,5 +730,6 @@ function convertAlertsToTableItems( isEditable: hasAllPrivilege(alert, alertTypesIndex.get(alert.alertTypeId)) && (canExecuteActions || (!canExecuteActions && !alert.actions.length)), + enabledInLicense: !!alertTypesIndex.get(alert.alertTypeId)?.enabledInLicense, })); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/collapsed_item_actions.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/collapsed_item_actions.tsx index 9279f8a1745fc..0817cb34dc6f5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/collapsed_item_actions.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/collapsed_item_actions.tsx @@ -68,7 +68,7 @@ export const CollapsedItemActions: React.FunctionComponent = ({
= ({ { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/translations.ts index dbcf2d6854af5..a57cca5476420 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/translations.ts @@ -77,9 +77,17 @@ export const ALERT_ERROR_EXECUTION_REASON = i18n.translate( } ); +export const ALERT_ERROR_LICENSE_REASON = i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertErrorReasonLicense', + { + defaultMessage: 'Cannot run alert', + } +); + export const alertsErrorReasonTranslationsMapping = { read: ALERT_ERROR_READING_REASON, decrypt: ALERT_ERROR_DECRYPTING_REASON, execute: ALERT_ERROR_EXECUTION_REASON, unknown: ALERT_ERROR_UNKNOWN_REASON, + license: ALERT_ERROR_LICENSE_REASON, }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/alert_quick_edit_buttons.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/alert_quick_edit_buttons.tsx index f87768c8d4537..2a58e51c2abd7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/alert_quick_edit_buttons.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/alert_quick_edit_buttons.tsx @@ -9,7 +9,7 @@ import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButtonEmpty, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; -import { Alert } from '../../../../types'; +import { AlertTableItem } from '../../../../types'; import { withBulkAlertOperations, ComponentOpts as BulkOperationsComponentOpts, @@ -18,7 +18,7 @@ import './alert_quick_edit_buttons.scss'; import { useKibana } from '../../../../common/lib/kibana'; export type ComponentOpts = { - selectedItems: Alert[]; + selectedItems: AlertTableItem[]; onPerformingAction?: () => void; onActionPerformed?: () => void; setAlertsToDelete: React.Dispatch>; @@ -49,6 +49,10 @@ export const AlertQuickEditButtons: React.FunctionComponent = ({ const isPerformingAction = isMutingAlerts || isUnmutingAlerts || isEnablingAlerts || isDisablingAlerts || isDeletingAlerts; + const hasDisabledByLicenseAlertTypes = !!selectedItems.find( + (alertItem) => !alertItem.enabledInLicense + ); + async function onmMuteAllClick() { onPerformingAction(); setIsMutingAlerts(true); @@ -156,7 +160,7 @@ export const AlertQuickEditButtons: React.FunctionComponent = ({ = ({ = ({ = ({ = ({ export const AlertQuickEditButtonsWithApi = withBulkAlertOperations(AlertQuickEditButtons); -function isAlertDisabled(alert: Alert) { +function isAlertDisabled(alert: AlertTableItem) { return alert.enabled === false; } -function isAlertMuted(alert: Alert) { +function isAlertMuted(alert: AlertTableItem) { return alert.muteAll === true; } diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index f950bbbd8ed25..cd1ebe47a8c22 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -11,6 +11,7 @@ import { DataPublicPluginStart } from 'src/plugins/data/public'; import { ActionGroup, AlertActionParam } from '../../alerts/common'; import { ActionType } from '../../actions/common'; import { TypeRegistry } from './application/type_registry'; +import { AlertType as CommonAlertType } from '../../alerts/common'; import { SanitizedAlert as Alert, AlertAction, @@ -140,15 +141,20 @@ export const OPTIONAL_ACTION_VARIABLES = ['context'] as const; export type ActionVariables = AsActionVariables & Partial>; -export interface AlertType { - id: string; - name: string; - actionGroups: ActionGroup[]; - recoveryActionGroup: ActionGroup; +export interface AlertType + extends Pick< + CommonAlertType, + | 'id' + | 'name' + | 'actionGroups' + | 'producer' + | 'minimumLicenseRequired' + | 'recoveryActionGroup' + | 'defaultActionGroupId' + > { actionVariables: ActionVariables; - defaultActionGroupId: ActionGroup['id']; authorizedConsumers: Record; - producer: string; + enabledInLicense: boolean; } export type SanitizedAlertType = Omit; @@ -159,6 +165,7 @@ export interface AlertTableItem extends Alert { alertType: AlertType['name']; tagsText: string; isEditable: boolean; + enabledInLicense: boolean; } export interface AlertTypeParamsExpressionProps< diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/executed_step.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/executed_step.test.tsx index 6864dc0eb7cf5..e3a3d39241de2 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/executed_step.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/executed_step.test.tsx @@ -9,7 +9,8 @@ import { ExecutedStep } from '../executed_step'; import { Ping } from '../../../../../common/runtime_types'; import { mountWithRouter } from '../../../../lib'; -describe('ExecutedStep', () => { +// FLAKY: https://github.com/elastic/kibana/issues/85899 +describe.skip('ExecutedStep', () => { let step: Ping; beforeEach(() => { 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/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/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/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 c0156d92439f0..f50868ee76c1c 100644 --- a/x-pack/test/apm_api_integration/basic/tests/index.ts +++ b/x-pack/test/apm_api_integration/basic/tests/index.ts @@ -27,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/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 ec79c8a1ca494..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); @@ -318,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/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 012af6b37f842..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,7 +23,7 @@ export const postCaseReq: CasePostRequest = { connector: { id: 'none', name: 'none', - type: '.none' as ConnectorTypes, + type: ConnectorTypes.none, fields: null, }, settings: { 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/functional/apps/discover/async_scripted_fields.js b/x-pack/test/functional/apps/discover/async_scripted_fields.js index 247c9c81015d3..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 () { @@ -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/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/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/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, }; }