diff --git a/docs/canvas/canvas-function-reference.asciidoc b/docs/canvas/canvas-function-reference.asciidoc index 6a6c840074f02..eaaf68eb06195 100644 --- a/docs/canvas/canvas-function-reference.asciidoc +++ b/docs/canvas/canvas-function-reference.asciidoc @@ -1697,6 +1697,16 @@ Aliases: `column`, `name` Aliases: `exp`, `fn`, `function` |`boolean`, `number`, `string`, `null` |A Canvas expression that is passed to each row as a single row `datatable`. + +|`id` + +|`string`, `null` +|An optional id of the resulting column. When not specified or `null` the name argument is used as id. + +|`copyMetaFrom` + +|`string`, `null` +|If set, the meta object from the specified column id is copied over to the specified target column. Throws an exception if the column doesn't exist |=== *Returns:* `datatable` @@ -1755,9 +1765,16 @@ Interprets a `TinyMath` math expression using a `number` or `datatable` as _cont Alias: `expression` |`string` |An evaluated `TinyMath` expression. See https://www.elastic.co/guide/en/kibana/current/canvas-tinymath-functions.html. + +|`onError` + +|`string` +|In case the `TinyMath` evaluation fails or returns NaN, the return value is specified by onError. For example, `"null"`, `"zero"`, `"false"`, `"throw"`. When `"throw"`, it will throw an exception, terminating expression execution. + +Default: `"throw"` |=== -*Returns:* `number` +*Returns:* `number` | `boolean` | `null` [float] diff --git a/packages/kbn-docs-utils/package.json b/packages/kbn-docs-utils/package.json index 6571f8c87dbfa..089732e9e6b40 100644 --- a/packages/kbn-docs-utils/package.json +++ b/packages/kbn-docs-utils/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@kbn/utils": "link:../kbn-utils", + "@kbn/config": "link:../kbn-config", "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/src/core/server/elasticsearch/default_headers.test.ts b/src/core/server/elasticsearch/default_headers.test.ts new file mode 100644 index 0000000000000..58e6e222a3f2b --- /dev/null +++ b/src/core/server/elasticsearch/default_headers.test.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getReservedHeaders, PRODUCT_ORIGIN_HEADER } from './default_headers'; + +describe('getReservedHeaders', () => { + it('returns the list of reserved headers contained in a list', () => { + expect(getReservedHeaders(['foo', 'bar', PRODUCT_ORIGIN_HEADER])).toEqual([ + PRODUCT_ORIGIN_HEADER, + ]); + }); + + it('ignores the case when identifying headers', () => { + expect(getReservedHeaders(['foo', 'bar', PRODUCT_ORIGIN_HEADER.toUpperCase()])).toEqual([ + PRODUCT_ORIGIN_HEADER.toUpperCase(), + ]); + }); +}); diff --git a/src/core/server/elasticsearch/default_headers.ts b/src/core/server/elasticsearch/default_headers.ts index 737d4772c5c0e..eef04754cd958 100644 --- a/src/core/server/elasticsearch/default_headers.ts +++ b/src/core/server/elasticsearch/default_headers.ts @@ -8,9 +8,22 @@ import { deepFreeze } from '@kbn/std'; +export const PRODUCT_ORIGIN_HEADER = 'x-elastic-product-origin'; + +export const RESERVED_HEADERS = deepFreeze([PRODUCT_ORIGIN_HEADER]); + export const DEFAULT_HEADERS = deepFreeze({ // Elasticsearch uses this to identify when a request is coming from Kibana, to allow Kibana to - // access system indices using the standard ES APIs without logging a warning. After migrating to - // use the new system index APIs, this header can be removed. - 'x-elastic-product-origin': 'kibana', + // access system indices using the standard ES APIs. + [PRODUCT_ORIGIN_HEADER]: 'kibana', }); + +export const getReservedHeaders = (headerNames: string[]): string[] => { + const reservedHeaders = []; + for (const headerName of headerNames) { + if (RESERVED_HEADERS.includes(headerName.toLowerCase())) { + reservedHeaders.push(headerName); + } + } + return reservedHeaders; +}; diff --git a/src/core/server/elasticsearch/elasticsearch_config.test.ts b/src/core/server/elasticsearch/elasticsearch_config.test.ts index ae71932bdd3a9..d3f9693bab229 100644 --- a/src/core/server/elasticsearch/elasticsearch_config.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_config.test.ts @@ -108,6 +108,35 @@ test('#requestHeadersWhitelist accepts both string and array of strings', () => expect(configValue.requestHeadersWhitelist).toEqual(['token', 'X-Forwarded-Proto']); }); +describe('reserved headers', () => { + test('throws if customHeaders contains reserved headers', () => { + expect(() => { + config.schema.validate({ + customHeaders: { foo: 'bar', 'x-elastic-product-origin': 'beats' }, + }); + }).toThrowErrorMatchingInlineSnapshot( + `"[customHeaders]: cannot use reserved headers: [x-elastic-product-origin]"` + ); + }); + + test('throws if requestHeadersWhitelist contains reserved headers', () => { + expect(() => { + config.schema.validate({ requestHeadersWhitelist: ['foo', 'x-elastic-product-origin'] }); + }).toThrowErrorMatchingInlineSnapshot(` + "[requestHeadersWhitelist]: types that failed validation: + - [requestHeadersWhitelist.0]: expected value of type [string] but got [Array] + - [requestHeadersWhitelist.1]: cannot use reserved headers: [x-elastic-product-origin]" + `); + expect(() => { + config.schema.validate({ requestHeadersWhitelist: 'x-elastic-product-origin' }); + }).toThrowErrorMatchingInlineSnapshot(` + "[requestHeadersWhitelist]: types that failed validation: + - [requestHeadersWhitelist.0]: cannot use reserved headers: [x-elastic-product-origin] + - [requestHeadersWhitelist.1]: could not parse array value from json input" + `); + }); +}); + describe('reads files', () => { beforeEach(() => { mockReadFileSync.mockReset(); diff --git a/src/core/server/elasticsearch/elasticsearch_config.ts b/src/core/server/elasticsearch/elasticsearch_config.ts index 300ff4a61a288..879002a6ece51 100644 --- a/src/core/server/elasticsearch/elasticsearch_config.ts +++ b/src/core/server/elasticsearch/elasticsearch_config.ts @@ -12,6 +12,7 @@ import { readFileSync } from 'fs'; import { ConfigDeprecationProvider } from 'src/core/server'; import { readPkcs12Keystore, readPkcs12Truststore } from '../utils'; import { ServiceConfigDescriptor } from '../internal_types'; +import { getReservedHeaders } from './default_headers'; const hostURISchema = schema.uri({ scheme: ['http', 'https'] }); @@ -52,10 +53,42 @@ export const configSchema = schema.object({ ) ), password: schema.maybe(schema.string()), - requestHeadersWhitelist: schema.oneOf([schema.string(), schema.arrayOf(schema.string())], { - defaultValue: ['authorization'], + requestHeadersWhitelist: schema.oneOf( + [ + schema.string({ + // can't use `validate` option on union types, forced to validate each individual subtypes + // see https://github.com/elastic/kibana/issues/64906 + validate: (headersWhitelist) => { + const reservedHeaders = getReservedHeaders([headersWhitelist]); + if (reservedHeaders.length) { + return `cannot use reserved headers: [${reservedHeaders.join(', ')}]`; + } + }, + }), + schema.arrayOf(schema.string(), { + // can't use `validate` option on union types, forced to validate each individual subtypes + // see https://github.com/elastic/kibana/issues/64906 + validate: (headersWhitelist) => { + const reservedHeaders = getReservedHeaders(headersWhitelist); + if (reservedHeaders.length) { + return `cannot use reserved headers: [${reservedHeaders.join(', ')}]`; + } + }, + }), + ], + { + defaultValue: ['authorization'], + } + ), + customHeaders: schema.recordOf(schema.string(), schema.string(), { + defaultValue: {}, + validate: (customHeaders) => { + const reservedHeaders = getReservedHeaders(Object.keys(customHeaders)); + if (reservedHeaders.length) { + return `cannot use reserved headers: [${reservedHeaders.join(', ')}]`; + } + }, }), - customHeaders: schema.recordOf(schema.string(), schema.string(), { defaultValue: {} }), shardTimeout: schema.duration({ defaultValue: '30s' }), requestTimeout: schema.duration({ defaultValue: '30s' }), pingTimeout: schema.duration({ defaultValue: schema.siblingRef('requestTimeout') }), diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts index fc26c837d5e52..267d671361184 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts @@ -96,8 +96,7 @@ describe('getSearchDsl', () => { mappings, opts.type, opts.sortField, - opts.sortOrder, - opts.pit + opts.sortOrder ); }); diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts index cae5e43897bcf..9820544f02bd1 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts @@ -78,7 +78,7 @@ export function getSearchDsl( hasReferenceOperator, kueryNode, }), - ...getSortingParams(mappings, type, sortField, sortOrder, pit), + ...getSortingParams(mappings, type, sortField, sortOrder), ...(pit ? getPitParams(pit) : {}), ...(searchAfter ? { search_after: searchAfter } : {}), }; diff --git a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts index 73c7065705fc5..1376f0d50a9da 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts @@ -79,11 +79,6 @@ describe('searchDsl/getSortParams', () => { ], }); }); - it('appends tiebreaker when PIT is provided', () => { - expect(getSortingParams(MAPPINGS, 'saved', 'title', undefined, { id: 'abc' }).sort).toEqual( - expect.arrayContaining([{ _shard_doc: 'asc' }]) - ); - }); }); describe('sortField is simple root property with multiple types', () => { it('returns correct params', () => { @@ -98,11 +93,6 @@ describe('searchDsl/getSortParams', () => { ], }); }); - it('appends tiebreaker when PIT is provided', () => { - expect( - getSortingParams(MAPPINGS, ['saved', 'pending'], 'type', undefined, { id: 'abc' }).sort - ).toEqual(expect.arrayContaining([{ _shard_doc: 'asc' }])); - }); }); describe('sortField is simple non-root property with multiple types', () => { it('returns correct params', () => { @@ -124,11 +114,6 @@ describe('searchDsl/getSortParams', () => { ], }); }); - it('appends tiebreaker when PIT is provided', () => { - expect( - getSortingParams(MAPPINGS, 'saved', 'title.raw', undefined, { id: 'abc' }).sort - ).toEqual(expect.arrayContaining([{ _shard_doc: 'asc' }])); - }); }); describe('sortField is multi-field with single type as array', () => { it('returns correct params', () => { @@ -143,11 +128,6 @@ describe('searchDsl/getSortParams', () => { ], }); }); - it('appends tiebreaker when PIT is provided', () => { - expect( - getSortingParams(MAPPINGS, ['saved'], 'title.raw', undefined, { id: 'abc' }).sort - ).toEqual(expect.arrayContaining([{ _shard_doc: 'asc' }])); - }); }); describe('sortField is root multi-field with multiple types', () => { it('returns correct params', () => { @@ -162,12 +142,6 @@ describe('searchDsl/getSortParams', () => { ], }); }); - it('appends tiebreaker when PIT is provided', () => { - expect( - getSortingParams(MAPPINGS, ['saved', 'pending'], 'type.raw', undefined, { id: 'abc' }) - .sort - ).toEqual(expect.arrayContaining([{ _shard_doc: 'asc' }])); - }); }); describe('sortField is not-root multi-field with multiple types', () => { it('returns correct params', () => { diff --git a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts index abef9bfa0a300..e3bfba6a80f59 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts @@ -8,12 +8,6 @@ import Boom from '@hapi/boom'; import { getProperty, IndexMapping } from '../../../mappings'; -import { SavedObjectsPitParams } from '../../../types'; - -// TODO: The plan is for ES to automatically add this tiebreaker when -// using PIT. We should remove this logic once that is resolved. -// https://github.com/elastic/elasticsearch/issues/56828 -const ES_PROVIDED_TIEBREAKER = { _shard_doc: 'asc' }; const TOP_LEVEL_FIELDS = ['_id', '_score']; @@ -21,8 +15,7 @@ export function getSortingParams( mappings: IndexMapping, type: string | string[], sortField?: string, - sortOrder?: string, - pit?: SavedObjectsPitParams + sortOrder?: string ) { if (!sortField) { return {}; @@ -38,7 +31,6 @@ export function getSortingParams( order: sortOrder, }, }, - ...(pit ? [ES_PROVIDED_TIEBREAKER] : []), ], }; } @@ -59,7 +51,6 @@ export function getSortingParams( unmapped_type: rootField.type, }, }, - ...(pit ? [ES_PROVIDED_TIEBREAKER] : []), ], }; } @@ -84,7 +75,6 @@ export function getSortingParams( unmapped_type: field.type, }, }, - ...(pit ? [ES_PROVIDED_TIEBREAKER] : []), ], }; } diff --git a/src/dev/typescript/ref_output_cache/ref_output_cache.ts b/src/dev/typescript/ref_output_cache/ref_output_cache.ts index 6f51243e47555..ca69236a706d2 100644 --- a/src/dev/typescript/ref_output_cache/ref_output_cache.ts +++ b/src/dev/typescript/ref_output_cache/ref_output_cache.ts @@ -76,28 +76,28 @@ export class RefOutputCache { * written to the directory. */ async initCaches() { - const archive = - this.archives.get(this.mergeBase) ?? - (await this.archives.getFirstAvailable([ - this.mergeBase, - ...(await this.repo.getRecentShasFrom(this.mergeBase, 5)), - ])); - - if (!archive) { - return; - } - const outdatedOutDirs = ( await concurrentMap(100, this.outDirs, async (outDir) => ({ path: outDir, - outdated: !(await matchMergeBase(outDir, archive.sha)), + outdated: !(await matchMergeBase(outDir, this.mergeBase)), })) ) .filter((o) => o.outdated) .map((o) => o.path); if (!outdatedOutDirs.length) { - this.log.debug('all outDirs have the most recent cache'); + this.log.debug('all outDirs have a recent cache'); + return; + } + + const archive = + this.archives.get(this.mergeBase) ?? + (await this.archives.getFirstAvailable([ + this.mergeBase, + ...(await this.repo.getRecentShasFrom(this.mergeBase, 5)), + ])); + + if (!archive) { return; } diff --git a/src/plugins/discover/public/application/components/sidebar/lib/field_filter.ts b/src/plugins/discover/public/application/components/sidebar/lib/field_filter.ts index 5517011f05718..8c7e48f173031 100644 --- a/src/plugins/discover/public/application/components/sidebar/lib/field_filter.ts +++ b/src/plugins/discover/public/application/components/sidebar/lib/field_filter.ts @@ -59,6 +59,7 @@ export function isFieldFiltered( const scriptedOrMissing = !filterState.missing || field.type === '_source' || + field.type === 'unknown_selected' || field.scripted || fieldCounts[field.name] > 0; const needle = filterState.name ? filterState.name.toLowerCase() : ''; diff --git a/src/plugins/discover/public/application/components/sidebar/lib/group_fields.test.ts b/src/plugins/discover/public/application/components/sidebar/lib/group_fields.test.ts index 9792e98ba84c7..68099fb0c8e2a 100644 --- a/src/plugins/discover/public/application/components/sidebar/lib/group_fields.test.ts +++ b/src/plugins/discover/public/application/components/sidebar/lib/group_fields.test.ts @@ -217,6 +217,20 @@ describe('group_fields', function () { ]); }); + it('should filter fields by a given name', function () { + const fieldFilterState = { ...getDefaultFieldFilter(), ...{ name: 'curr' } }; + + const actual1 = groupFields( + fields as IndexPatternField[], + ['customer_birth_date', 'currency', 'unknown'], + 5, + fieldCounts, + fieldFilterState, + false + ); + expect(actual1.selected.map((field) => field.name)).toEqual(['currency']); + }); + it('excludes unmapped fields if showUnmappedFields set to false', function () { const fieldFilterState = getDefaultFieldFilter(); const fieldsWithUnmappedField = [...fields]; diff --git a/src/plugins/discover/public/application/components/sidebar/lib/group_fields.tsx b/src/plugins/discover/public/application/components/sidebar/lib/group_fields.tsx index eefb96b78aac6..dc6cbcedc8086 100644 --- a/src/plugins/discover/public/application/components/sidebar/lib/group_fields.tsx +++ b/src/plugins/discover/public/application/components/sidebar/lib/group_fields.tsx @@ -71,10 +71,18 @@ export function groupFields( } } } - // add columns, that are not part of the index pattern, to be removeable + // add selected columns, that are not part of the index pattern, to be removeable for (const column of columns) { - if (!result.selected.find((field) => field.name === column)) { - result.selected.push({ name: column, displayName: column } as IndexPatternField); + const tmpField = { + name: column, + displayName: column, + type: 'unknown_selected', + } as IndexPatternField; + if ( + !result.selected.find((field) => field.name === column) && + isFieldFiltered(tmpField, fieldFilterState, fieldCounts) + ) { + result.selected.push(tmpField); } } result.selected.sort((a, b) => { diff --git a/src/plugins/embeddable/README.asciidoc b/src/plugins/embeddable/README.asciidoc index daa6040eab7eb..165dc37c56cb3 100644 --- a/src/plugins/embeddable/README.asciidoc +++ b/src/plugins/embeddable/README.asciidoc @@ -22,18 +22,17 @@ There is also an example of rendering dashboard container outside of dashboard a === Docs -link:./docs/README.md[Embeddable docs, guides & caveats] +link:https://github.com/elastic/kibana/blob/master/src/plugins/embeddable/docs/README.md[Embeddable docs, guides & caveats] === API docs -==== Server API -https://github.com/elastic/kibana/blob/master/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.md[Server Setup contract] -https://github.com/elastic/kibana/blob/master/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablestart.md[Server Start contract] - -===== Browser API +==== Browser API https://github.com/elastic/kibana/blob/master/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablesetup.md[Browser Setup contract] https://github.com/elastic/kibana/blob/master/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestart.md[Browser Start contract] +==== Server API +https://github.com/elastic/kibana/blob/master/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.md[Server Setup contract] + === Testing Run unit tests diff --git a/src/plugins/expressions/README.asciidoc b/src/plugins/expressions/README.asciidoc index e07f6e2909ab8..554e3bfcb6976 100644 --- a/src/plugins/expressions/README.asciidoc +++ b/src/plugins/expressions/README.asciidoc @@ -46,7 +46,7 @@ image::https://user-images.githubusercontent.com/9773803/74162514-3250a880-4c21- https://github.com/elastic/kibana/blob/master/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.expressionsserversetup.md[Server Setup contract] https://github.com/elastic/kibana/blob/master/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.expressionsserverstart.md[Server Start contract] -===== Browser API +==== Browser API https://github.com/elastic/kibana/blob/master/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionsservicesetup.md[Browser Setup contract] https://github.com/elastic/kibana/blob/master/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionsstart.md[Browser Start contract] diff --git a/src/plugins/expressions/common/expression_functions/specs/index.ts b/src/plugins/expressions/common/expression_functions/specs/index.ts index 938fce026027c..9408b3a433712 100644 --- a/src/plugins/expressions/common/expression_functions/specs/index.ts +++ b/src/plugins/expressions/common/expression_functions/specs/index.ts @@ -15,6 +15,8 @@ import { theme } from './theme'; import { cumulativeSum } from './cumulative_sum'; import { derivative } from './derivative'; import { movingAverage } from './moving_average'; +import { mapColumn } from './map_column'; +import { math } from './math'; export const functionSpecs: AnyExpressionFunctionDefinition[] = [ clog, @@ -25,6 +27,8 @@ export const functionSpecs: AnyExpressionFunctionDefinition[] = [ cumulativeSum, derivative, movingAverage, + mapColumn, + math, ]; export * from './clog'; @@ -35,3 +39,5 @@ export * from './theme'; export * from './cumulative_sum'; export * from './derivative'; export * from './moving_average'; +export { mapColumn, MapColumnArguments } from './map_column'; +export { math, MathArguments, MathInput } from './math'; diff --git a/src/plugins/expressions/common/expression_functions/specs/map_column.ts b/src/plugins/expressions/common/expression_functions/specs/map_column.ts new file mode 100644 index 0000000000000..e2605e5ddf38d --- /dev/null +++ b/src/plugins/expressions/common/expression_functions/specs/map_column.ts @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from '../types'; +import { Datatable, getType } from '../../expression_types'; + +export interface MapColumnArguments { + id?: string | null; + name: string; + expression?: (datatable: Datatable) => Promise; + copyMetaFrom?: string | null; +} + +export const mapColumn: ExpressionFunctionDefinition< + 'mapColumn', + Datatable, + MapColumnArguments, + Promise +> = { + name: 'mapColumn', + aliases: ['mc'], // midnight commander. So many times I've launched midnight commander instead of moving a file. + type: 'datatable', + inputTypes: ['datatable'], + help: i18n.translate('expressions.functions.mapColumnHelpText', { + defaultMessage: + 'Adds a column calculated as the result of other columns. ' + + 'Changes are made only when you provide arguments.' + + 'See also {alterColumnFn} and {staticColumnFn}.', + values: { + alterColumnFn: '`alterColumn`', + staticColumnFn: '`staticColumn`', + }, + }), + args: { + id: { + types: ['string', 'null'], + help: i18n.translate('expressions.functions.mapColumn.args.idHelpText', { + defaultMessage: + 'An optional id of the resulting column. When `null` the name/column argument is used as id.', + }), + required: false, + default: null, + }, + name: { + types: ['string'], + aliases: ['_', 'column'], + help: i18n.translate('expressions.functions.mapColumn.args.nameHelpText', { + defaultMessage: 'The name of the resulting column.', + }), + required: true, + }, + expression: { + types: ['boolean', 'number', 'string', 'null'], + resolve: false, + aliases: ['exp', 'fn', 'function'], + help: i18n.translate('expressions.functions.mapColumn.args.expressionHelpText', { + defaultMessage: + 'An expression that is executed on every row, provided with a single-row {DATATABLE} context and returning the cell value.', + values: { + DATATABLE: '`datatable`', + }, + }), + required: true, + }, + copyMetaFrom: { + types: ['string', 'null'], + help: i18n.translate('expressions.functions.mapColumn.args.copyMetaFromHelpText', { + defaultMessage: + "If set, the meta object from the specified column id is copied over to the specified target column. If the column doesn't exist it silently fails.", + }), + required: false, + default: null, + }, + }, + fn: (input, args) => { + const expression = args.expression || (() => Promise.resolve(null)); + const columnId = args.id != null ? args.id : args.name; + + const columns = [...input.columns]; + const rowPromises = input.rows.map((row) => { + return expression({ + type: 'datatable', + columns, + rows: [row], + }).then((val) => ({ + ...row, + [columnId]: val, + })); + }); + + return Promise.all(rowPromises).then((rows) => { + const existingColumnIndex = columns.findIndex(({ name }) => name === args.name); + const type = rows.length ? getType(rows[0][columnId]) : 'null'; + const newColumn = { + id: columnId, + name: args.name, + meta: { type }, + }; + if (args.copyMetaFrom) { + const metaSourceFrom = columns.find(({ id }) => id === args.copyMetaFrom); + newColumn.meta = { ...newColumn.meta, ...(metaSourceFrom?.meta || {}) }; + } + + if (existingColumnIndex === -1) { + columns.push(newColumn); + } else { + columns[existingColumnIndex] = newColumn; + } + + return { + type: 'datatable', + columns, + rows, + } as Datatable; + }); + }, +}; diff --git a/src/plugins/expressions/common/expression_functions/specs/math.ts b/src/plugins/expressions/common/expression_functions/specs/math.ts new file mode 100644 index 0000000000000..a70c032769b57 --- /dev/null +++ b/src/plugins/expressions/common/expression_functions/specs/math.ts @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { map, zipObject } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { evaluate } from '@kbn/tinymath'; +import { ExpressionFunctionDefinition } from '../types'; +import { Datatable, isDatatable } from '../../expression_types'; + +export type MathArguments = { + expression: string; + onError?: 'null' | 'zero' | 'false' | 'throw'; +}; + +export type MathInput = number | Datatable; + +const TINYMATH = '`TinyMath`'; +const TINYMATH_URL = + 'https://www.elastic.co/guide/en/kibana/current/canvas-tinymath-functions.html'; + +const isString = (val: any): boolean => typeof val === 'string'; + +function pivotObjectArray< + RowType extends { [key: string]: any }, + ReturnColumns extends string | number | symbol = keyof RowType +>(rows: RowType[], columns?: string[]): Record { + const columnNames = columns || Object.keys(rows[0]); + if (!columnNames.every(isString)) { + throw new Error('Columns should be an array of strings'); + } + + const columnValues = map(columnNames, (name) => map(rows, name)); + return zipObject(columnNames, columnValues); +} + +export const errors = { + emptyExpression: () => + new Error( + i18n.translate('expressions.functions.math.emptyExpressionErrorMessage', { + defaultMessage: 'Empty expression', + }) + ), + tooManyResults: () => + new Error( + i18n.translate('expressions.functions.math.tooManyResultsErrorMessage', { + defaultMessage: + 'Expressions must return a single number. Try wrapping your expression in {mean} or {sum}', + values: { + mean: 'mean()', + sum: 'sum()', + }, + }) + ), + executionFailed: () => + new Error( + i18n.translate('expressions.functions.math.executionFailedErrorMessage', { + defaultMessage: 'Failed to execute math expression. Check your column names', + }) + ), + emptyDatatable: () => + new Error( + i18n.translate('expressions.functions.math.emptyDatatableErrorMessage', { + defaultMessage: 'Empty datatable', + }) + ), +}; + +const fallbackValue = { + null: null, + zero: 0, + false: false, +} as const; + +export const math: ExpressionFunctionDefinition< + 'math', + MathInput, + MathArguments, + boolean | number | null +> = { + name: 'math', + type: undefined, + inputTypes: ['number', 'datatable'], + help: i18n.translate('expressions.functions.mathHelpText', { + defaultMessage: + 'Interprets a {TINYMATH} math expression using a {TYPE_NUMBER} or {DATATABLE} as {CONTEXT}. ' + + 'The {DATATABLE} columns are available by their column name. ' + + 'If the {CONTEXT} is a number it is available as {value}.', + values: { + TINYMATH, + CONTEXT: '_context_', + DATATABLE: '`datatable`', + value: '`value`', + TYPE_NUMBER: '`number`', + }, + }), + args: { + expression: { + aliases: ['_'], + types: ['string'], + help: i18n.translate('expressions.functions.math.args.expressionHelpText', { + defaultMessage: 'An evaluated {TINYMATH} expression. See {TINYMATH_URL}.', + values: { + TINYMATH, + TINYMATH_URL, + }, + }), + }, + onError: { + types: ['string'], + options: ['throw', 'false', 'zero', 'null'], + help: i18n.translate('expressions.functions.math.args.onErrorHelpText', { + defaultMessage: + "In case the {TINYMATH} evaluation fails or returns NaN, the return value is specified by onError. When `'throw'`, it will throw an exception, terminating expression execution (default).", + values: { + TINYMATH, + }, + }), + }, + }, + fn: (input, args) => { + const { expression, onError } = args; + const onErrorValue = onError ?? 'throw'; + + if (!expression || expression.trim() === '') { + throw errors.emptyExpression(); + } + + const mathContext = isDatatable(input) + ? pivotObjectArray( + input.rows, + input.columns.map((col) => col.name) + ) + : { value: input }; + + try { + const result = evaluate(expression, mathContext); + if (Array.isArray(result)) { + if (result.length === 1) { + return result[0]; + } + throw errors.tooManyResults(); + } + if (isNaN(result)) { + // make TS happy + if (onErrorValue !== 'throw' && onErrorValue in fallbackValue) { + return fallbackValue[onErrorValue]; + } + throw errors.executionFailed(); + } + return result; + } catch (e) { + if (onErrorValue !== 'throw' && onErrorValue in fallbackValue) { + return fallbackValue[onErrorValue]; + } + if (isDatatable(input) && input.rows.length === 0) { + throw errors.emptyDatatable(); + } else { + throw e; + } + } + }, +}; diff --git a/src/plugins/expressions/common/expression_functions/specs/tests/map_column.test.ts b/src/plugins/expressions/common/expression_functions/specs/tests/map_column.test.ts new file mode 100644 index 0000000000000..6b0dce4ff9a2a --- /dev/null +++ b/src/plugins/expressions/common/expression_functions/specs/tests/map_column.test.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Datatable } from '../../../expression_types'; +import { mapColumn, MapColumnArguments } from '../map_column'; +import { emptyTable, functionWrapper, testTable } from './utils'; + +const pricePlusTwo = (datatable: Datatable) => Promise.resolve(datatable.rows[0].price + 2); + +describe('mapColumn', () => { + const fn = functionWrapper(mapColumn); + const runFn = (input: Datatable, args: MapColumnArguments) => + fn(input, args) as Promise; + + it('returns a datatable with a new column with the values from mapping a function over each row in a datatable', () => { + return runFn(testTable, { + id: 'pricePlusTwo', + name: 'pricePlusTwo', + expression: pricePlusTwo, + }).then((result) => { + const arbitraryRowIndex = 2; + + expect(result.type).toBe('datatable'); + expect(result.columns).toEqual([ + ...testTable.columns, + { id: 'pricePlusTwo', name: 'pricePlusTwo', meta: { type: 'number' } }, + ]); + expect(result.columns[result.columns.length - 1]).toHaveProperty('name', 'pricePlusTwo'); + expect(result.rows[arbitraryRowIndex]).toHaveProperty('pricePlusTwo'); + }); + }); + + it('overwrites existing column with the new column if an existing column name is provided', () => { + return runFn(testTable, { name: 'name', expression: pricePlusTwo }).then((result) => { + const nameColumnIndex = result.columns.findIndex(({ name }) => name === 'name'); + const arbitraryRowIndex = 4; + + expect(result.type).toBe('datatable'); + expect(result.columns).toHaveLength(testTable.columns.length); + expect(result.columns[nameColumnIndex]).toHaveProperty('name', 'name'); + expect(result.columns[nameColumnIndex].meta).toHaveProperty('type', 'number'); + expect(result.rows[arbitraryRowIndex]).toHaveProperty('name', 202); + }); + }); + + it('adds a column to empty tables', () => { + return runFn(emptyTable, { name: 'name', expression: pricePlusTwo }).then((result) => { + expect(result.type).toBe('datatable'); + expect(result.columns).toHaveLength(1); + expect(result.columns[0]).toHaveProperty('name', 'name'); + expect(result.columns[0].meta).toHaveProperty('type', 'null'); + }); + }); + + it('should assign specific id, different from name, when id arg is passed for new columns', () => { + return runFn(emptyTable, { name: 'name', id: 'myid', expression: pricePlusTwo }).then( + (result) => { + expect(result.type).toBe('datatable'); + expect(result.columns).toHaveLength(1); + expect(result.columns[0]).toHaveProperty('name', 'name'); + expect(result.columns[0]).toHaveProperty('id', 'myid'); + expect(result.columns[0].meta).toHaveProperty('type', 'null'); + } + ); + }); + + it('should assign specific id, different from name, when id arg is passed for copied column', () => { + return runFn(testTable, { name: 'name', id: 'myid', expression: pricePlusTwo }).then( + (result) => { + const nameColumnIndex = result.columns.findIndex(({ name }) => name === 'name'); + expect(result.type).toBe('datatable'); + expect(result.columns[nameColumnIndex]).toEqual({ + id: 'myid', + name: 'name', + meta: { type: 'number' }, + }); + } + ); + }); + + it('should copy over the meta information from the specified column', () => { + return runFn( + { + ...testTable, + columns: [ + ...testTable.columns, + // add a new entry + { + id: 'myId', + name: 'myName', + meta: { type: 'date', params: { id: 'number', params: { digits: 2 } } }, + }, + ], + rows: testTable.rows.map((row) => ({ ...row, myId: Date.now() })), + }, + { name: 'name', copyMetaFrom: 'myId', expression: pricePlusTwo } + ).then((result) => { + const nameColumnIndex = result.columns.findIndex(({ name }) => name === 'name'); + expect(result.type).toBe('datatable'); + expect(result.columns[nameColumnIndex]).toEqual({ + id: 'name', + name: 'name', + meta: { type: 'date', params: { id: 'number', params: { digits: 2 } } }, + }); + }); + }); + + it('should be resilient if the references column for meta information does not exists', () => { + return runFn(emptyTable, { name: 'name', copyMetaFrom: 'time', expression: pricePlusTwo }).then( + (result) => { + expect(result.type).toBe('datatable'); + expect(result.columns).toHaveLength(1); + expect(result.columns[0]).toHaveProperty('name', 'name'); + expect(result.columns[0]).toHaveProperty('id', 'name'); + expect(result.columns[0].meta).toHaveProperty('type', 'null'); + } + ); + }); + + it('should correctly infer the type fromt he first row if the references column for meta information does not exists', () => { + return runFn( + { ...emptyTable, rows: [...emptyTable.rows, { value: 5 }] }, + { name: 'value', copyMetaFrom: 'time', expression: pricePlusTwo } + ).then((result) => { + expect(result.type).toBe('datatable'); + expect(result.columns).toHaveLength(1); + expect(result.columns[0]).toHaveProperty('name', 'value'); + expect(result.columns[0]).toHaveProperty('id', 'value'); + expect(result.columns[0].meta).toHaveProperty('type', 'number'); + }); + }); + + describe('expression', () => { + it('maps null values to the new column', () => { + return runFn(testTable, { name: 'empty' }).then((result) => { + const emptyColumnIndex = result.columns.findIndex(({ name }) => name === 'empty'); + const arbitraryRowIndex = 8; + + expect(result.columns[emptyColumnIndex]).toHaveProperty('name', 'empty'); + expect(result.columns[emptyColumnIndex].meta).toHaveProperty('type', 'null'); + expect(result.rows[arbitraryRowIndex]).toHaveProperty('empty', null); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.test.js b/src/plugins/expressions/common/expression_functions/specs/tests/math.test.ts similarity index 63% rename from x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.test.js rename to src/plugins/expressions/common/expression_functions/specs/tests/math.test.ts index f5b8123ab8568..7541852cdbdaf 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.test.js +++ b/src/plugins/expressions/common/expression_functions/specs/tests/math.test.ts @@ -1,19 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; -import { getFunctionErrors } from '../../../i18n'; -import { emptyTable, testTable } from './__fixtures__/test_tables'; -import { math } from './math'; - -const errors = getFunctionErrors().math; +import { errors, math } from '../math'; +import { emptyTable, functionWrapper, testTable } from './utils'; describe('math', () => { - const fn = functionWrapper(math); + const fn = functionWrapper(math); it('evaluates math expressions without reference to context', () => { expect(fn(null, { expression: '10.5345' })).toBe(10.5345); @@ -48,6 +45,19 @@ describe('math', () => { expect(fn(testTable, { expression: 'count(name)' })).toBe(9); }); }); + + describe('onError', () => { + it('should return the desired fallback value, for invalid expressions', () => { + expect(fn(testTable, { expression: 'mean(name)', onError: 'zero' })).toBe(0); + expect(fn(testTable, { expression: 'mean(name)', onError: 'null' })).toBe(null); + expect(fn(testTable, { expression: 'mean(name)', onError: 'false' })).toBe(false); + }); + it('should return the desired fallback value, for division by zero', () => { + expect(fn(testTable, { expression: '1/0', onError: 'zero' })).toBe(0); + expect(fn(testTable, { expression: '1/0', onError: 'null' })).toBe(null); + expect(fn(testTable, { expression: '1/0', onError: 'false' })).toBe(false); + }); + }); }); describe('invalid expressions', () => { @@ -88,5 +98,23 @@ describe('math', () => { new RegExp(errors.emptyDatatable().message) ); }); + + it('should not throw when requesting fallback values for invalid expression', () => { + expect(() => fn(testTable, { expression: 'mean(name)', onError: 'zero' })).not.toThrow(); + expect(() => fn(testTable, { expression: 'mean(name)', onError: 'false' })).not.toThrow(); + expect(() => fn(testTable, { expression: 'mean(name)', onError: 'null' })).not.toThrow(); + }); + + it('should throw when declared in the onError argument', () => { + expect(() => fn(testTable, { expression: 'mean(name)', onError: 'throw' })).toThrow( + new RegExp(errors.executionFailed().message) + ); + }); + + it('should throw when dividing by zero', () => { + expect(() => fn(testTable, { expression: '1/0', onError: 'throw' })).toThrow( + new RegExp('Cannot divide by 0') + ); + }); }); }); diff --git a/src/plugins/expressions/common/expression_functions/specs/tests/utils.ts b/src/plugins/expressions/common/expression_functions/specs/tests/utils.ts index 9006de9067616..7369570cf2c4b 100644 --- a/src/plugins/expressions/common/expression_functions/specs/tests/utils.ts +++ b/src/plugins/expressions/common/expression_functions/specs/tests/utils.ts @@ -9,16 +9,219 @@ import { mapValues } from 'lodash'; import { AnyExpressionFunctionDefinition } from '../../types'; import { ExecutionContext } from '../../../execution/types'; +import { Datatable } from '../../../expression_types'; /** * Takes a function spec and passes in default args, * overriding with any provided args. */ -export const functionWrapper = (spec: AnyExpressionFunctionDefinition) => { +export const functionWrapper = ( + spec: AnyExpressionFunctionDefinition +) => { const defaultArgs = mapValues(spec.args, (argSpec) => argSpec.default); return ( - context: object | null, + context: ContextType, args: Record = {}, handlers: ExecutionContext = {} as ExecutionContext ) => spec.fn(context, { ...defaultArgs, ...args }, handlers); }; + +const emptyTable: Datatable = { + type: 'datatable', + columns: [], + rows: [], +}; + +const testTable: Datatable = { + type: 'datatable', + columns: [ + { + id: 'name', + name: 'name', + meta: { type: 'string' }, + }, + { + id: 'time', + name: 'time', + meta: { type: 'date' }, + }, + { + id: 'price', + name: 'price', + meta: { type: 'number' }, + }, + { + id: 'quantity', + name: 'quantity', + meta: { type: 'number' }, + }, + { + id: 'in_stock', + name: 'in_stock', + meta: { type: 'boolean' }, + }, + ], + rows: [ + { + name: 'product1', + time: 1517842800950, // 05 Feb 2018 15:00:00 GMT + price: 605, + quantity: 100, + in_stock: true, + }, + { + name: 'product1', + time: 1517929200950, // 06 Feb 2018 15:00:00 GMT + price: 583, + quantity: 200, + in_stock: true, + }, + { + name: 'product1', + time: 1518015600950, // 07 Feb 2018 15:00:00 GMT + price: 420, + quantity: 300, + in_stock: true, + }, + { + name: 'product2', + time: 1517842800950, // 05 Feb 2018 15:00:00 GMT + price: 216, + quantity: 350, + in_stock: false, + }, + { + name: 'product2', + time: 1517929200950, // 06 Feb 2018 15:00:00 GMT + price: 200, + quantity: 256, + in_stock: false, + }, + { + name: 'product2', + time: 1518015600950, // 07 Feb 2018 15:00:00 GMT + price: 190, + quantity: 231, + in_stock: false, + }, + { + name: 'product3', + time: 1517842800950, // 05 Feb 2018 15:00:00 GMT + price: 67, + quantity: 240, + in_stock: true, + }, + { + name: 'product4', + time: 1517842800950, // 05 Feb 2018 15:00:00 GMT + price: 311, + quantity: 447, + in_stock: false, + }, + { + name: 'product5', + time: 1517842800950, // 05 Feb 2018 15:00:00 GMT + price: 288, + quantity: 384, + in_stock: true, + }, + ], +}; + +const stringTable: Datatable = { + type: 'datatable', + columns: [ + { + id: 'name', + name: 'name', + meta: { type: 'string' }, + }, + { + id: 'time', + name: 'time', + meta: { type: 'string' }, + }, + { + id: 'price', + name: 'price', + meta: { type: 'string' }, + }, + { + id: 'quantity', + name: 'quantity', + meta: { type: 'string' }, + }, + { + id: 'in_stock', + name: 'in_stock', + meta: { type: 'string' }, + }, + ], + rows: [ + { + name: 'product1', + time: '2018-02-05T15:00:00.950Z', + price: '605', + quantity: '100', + in_stock: 'true', + }, + { + name: 'product1', + time: '2018-02-06T15:00:00.950Z', + price: '583', + quantity: '200', + in_stock: 'true', + }, + { + name: 'product1', + time: '2018-02-07T15:00:00.950Z', + price: '420', + quantity: '300', + in_stock: 'true', + }, + { + name: 'product2', + time: '2018-02-05T15:00:00.950Z', + price: '216', + quantity: '350', + in_stock: 'false', + }, + { + name: 'product2', + time: '2018-02-06T15:00:00.950Z', + price: '200', + quantity: '256', + in_stock: 'false', + }, + { + name: 'product2', + time: '2018-02-07T15:00:00.950Z', + price: '190', + quantity: '231', + in_stock: 'false', + }, + { + name: 'product3', + time: '2018-02-05T15:00:00.950Z', + price: '67', + quantity: '240', + in_stock: 'true', + }, + { + name: 'product4', + time: '2018-02-05T15:00:00.950Z', + price: '311', + quantity: '447', + in_stock: 'false', + }, + { + name: 'product5', + time: '2018-02-05T15:00:00.950Z', + price: '288', + quantity: '384', + in_stock: 'true', + }, + ], +}; + +export { emptyTable, testTable, stringTable }; diff --git a/src/plugins/vis_type_xy/server/plugin.ts b/src/plugins/vis_type_xy/server/plugin.ts index fd670e288ff5b..a9e6020cf3ee8 100644 --- a/src/plugins/vis_type_xy/server/plugin.ts +++ b/src/plugins/vis_type_xy/server/plugin.ts @@ -20,6 +20,7 @@ export const uiSettingsConfig: Record> = { name: i18n.translate('visTypeXy.advancedSettings.visualization.legacyChartsLibrary.name', { defaultMessage: 'Legacy charts library', }), + requiresPageReload: true, value: false, description: i18n.translate( 'visTypeXy.advancedSettings.visualization.legacyChartsLibrary.description', diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts index 048bc3468f149..5c4d1d55cff04 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts @@ -34,8 +34,6 @@ import { joinRows } from './join_rows'; import { lt } from './lt'; import { lte } from './lte'; import { mapCenter } from './map_center'; -import { mapColumn } from './mapColumn'; -import { math } from './math'; import { metric } from './metric'; import { neq } from './neq'; import { ply } from './ply'; @@ -89,8 +87,6 @@ export const functions = [ lte, joinRows, mapCenter, - mapColumn, - math, metric, neq, ply, diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.test.js deleted file mode 100644 index d511c7774122d..0000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.test.js +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { functionWrapper } from '../../../test_helpers/function_wrapper'; -import { testTable, emptyTable } from './__fixtures__/test_tables'; -import { mapColumn } from './mapColumn'; - -const pricePlusTwo = (datatable) => Promise.resolve(datatable.rows[0].price + 2); - -describe('mapColumn', () => { - const fn = functionWrapper(mapColumn); - - it('returns a datatable with a new column with the values from mapping a function over each row in a datatable', () => { - return fn(testTable, { - id: 'pricePlusTwo', - name: 'pricePlusTwo', - expression: pricePlusTwo, - }).then((result) => { - const arbitraryRowIndex = 2; - - expect(result.type).toBe('datatable'); - expect(result.columns).toEqual([ - ...testTable.columns, - { id: 'pricePlusTwo', name: 'pricePlusTwo', meta: { type: 'number' } }, - ]); - expect(result.columns[result.columns.length - 1]).toHaveProperty('name', 'pricePlusTwo'); - expect(result.rows[arbitraryRowIndex]).toHaveProperty('pricePlusTwo'); - }); - }); - - it('overwrites existing column with the new column if an existing column name is provided', () => { - return fn(testTable, { name: 'name', expression: pricePlusTwo }).then((result) => { - const nameColumnIndex = result.columns.findIndex(({ name }) => name === 'name'); - const arbitraryRowIndex = 4; - - expect(result.type).toBe('datatable'); - expect(result.columns).toHaveLength(testTable.columns.length); - expect(result.columns[nameColumnIndex]).toHaveProperty('name', 'name'); - expect(result.columns[nameColumnIndex].meta).toHaveProperty('type', 'number'); - expect(result.rows[arbitraryRowIndex]).toHaveProperty('name', 202); - }); - }); - - it('adds a column to empty tables', () => { - return fn(emptyTable, { name: 'name', expression: pricePlusTwo }).then((result) => { - expect(result.type).toBe('datatable'); - expect(result.columns).toHaveLength(1); - expect(result.columns[0]).toHaveProperty('name', 'name'); - expect(result.columns[0].meta).toHaveProperty('type', 'null'); - }); - }); - - describe('expression', () => { - it('maps null values to the new column', () => { - return fn(testTable, { name: 'empty' }).then((result) => { - const emptyColumnIndex = result.columns.findIndex(({ name }) => name === 'empty'); - const arbitraryRowIndex = 8; - - expect(result.columns[emptyColumnIndex]).toHaveProperty('name', 'empty'); - expect(result.columns[emptyColumnIndex].meta).toHaveProperty('type', 'null'); - expect(result.rows[arbitraryRowIndex]).toHaveProperty('empty', null); - }); - }); - }); -}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.ts deleted file mode 100644 index 63cc0d6cbc687..0000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { Datatable, ExpressionFunctionDefinition, getType } from '../../../types'; -import { getFunctionHelp } from '../../../i18n'; - -interface Arguments { - name: string; - expression: (datatable: Datatable) => Promise; -} - -export function mapColumn(): ExpressionFunctionDefinition< - 'mapColumn', - Datatable, - Arguments, - Promise -> { - const { help, args: argHelp } = getFunctionHelp().mapColumn; - - return { - name: 'mapColumn', - aliases: ['mc'], // midnight commander. So many times I've launched midnight commander instead of moving a file. - type: 'datatable', - inputTypes: ['datatable'], - help, - args: { - name: { - types: ['string'], - aliases: ['_', 'column'], - help: argHelp.name, - required: true, - }, - expression: { - types: ['boolean', 'number', 'string', 'null'], - resolve: false, - aliases: ['exp', 'fn', 'function'], - help: argHelp.expression, - required: true, - }, - }, - fn: (input, args) => { - const expression = args.expression || (() => Promise.resolve(null)); - - const columns = [...input.columns]; - const rowPromises = input.rows.map((row) => { - return expression({ - type: 'datatable', - columns, - rows: [row], - }).then((val) => ({ - ...row, - [args.name]: val, - })); - }); - - return Promise.all(rowPromises).then((rows) => { - const existingColumnIndex = columns.findIndex(({ name }) => name === args.name); - const type = rows.length ? getType(rows[0][args.name]) : 'null'; - const newColumn = { id: args.name, name: args.name, meta: { type } }; - - if (existingColumnIndex === -1) { - columns.push(newColumn); - } else { - columns[existingColumnIndex] = newColumn; - } - - return { - type: 'datatable', - columns, - rows, - } as Datatable; - }); - }, - }; -} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.ts deleted file mode 100644 index af70fa729b7da..0000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { evaluate } from '@kbn/tinymath'; -import { pivotObjectArray } from '../../../common/lib/pivot_object_array'; -import { Datatable, isDatatable, ExpressionFunctionDefinition } from '../../../types'; -import { getFunctionHelp, getFunctionErrors } from '../../../i18n'; - -interface Arguments { - expression: string; -} - -type Input = number | Datatable; - -export function math(): ExpressionFunctionDefinition<'math', Input, Arguments, number> { - const { help, args: argHelp } = getFunctionHelp().math; - const errors = getFunctionErrors().math; - - return { - name: 'math', - type: 'number', - inputTypes: ['number', 'datatable'], - help, - args: { - expression: { - aliases: ['_'], - types: ['string'], - help: argHelp.expression, - }, - }, - fn: (input, args) => { - const { expression } = args; - - if (!expression || expression.trim() === '') { - throw errors.emptyExpression(); - } - - const mathContext = isDatatable(input) - ? pivotObjectArray( - input.rows, - input.columns.map((col) => col.name) - ) - : { value: input }; - - try { - const result = evaluate(expression, mathContext); - if (Array.isArray(result)) { - if (result.length === 1) { - return result[0]; - } - throw errors.tooManyResults(); - } - if (isNaN(result)) { - throw errors.executionFailed(); - } - return result; - } catch (e) { - if (isDatatable(input) && input.rows.length === 0) { - throw errors.emptyDatatable(); - } else { - throw e; - } - } - }, - }; -} diff --git a/x-pack/plugins/canvas/i18n/functions/dict/map_column.ts b/x-pack/plugins/canvas/i18n/functions/dict/map_column.ts deleted file mode 100644 index f8d0311d08961..0000000000000 --- a/x-pack/plugins/canvas/i18n/functions/dict/map_column.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; -import { mapColumn } from '../../../canvas_plugin_src/functions/common/mapColumn'; -import { FunctionHelp } from '../function_help'; -import { FunctionFactory } from '../../../types'; -import { CANVAS, DATATABLE } from '../../constants'; - -export const help: FunctionHelp> = { - help: i18n.translate('xpack.canvas.functions.mapColumnHelpText', { - defaultMessage: - 'Adds a column calculated as the result of other columns. ' + - 'Changes are made only when you provide arguments.' + - 'See also {alterColumnFn} and {staticColumnFn}.', - values: { - alterColumnFn: '`alterColumn`', - staticColumnFn: '`staticColumn`', - }, - }), - args: { - name: i18n.translate('xpack.canvas.functions.mapColumn.args.nameHelpText', { - defaultMessage: 'The name of the resulting column.', - }), - expression: i18n.translate('xpack.canvas.functions.mapColumn.args.expressionHelpText', { - defaultMessage: - 'A {CANVAS} expression that is passed to each row as a single row {DATATABLE}.', - values: { - CANVAS, - DATATABLE, - }, - }), - }, -}; diff --git a/x-pack/plugins/canvas/i18n/functions/dict/math.ts b/x-pack/plugins/canvas/i18n/functions/dict/math.ts deleted file mode 100644 index 110136872e1ff..0000000000000 --- a/x-pack/plugins/canvas/i18n/functions/dict/math.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; -import { math } from '../../../canvas_plugin_src/functions/common/math'; -import { FunctionHelp } from '../function_help'; -import { FunctionFactory } from '../../../types'; -import { DATATABLE, CONTEXT, TINYMATH, TINYMATH_URL, TYPE_NUMBER } from '../../constants'; - -export const help: FunctionHelp> = { - help: i18n.translate('xpack.canvas.functions.mathHelpText', { - defaultMessage: - 'Interprets a {TINYMATH} math expression using a {TYPE_NUMBER} or {DATATABLE} as {CONTEXT}. ' + - 'The {DATATABLE} columns are available by their column name. ' + - 'If the {CONTEXT} is a number it is available as {value}.', - values: { - TINYMATH, - CONTEXT, - DATATABLE, - value: '`value`', - TYPE_NUMBER, - }, - }), - args: { - expression: i18n.translate('xpack.canvas.functions.math.args.expressionHelpText', { - defaultMessage: 'An evaluated {TINYMATH} expression. See {TINYMATH_URL}.', - values: { - TINYMATH, - TINYMATH_URL, - }, - }), - }, -}; - -export const errors = { - emptyExpression: () => - new Error( - i18n.translate('xpack.canvas.functions.math.emptyExpressionErrorMessage', { - defaultMessage: 'Empty expression', - }) - ), - tooManyResults: () => - new Error( - i18n.translate('xpack.canvas.functions.math.tooManyResultsErrorMessage', { - defaultMessage: - 'Expressions must return a single number. Try wrapping your expression in {mean} or {sum}', - values: { - mean: 'mean()', - sum: 'sum()', - }, - }) - ), - executionFailed: () => - new Error( - i18n.translate('xpack.canvas.functions.math.executionFailedErrorMessage', { - defaultMessage: 'Failed to execute math expression. Check your column names', - }) - ), - emptyDatatable: () => - new Error( - i18n.translate('xpack.canvas.functions.math.emptyDatatableErrorMessage', { - defaultMessage: 'Empty datatable', - }) - ), -}; diff --git a/x-pack/plugins/canvas/i18n/functions/function_errors.ts b/x-pack/plugins/canvas/i18n/functions/function_errors.ts index ac86eb4c4d0e9..4a85018c1b4ac 100644 --- a/x-pack/plugins/canvas/i18n/functions/function_errors.ts +++ b/x-pack/plugins/canvas/i18n/functions/function_errors.ts @@ -16,7 +16,6 @@ import { errors as demodata } from './dict/demodata'; import { errors as getCell } from './dict/get_cell'; import { errors as image } from './dict/image'; import { errors as joinRows } from './dict/join_rows'; -import { errors as math } from './dict/math'; import { errors as ply } from './dict/ply'; import { errors as pointseries } from './dict/pointseries'; import { errors as progress } from './dict/progress'; @@ -36,7 +35,6 @@ export const getFunctionErrors = () => ({ getCell, image, joinRows, - math, ply, pointseries, progress, diff --git a/x-pack/plugins/canvas/i18n/functions/function_help.ts b/x-pack/plugins/canvas/i18n/functions/function_help.ts index 245732e53cc89..512ebc4ff8c93 100644 --- a/x-pack/plugins/canvas/i18n/functions/function_help.ts +++ b/x-pack/plugins/canvas/i18n/functions/function_help.ts @@ -46,9 +46,7 @@ import { help as location } from './dict/location'; import { help as lt } from './dict/lt'; import { help as lte } from './dict/lte'; import { help as mapCenter } from './dict/map_center'; -import { help as mapColumn } from './dict/map_column'; import { help as markdown } from './dict/markdown'; -import { help as math } from './dict/math'; import { help as metric } from './dict/metric'; import { help as neq } from './dict/neq'; import { help as pie } from './dict/pie'; @@ -209,9 +207,7 @@ export const getFunctionHelp = (): FunctionHelpDict => ({ lt, lte, mapCenter, - mapColumn, markdown, - math, metric, neq, pie, diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx index a9845c2315604..53871eae6dba1 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx @@ -183,6 +183,13 @@ export const setup = async (arg?: { const enable = (phase: Phases) => createFormToggleAction(`enablePhaseSwitch-${phase}`); + const showDataAllocationOptions = (phase: Phases) => () => { + act(() => { + find(`${phase}-dataTierAllocationControls.dataTierSelect`).simulate('click'); + }); + component.update(); + }; + const createMinAgeActions = (phase: Phases) => { return { hasMinAgeInput: () => exists(`${phase}-selectedMinimumAge`), @@ -379,6 +386,7 @@ export const setup = async (arg?: { }, warm: { enable: enable('warm'), + showDataAllocationOptions: showDataAllocationOptions('warm'), ...createMinAgeActions('warm'), setReplicas: setReplicas('warm'), hasErrorIndicator: () => exists('phaseErrorIndicator-warm'), @@ -390,6 +398,7 @@ export const setup = async (arg?: { }, cold: { enable: enable('cold'), + showDataAllocationOptions: showDataAllocationOptions('cold'), ...createMinAgeActions('cold'), setReplicas: setReplicas('cold'), setFreeze, diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts index 7fe5c6f50d046..ffa9b1aa236a0 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts @@ -693,43 +693,52 @@ describe('', () => { expect(find('cold-dataTierAllocationControls.dataTierSelect').text()).toContain('Off'); }); }); - }); - - describe('searchable snapshot', () => { describe('on cloud', () => { - describe('new policy', () => { + describe('using legacy data role config', () => { beforeEach(async () => { - // simulate creating a new policy - httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('')]); + httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('my_policy')]); httpRequestsMockHelpers.setListNodes({ - isUsingDeprecatedDataRoleConfig: false, nodesByAttributes: { test: ['123'] }, - nodesByRoles: { data: ['123'] }, + // On cloud, even if there are data_* roles set, the default, recommended allocation option should not + // be available. + nodesByRoles: { data_hot: ['123'] }, + isUsingDeprecatedDataRoleConfig: true, }); httpRequestsMockHelpers.setListSnapshotRepos({ repositories: ['found-snapshots'] }); await act(async () => { - testBed = await setup({ appServicesContext: { cloud: { isCloudEnabled: true } } }); + testBed = await setup({ + appServicesContext: { + cloud: { + isCloudEnabled: true, + }, + license: licensingMock.createLicense({ license: { type: 'basic' } }), + }, + }); }); const { component } = testBed; component.update(); }); - test('defaults searchable snapshot to true on cloud', async () => { - const { find, actions } = testBed; - await actions.cold.enable(true); - expect( - find('searchableSnapshotField-cold.searchableSnapshotToggle').props()['aria-checked'] - ).toBe(true); + test('removes default, recommended option', async () => { + const { actions, find } = testBed; + await actions.warm.enable(true); + actions.warm.showDataAllocationOptions(); + + expect(find('defaultDataAllocationOption').exists()).toBeFalsy(); + expect(find('customDataAllocationOption').exists()).toBeTruthy(); + expect(find('noneDataAllocationOption').exists()).toBeTruthy(); + // Show the call-to-action for users to migrate their cluster to use node roles + expect(find('cloudDataTierCallout').exists()).toBeTruthy(); }); }); - describe('existing policy', () => { + describe('using node roles', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('my_policy')]); httpRequestsMockHelpers.setListNodes({ - isUsingDeprecatedDataRoleConfig: false, nodesByAttributes: { test: ['123'] }, - nodesByRoles: { data: ['123'] }, + nodesByRoles: { data_hot: ['123'] }, + isUsingDeprecatedDataRoleConfig: false, }); httpRequestsMockHelpers.setListSnapshotRepos({ repositories: ['found-snapshots'] }); @@ -740,19 +749,34 @@ describe('', () => { const { component } = testBed; component.update(); }); - test('correctly sets snapshot repository default to "found-snapshots"', async () => { - const { actions } = testBed; + + test('should show recommended, custom and "off" options on cloud with data roles', async () => { + const { actions, find } = testBed; + + await actions.warm.enable(true); + actions.warm.showDataAllocationOptions(); + expect(find('defaultDataAllocationOption').exists()).toBeTruthy(); + expect(find('customDataAllocationOption').exists()).toBeTruthy(); + expect(find('noneDataAllocationOption').exists()).toBeTruthy(); + // We should not be showing the call-to-action for users to activate the cold tier on cloud + expect(find('cloudMissingColdTierCallout').exists()).toBeFalsy(); + // Do not show the call-to-action for users to migrate their cluster to use node roles + expect(find('cloudDataTierCallout').exists()).toBeFalsy(); + }); + + test('should show cloud notice when cold tier nodes do not exist', async () => { + const { actions, find } = testBed; await actions.cold.enable(true); - await actions.cold.toggleSearchableSnapshot(true); - await actions.savePolicy(); - const latestRequest = server.requests[server.requests.length - 1]; - const request = JSON.parse(JSON.parse(latestRequest.requestBody).body); - expect(request.phases.cold.actions.searchable_snapshot.snapshot_repository).toEqual( - 'found-snapshots' - ); + expect(find('cloudMissingColdTierCallout').exists()).toBeTruthy(); + // Assert that other notices are not showing + expect(find('defaultAllocationNotice').exists()).toBeFalsy(); + expect(find('noNodeAttributesWarning').exists()).toBeFalsy(); }); }); }); + }); + + describe('searchable snapshot', () => { describe('on non-enterprise license', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('my_policy')]); @@ -789,6 +813,64 @@ describe('', () => { expect(actions.cold.searchableSnapshotDisabledDueToLicense()).toBeTruthy(); }); }); + + describe('on cloud', () => { + describe('new policy', () => { + beforeEach(async () => { + // simulate creating a new policy + httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('')]); + httpRequestsMockHelpers.setListNodes({ + nodesByAttributes: { test: ['123'] }, + nodesByRoles: { data: ['123'] }, + isUsingDeprecatedDataRoleConfig: false, + }); + httpRequestsMockHelpers.setListSnapshotRepos({ repositories: ['found-snapshots'] }); + + await act(async () => { + testBed = await setup({ appServicesContext: { cloud: { isCloudEnabled: true } } }); + }); + + const { component } = testBed; + component.update(); + }); + test('defaults searchable snapshot to true on cloud', async () => { + const { find, actions } = testBed; + await actions.cold.enable(true); + expect( + find('searchableSnapshotField-cold.searchableSnapshotToggle').props()['aria-checked'] + ).toBe(true); + }); + }); + describe('existing policy', () => { + beforeEach(async () => { + httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('my_policy')]); + httpRequestsMockHelpers.setListNodes({ + isUsingDeprecatedDataRoleConfig: false, + nodesByAttributes: { test: ['123'] }, + nodesByRoles: { data_hot: ['123'] }, + }); + httpRequestsMockHelpers.setListSnapshotRepos({ repositories: ['found-snapshots'] }); + + await act(async () => { + testBed = await setup({ appServicesContext: { cloud: { isCloudEnabled: true } } }); + }); + + const { component } = testBed; + component.update(); + }); + test('correctly sets snapshot repository default to "found-snapshots"', async () => { + const { actions } = testBed; + await actions.cold.enable(true); + await actions.cold.toggleSearchableSnapshot(true); + await actions.savePolicy(); + const latestRequest = server.requests[server.requests.length - 1]; + const request = JSON.parse(JSON.parse(latestRequest.requestBody).body); + expect(request.phases.cold.actions.searchable_snapshot.snapshot_repository).toEqual( + 'found-snapshots' + ); + }); + }); + }); }); describe('with rollover', () => { beforeEach(async () => { diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/reactive_form/node_allocation.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/reactive_form/node_allocation.test.ts index 113698fdf6df2..b02d190d10899 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/reactive_form/node_allocation.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/reactive_form/node_allocation.test.ts @@ -125,7 +125,7 @@ describe(' node allocation', () => { expect(actions.warm.hasDefaultAllocationWarning()).toBeTruthy(); }); - test('shows default allocation notice when hot tier exists, but not warm tier', async () => { + test('when configuring warm phase shows default allocation notice when hot tier exists, but not warm tier', async () => { httpRequestsMockHelpers.setListNodes({ nodesByAttributes: {}, nodesByRoles: { data_hot: ['test'], data_cold: ['test'] }, @@ -309,7 +309,7 @@ describe(' node allocation', () => { describe('on cloud', () => { describe('with deprecated data role config', () => { - test('should hide data tier option on cloud using legacy node role configuration', async () => { + test('should hide data tier option on cloud', async () => { httpRequestsMockHelpers.setListNodes({ nodesByAttributes: { test: ['123'] }, // On cloud, if using legacy config there will not be any "data_*" roles set. @@ -331,10 +331,29 @@ describe(' node allocation', () => { expect(exists('customDataAllocationOption')).toBeTruthy(); expect(exists('noneDataAllocationOption')).toBeTruthy(); }); + + test('should ask users to migrate to node roles when on cloud using legacy data role', async () => { + httpRequestsMockHelpers.setListNodes({ + nodesByAttributes: { test: ['123'] }, + // On cloud, if using legacy config there will not be any "data_*" roles set. + nodesByRoles: { data: ['test'] }, + isUsingDeprecatedDataRoleConfig: true, + }); + await act(async () => { + testBed = await setup({ appServicesContext: { cloud: { isCloudEnabled: true } } }); + }); + const { actions, component, exists } = testBed; + + component.update(); + await actions.warm.enable(true); + expect(component.find('.euiLoadingSpinner').exists()).toBeFalsy(); + + expect(exists('cloudDataTierCallout')).toBeTruthy(); + }); }); describe('with node role config', () => { - test('shows off, custom and data role options on cloud with data roles', async () => { + test('shows data role, custom and "off" options on cloud with data roles', async () => { httpRequestsMockHelpers.setListNodes({ nodesByAttributes: { test: ['123'] }, nodesByRoles: { data: ['test'], data_hot: ['test'], data_warm: ['test'] }, @@ -372,7 +391,7 @@ describe(' node allocation', () => { await actions.cold.enable(true); expect(component.find('.euiLoadingSpinner').exists()).toBeFalsy(); - expect(exists('cloudDataTierCallout')).toBeTruthy(); + expect(exists('cloudMissingColdTierCallout')).toBeTruthy(); // Assert that other notices are not showing expect(actions.cold.hasDefaultAllocationNotice()).toBeFalsy(); expect(actions.cold.hasNoNodeAttrsWarning()).toBeFalsy(); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/cloud_data_tier_callout.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/cloud_data_tier_callout.tsx index 4d3dbbba39037..351d6ac1c530b 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/cloud_data_tier_callout.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/cloud_data_tier_callout.tsx @@ -7,21 +7,38 @@ import { i18n } from '@kbn/i18n'; import React, { FunctionComponent } from 'react'; -import { EuiCallOut } from '@elastic/eui'; +import { EuiCallOut, EuiLink } from '@elastic/eui'; const i18nTexts = { title: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.cloudDataTierCallout.title', { - defaultMessage: 'Create a cold tier', + defaultMessage: 'Migrate to data tiers', }), body: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.cloudDataTierCallout.body', { - defaultMessage: 'Edit your Elastic Cloud deployment to set up a cold tier.', + defaultMessage: 'Migrate your Elastic Cloud deployment to use data tiers.', }), + linkText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.cloudDataTierCallout.linkToCloudDeploymentDescription', + { defaultMessage: 'View cloud deployment' } + ), }; -export const CloudDataTierCallout: FunctionComponent = () => { +interface Props { + linkToCloudDeployment?: string; +} + +/** + * A call-to-action for users to migrate to data tiers if their cluster is still running + * the deprecated node.data:true config. + */ +export const CloudDataTierCallout: FunctionComponent = ({ linkToCloudDeployment }) => { return ( - {i18nTexts.body} + {i18nTexts.body}{' '} + {Boolean(linkToCloudDeployment) && ( + + {i18nTexts.linkText} + + )} ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/default_allocation_notice.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/default_allocation_notice.tsx index 562267089051a..e43b750849774 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/default_allocation_notice.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/default_allocation_notice.tsx @@ -11,8 +11,6 @@ import { EuiCallOut } from '@elastic/eui'; import { PhaseWithAllocation, DataTierRole } from '../../../../../../../../../common/types'; -import { AllocationNodeRole } from '../../../../../../../lib'; - const i18nTextsNodeRoleToDataTier: Record = { data_hot: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.dataTierHotLabel', { defaultMessage: 'hot', @@ -84,24 +82,13 @@ const i18nTexts = { interface Props { phase: PhaseWithAllocation; - targetNodeRole: AllocationNodeRole; + targetNodeRole: DataTierRole; } export const DefaultAllocationNotice: FunctionComponent = ({ phase, targetNodeRole }) => { - const content = - targetNodeRole === 'none' ? ( - - {i18nTexts.warning[phase].body} - - ) : ( - - {i18nTexts.notice[phase].body(targetNodeRole)} - - ); - - return content; + return ( + + {i18nTexts.notice[phase].body(targetNodeRole)} + + ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/default_allocation_warning.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/default_allocation_warning.tsx new file mode 100644 index 0000000000000..a194f3c07f900 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/default_allocation_warning.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import React, { FunctionComponent } from 'react'; +import { EuiCallOut } from '@elastic/eui'; + +import { PhaseWithAllocation } from '../../../../../../../../../common/types'; + +const i18nTexts = { + warning: { + warm: { + title: i18n.translate( + 'xpack.indexLifecycleMgmt.warmPhase.dataTier.defaultAllocationNotAvailableTitle', + { defaultMessage: 'No nodes assigned to the warm tier' } + ), + body: i18n.translate( + 'xpack.indexLifecycleMgmt.warmPhase.dataTier.defaultAllocationNotAvailableBody', + { + defaultMessage: + 'Assign at least one node to the warm or hot tier to use role-based allocation. The policy will fail to complete allocation if there are no available nodes.', + } + ), + }, + cold: { + title: i18n.translate( + 'xpack.indexLifecycleMgmt.coldPhase.dataTier.defaultAllocationNotAvailableTitle', + { defaultMessage: 'No nodes assigned to the cold tier' } + ), + body: i18n.translate( + 'xpack.indexLifecycleMgmt.coldPhase.dataTier.defaultAllocationNotAvailableBody', + { + defaultMessage: + 'Assign at least one node to the cold, warm, or hot tier to use role-based allocation. The policy will fail to complete allocation if there are no available nodes.', + } + ), + }, + }, +}; + +interface Props { + phase: PhaseWithAllocation; +} + +export const DefaultAllocationWarning: FunctionComponent = ({ phase }) => { + return ( + + {i18nTexts.warning[phase].body} + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/index.ts index b3f57ac24e0d7..938e0a850f933 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/index.ts @@ -13,8 +13,12 @@ export { DataTierAllocation } from './data_tier_allocation'; export { DefaultAllocationNotice } from './default_allocation_notice'; +export { DefaultAllocationWarning } from './default_allocation_warning'; + export { NoNodeAttributesWarning } from './no_node_attributes_warning'; +export { MissingColdTierCallout } from './missing_cold_tier_callout'; + export { CloudDataTierCallout } from './cloud_data_tier_callout'; export { LoadingError } from './loading_error'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/missing_cold_tier_callout.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/missing_cold_tier_callout.tsx new file mode 100644 index 0000000000000..21b8850e0b088 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/missing_cold_tier_callout.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import React, { FunctionComponent } from 'react'; +import { EuiCallOut, EuiLink } from '@elastic/eui'; + +const i18nTexts = { + title: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.cloudMissingColdTierCallout.title', { + defaultMessage: 'Create a cold tier', + }), + body: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.cloudMissingColdTierCallout.body', { + defaultMessage: 'Edit your Elastic Cloud deployment to set up a cold tier.', + }), + linkText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.cloudMissingColdTierCallout.linkToCloudDeploymentDescription', + { defaultMessage: 'View cloud deployment' } + ), +}; + +interface Props { + linkToCloudDeployment?: string; +} + +/** + * A call-to-action for users to activate their cold tier slider to provision cold tier nodes. + * This may need to be change when we have autoscaling enabled on a cluster because nodes may not + * yet exist, but will automatically be provisioned. + */ +export const MissingColdTierCallout: FunctionComponent = ({ linkToCloudDeployment }) => { + return ( + + {i18nTexts.body}{' '} + {Boolean(linkToCloudDeployment) && ( + + {i18nTexts.linkText} + + )} + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/data_tier_allocation_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/data_tier_allocation_field.tsx index ad36039728f5c..7a660e0379a8d 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/data_tier_allocation_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/data_tier_allocation_field.tsx @@ -14,9 +14,7 @@ import { useKibana, useFormData } from '../../../../../../../shared_imports'; import { PhaseWithAllocation } from '../../../../../../../../common/types'; -import { getAvailableNodeRoleForPhase } from '../../../../../../lib/data_tiers'; - -import { isNodeRoleFirstPreference } from '../../../../../../lib'; +import { getAvailableNodeRoleForPhase, isNodeRoleFirstPreference } from '../../../../../../lib'; import { useLoadNodes } from '../../../../../../services/api'; @@ -25,7 +23,9 @@ import { DataTierAllocationType } from '../../../../types'; import { DataTierAllocation, DefaultAllocationNotice, + DefaultAllocationWarning, NoNodeAttributesWarning, + MissingColdTierCallout, CloudDataTierCallout, LoadingError, } from './components'; @@ -65,30 +65,48 @@ export const DataTierAllocationField: FunctionComponent = ({ phase, descr ); const hasNodeAttrs = Boolean(Object.keys(nodesByAttributes ?? {}).length); const isCloudEnabled = cloud?.isCloudEnabled ?? false; + const cloudDeploymentUrl = cloud?.cloudDeploymentUrl; const renderNotice = () => { switch (allocationType) { case 'node_roles': - if (isCloudEnabled && phase === 'cold') { - const isUsingNodeRolesAllocation = !isUsingDeprecatedDataRoleConfig && hasDataNodeRoles; + /** + * We'll drive Cloud users to add a cold tier to their deployment if there are no nodes with the cold node role. + */ + if (isCloudEnabled && phase === 'cold' && !isUsingDeprecatedDataRoleConfig) { const hasNoNodesWithNodeRole = !nodesByRoles.data_cold?.length; - if (isUsingNodeRolesAllocation && hasNoNodesWithNodeRole) { + if (hasDataNodeRoles && hasNoNodesWithNodeRole) { // Tell cloud users they can deploy nodes on cloud. return ( <> - + ); } } + /** + * Node role allocation moves data in a phase to a corresponding tier of the same name. To prevent policy execution from getting + * stuck ILM allocation will fall back to a previous tier if possible. We show the WARNING below to inform a user when even + * this fallback will not succeed. + */ const allocationNodeRole = getAvailableNodeRoleForPhase(phase, nodesByRoles); - if ( - allocationNodeRole === 'none' || - !isNodeRoleFirstPreference(phase, allocationNodeRole) - ) { + if (allocationNodeRole === 'none') { + return ( + <> + + + + ); + } + + /** + * If we are able to fallback to a data tier that does not map to this phase, we show a notice informing the user that their + * data will not be assigned to a corresponding tier. + */ + if (!isNodeRoleFirstPreference(phase, allocationNodeRole)) { return ( <> @@ -106,6 +124,19 @@ export const DataTierAllocationField: FunctionComponent = ({ phase, descr ); } + /** + * Special cloud case: when deprecated data role configuration is in use, it means that this deployment is not using + * the new node role based allocation. We drive users to the cloud console to migrate to node role based allocation + * in that case. + */ + if (isCloudEnabled && isUsingDeprecatedDataRoleConfig) { + return ( + <> + + + + ); + } break; default: return null; @@ -141,9 +172,7 @@ export const DataTierAllocationField: FunctionComponent = ({ phase, descr hasNodeAttributes={hasNodeAttrs} phase={phase} nodes={nodesByAttributes} - disableDataTierOption={Boolean( - isCloudEnabled && !hasDataNodeRoles && isUsingDeprecatedDataRoleConfig - )} + disableDataTierOption={Boolean(isCloudEnabled && isUsingDeprecatedDataRoleConfig)} isLoading={isLoading} /> diff --git a/x-pack/plugins/index_lifecycle_management/public/types.ts b/x-pack/plugins/index_lifecycle_management/public/types.ts index 27b3795c6731f..adfca9ad41b26 100644 --- a/x-pack/plugins/index_lifecycle_management/public/types.ts +++ b/x-pack/plugins/index_lifecycle_management/public/types.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public'; import { ManagementSetup } from '../../../../src/plugins/management/public'; diff --git a/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap b/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap index e5594bb0bb769..7aa838021f2a8 100644 --- a/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap +++ b/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap @@ -1,16 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`DragDrop defined dropType is reflected in the className 1`] = ` -
- -
+ Hello! + `; exports[`DragDrop items that has dropType=undefined get special styling when another item is dragged 1`] = ` diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.scss b/x-pack/plugins/lens/public/drag_drop/drag_drop.scss index 9e3f1e1c3cf26..961f7ee0ec400 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.scss +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.scss @@ -81,13 +81,6 @@ } } -.lnsDragDrop__container { - position: relative; - overflow: visible !important; // sass-lint:disable-line no-important - width: 100%; - height: 100%; -} - .lnsDragDrop__reorderableDrop { position: absolute; width: 100%; diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx index 618a7accb9b2b..76e44c29eaed5 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx @@ -456,7 +456,7 @@ const DropInner = memo(function DropInner(props: DropInnerProps) { isActiveDropTarget && dropType !== 'reorder' && dragging?.ghost ? dragging.ghost : undefined; return ( -
+ <> {React.cloneElement(children, { 'data-test-subj': dataTestSubj || 'lnsDragDrop', className: classNames(children.props.className, classes, className), @@ -471,7 +471,7 @@ const DropInner = memo(function DropInner(props: DropInnerProps) { style: ghost.style, }) : null} -
+ ); }); 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 d5c0b9ff64807..af5411dd4d3b0 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 @@ -11,9 +11,9 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { Ast } from '@kbn/interpreter/common'; import { i18n } from '@kbn/i18n'; import { + EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, - EuiIcon, EuiText, EuiButtonEmpty, EuiLink, @@ -389,72 +389,84 @@ export const InnerVisualizationWrapper = ({ if (localState.configurationValidationError?.length) { let showExtraErrors = null; + let showExtraErrorsAction = null; + if (localState.configurationValidationError.length > 1) { if (localState.expandError) { showExtraErrors = localState.configurationValidationError .slice(1) .map(({ longMessage }) => ( - {longMessage} - +

)); } else { - showExtraErrors = ( - - { - setLocalState((prevState: WorkspaceState) => ({ - ...prevState, - expandError: !prevState.expandError, - })); - }} - data-test-subj="configuration-failure-more-errors" - > - {i18n.translate('xpack.lens.editorFrame.configurationFailureMoreErrors', { - defaultMessage: ` +{errors} {errors, plural, one {error} other {errors}}`, - values: { errors: localState.configurationValidationError.length - 1 }, - })} - - + showExtraErrorsAction = ( + { + setLocalState((prevState: WorkspaceState) => ({ + ...prevState, + expandError: !prevState.expandError, + })); + }} + data-test-subj="configuration-failure-more-errors" + > + {i18n.translate('xpack.lens.editorFrame.configurationFailureMoreErrors', { + defaultMessage: ` +{errors} {errors, plural, one {error} other {errors}}`, + values: { errors: localState.configurationValidationError.length - 1 }, + })} + ); } } return ( - + - - - - {localState.configurationValidationError[0].longMessage} + +

+ {localState.configurationValidationError[0].longMessage} +

+ + {showExtraErrors} + + } + iconColor="danger" + iconType="alert" + />
- {showExtraErrors}
); } if (localState.expressionBuildError?.length) { return ( - + - - - - +

+ +

+ +

{localState.expressionBuildError[0].longMessage}

+ + } + iconColor="danger" + iconType="alert" />
- {localState.expressionBuildError[0].longMessage}
); } @@ -474,34 +486,43 @@ export const InnerVisualizationWrapper = ({ const visibleErrorMessage = getOriginalRequestErrorMessage(error) || errorMessage; return ( - + - - - - { + setLocalState((prevState: WorkspaceState) => ({ + ...prevState, + expandError: !prevState.expandError, + })); + }} + > + {i18n.translate('xpack.lens.editorFrame.expandRenderingErrorButton', { + defaultMessage: 'Show details of error', + })} + + ) : null + } + body={ + <> +

+ +

+ + {localState.expandError ? ( +

visibleErrorMessage

+ ) : null} + + } + iconColor="danger" + iconType="alert" />
- {visibleErrorMessage ? ( - - { - setLocalState((prevState: WorkspaceState) => ({ - ...prevState, - expandError: !prevState.expandError, - })); - }} - > - {i18n.translate('xpack.lens.editorFrame.expandRenderingErrorButton', { - defaultMessage: 'Show details of error', - })} - - - {localState.expandError ? visibleErrorMessage : null} - - ) : null}
); }} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss index 3949c7deb53b4..167c17ee6ae9c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss @@ -11,6 +11,7 @@ min-height: $euiSizeXXL * 10; overflow: visible; border: none; + height: 100%; .lnsWorkspacePanelWrapper__pageContentBody { @include euiScrollBar; diff --git a/x-pack/plugins/lists/common/constants.mock.ts b/x-pack/plugins/lists/common/constants.mock.ts index 6f8782c148d45..27e0fa29b1e55 100644 --- a/x-pack/plugins/lists/common/constants.mock.ts +++ b/x-pack/plugins/lists/common/constants.mock.ts @@ -8,7 +8,7 @@ import moment from 'moment'; import { OsTypeArray } from './schemas/common'; -import { EntriesArray } from './schemas/types'; +import { EntriesArray, Entry, EntryMatch, EntryNested } from './schemas/types'; import { EndpointEntriesArray } from './schemas/types/endpoint'; export const DATE_NOW = '2020-04-20T15:25:31.830Z'; export const OLD_DATE_RELATIVE_TO_DATE_NOW = '2020-04-19T15:25:31.830Z'; @@ -72,6 +72,34 @@ export const ENDPOINT_ENTRIES: EndpointEntriesArray = [ }, { field: 'some.not.nested.field', operator: 'included', type: 'match', value: 'some value' }, ]; +// ENTRIES_WITH_IDS should only be used to mock out functionality of a collection of transforms +// that are UI specific and useful for UI concerns that are inserted between the +// API and the actual user interface. In some ways these might be viewed as +// technical debt or to compensate for the differences and preferences +// of how ReactJS might prefer data vs. how we want to model data. +export const ENTRIES_WITH_IDS: EntriesArray = [ + { + entries: [ + { + field: 'nested.field', + id: '123', + operator: 'included', + type: 'match', + value: 'some value', + } as EntryMatch & { id: string }, + ], + field: 'some.parentField', + id: '123', + type: 'nested', + } as EntryNested & { id: string }, + { + field: 'some.not.nested.field', + id: '123', + operator: 'included', + type: 'match', + value: 'some value', + } as Entry & { id: string }, +]; export const ITEM_TYPE = 'simple'; export const OS_TYPES: OsTypeArray = ['windows']; export const TAGS = []; diff --git a/x-pack/plugins/lists/common/shared_imports.ts b/x-pack/plugins/lists/common/shared_imports.ts index 7e96b13c036ec..2483c1f7dd992 100644 --- a/x-pack/plugins/lists/common/shared_imports.ts +++ b/x-pack/plugins/lists/common/shared_imports.ts @@ -12,6 +12,8 @@ export { DefaultStringArray, DefaultVersionNumber, DefaultVersionNumberDecoded, + addIdToItem, + removeIdFromItem, exactCheck, getPaths, foldLeftRight, diff --git a/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.test.ts b/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.test.ts index ed16a405a5107..7a39bd5651014 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.test.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.test.ts @@ -7,6 +7,7 @@ import { act, renderHook } from '@testing-library/react-hooks'; +import { ENTRIES_WITH_IDS } from '../../../common/constants.mock'; import { coreMock } from '../../../../../../src/core/public/mocks'; import * as api from '../api'; import { getCreateExceptionListItemSchemaMock } from '../../../common/schemas/request/create_exception_list_item_schema.mock'; @@ -69,8 +70,8 @@ describe('usePersistExceptionItem', () => { }); test('it invokes "updateExceptionListItem" when payload has "id"', async () => { - const addExceptionItem = jest.spyOn(api, 'addExceptionListItem'); - const updateExceptionItem = jest.spyOn(api, 'updateExceptionListItem'); + const addExceptionListItem = jest.spyOn(api, 'addExceptionListItem'); + const updateExceptionListItem = jest.spyOn(api, 'updateExceptionListItem'); await act(async () => { const { result, waitForNextUpdate } = renderHook< PersistHookProps, @@ -78,12 +79,45 @@ describe('usePersistExceptionItem', () => { >(() => usePersistExceptionItem({ http: mockKibanaHttpService, onError })); await waitForNextUpdate(); - result.current[1](getUpdateExceptionListItemSchemaMock()); + // NOTE: Take note here passing in an exception item where it's + // entries have been enriched with ids to ensure that they get stripped + // before the call goes through + result.current[1]({ ...getUpdateExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }); await waitForNextUpdate(); expect(result.current).toEqual([{ isLoading: false, isSaved: true }, result.current[1]]); - expect(addExceptionItem).not.toHaveBeenCalled(); - expect(updateExceptionItem).toHaveBeenCalled(); + expect(addExceptionListItem).not.toHaveBeenCalled(); + expect(updateExceptionListItem).toHaveBeenCalledWith({ + http: mockKibanaHttpService, + listItem: getUpdateExceptionListItemSchemaMock(), + signal: new AbortController().signal, + }); + }); + }); + + test('it invokes "addExceptionListItem" when payload does not have "id"', async () => { + const updateExceptionListItem = jest.spyOn(api, 'updateExceptionListItem'); + const addExceptionListItem = jest.spyOn(api, 'addExceptionListItem'); + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + PersistHookProps, + ReturnPersistExceptionItem + >(() => usePersistExceptionItem({ http: mockKibanaHttpService, onError })); + + await waitForNextUpdate(); + // NOTE: Take note here passing in an exception item where it's + // entries have been enriched with ids to ensure that they get stripped + // before the call goes through + result.current[1]({ ...getCreateExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }); + await waitForNextUpdate(); + + expect(result.current).toEqual([{ isLoading: false, isSaved: true }, result.current[1]]); + expect(updateExceptionListItem).not.toHaveBeenCalled(); + expect(addExceptionListItem).toHaveBeenCalledWith({ + http: mockKibanaHttpService, + listItem: getCreateExceptionListItemSchemaMock(), + signal: new AbortController().signal, + }); }); }); diff --git a/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.ts b/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.ts index 995c6b8703bf4..6135d14aef6a4 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.ts @@ -7,9 +7,13 @@ import { Dispatch, useEffect, useState } from 'react'; -import { UpdateExceptionListItemSchema } from '../../../common/schemas'; +import { + CreateExceptionListItemSchema, + UpdateExceptionListItemSchema, +} from '../../../common/schemas'; import { addExceptionListItem, updateExceptionListItem } from '../api'; -import { AddExceptionListItem, PersistHookProps } from '../types'; +import { transformNewItemOutput, transformOutput } from '../transforms'; +import { PersistHookProps } from '../types'; interface PersistReturnExceptionItem { isLoading: boolean; @@ -18,7 +22,7 @@ interface PersistReturnExceptionItem { export type ReturnPersistExceptionItem = [ PersistReturnExceptionItem, - Dispatch + Dispatch ]; /** @@ -32,7 +36,9 @@ export const usePersistExceptionItem = ({ http, onError, }: PersistHookProps): ReturnPersistExceptionItem => { - const [exceptionListItem, setExceptionItem] = useState(null); + const [exceptionListItem, setExceptionItem] = useState< + CreateExceptionListItemSchema | UpdateExceptionListItemSchema | null + >(null); const [isSaved, setIsSaved] = useState(false); const [isLoading, setIsLoading] = useState(false); const isUpdateExceptionItem = (item: unknown): item is UpdateExceptionListItemSchema => @@ -47,16 +53,25 @@ export const usePersistExceptionItem = ({ if (exceptionListItem != null) { try { setIsLoading(true); + if (isUpdateExceptionItem(exceptionListItem)) { + // Please see `x-pack/plugins/lists/public/exceptions/transforms.ts` doc notes + // for context around the temporary `id` + const transformedList = transformOutput(exceptionListItem); + await updateExceptionListItem({ http, - listItem: exceptionListItem, + listItem: transformedList, signal: abortCtrl.signal, }); } else { + // Please see `x-pack/plugins/lists/public/exceptions/transforms.ts` doc notes + // for context around the temporary `id` + const transformedList = transformNewItemOutput(exceptionListItem); + await addExceptionListItem({ http, - listItem: exceptionListItem, + listItem: transformedList, signal: abortCtrl.signal, }); } diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.ts index e61e74ca33236..62f959cb386a0 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.ts @@ -7,6 +7,7 @@ import { act, renderHook } from '@testing-library/react-hooks'; +import { ENTRIES_WITH_IDS } from '../../../common/constants.mock'; import { getUpdateExceptionListItemSchemaMock } from '../../../common/schemas/request/update_exception_list_item_schema.mock'; import { coreMock } from '../../../../../../src/core/public/mocks'; import * as api from '../api'; @@ -24,6 +25,10 @@ import { import { ExceptionsApi, useApi } from './use_api'; +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('123'), +})); + const mockKibanaHttpService = coreMock.createStart().http; describe('useApi', () => { @@ -34,397 +39,428 @@ describe('useApi', () => { jest.clearAllMocks(); }); - test('it invokes "deleteExceptionListItemById" when "deleteExceptionItem" used', async () => { - const payload = getExceptionListItemSchemaMock(); - const onSuccessMock = jest.fn(); - const spyOnDeleteExceptionListItemById = jest - .spyOn(api, 'deleteExceptionListItemById') - .mockResolvedValue(payload); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); - - const { id, namespace_type: namespaceType } = payload; - - await result.current.deleteExceptionItem({ - id, - namespaceType, - onError: jest.fn(), - onSuccess: onSuccessMock, + describe('deleteExceptionItem', () => { + test('it invokes "deleteExceptionListItemById" when "deleteExceptionItem" used', async () => { + const payload = getExceptionListItemSchemaMock(); + const onSuccessMock = jest.fn(); + const spyOnDeleteExceptionListItemById = jest + .spyOn(api, 'deleteExceptionListItemById') + .mockResolvedValue(payload); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + const { id, namespace_type: namespaceType } = payload; + + await result.current.deleteExceptionItem({ + id, + namespaceType, + onError: jest.fn(), + onSuccess: onSuccessMock, + }); + + const expected: ApiCallByIdProps = { + http: mockKibanaHttpService, + id, + namespaceType, + signal: new AbortController().signal, + }; + + expect(spyOnDeleteExceptionListItemById).toHaveBeenCalledWith(expected); + expect(onSuccessMock).toHaveBeenCalled(); }); - - const expected: ApiCallByIdProps = { - http: mockKibanaHttpService, - id, - namespaceType, - signal: new AbortController().signal, - }; - - expect(spyOnDeleteExceptionListItemById).toHaveBeenCalledWith(expected); - expect(onSuccessMock).toHaveBeenCalled(); }); - }); - test('invokes "onError" callback if "deleteExceptionListItemById" fails', async () => { - const mockError = new Error('failed to delete item'); - jest.spyOn(api, 'deleteExceptionListItemById').mockRejectedValue(mockError); + test('invokes "onError" callback if "deleteExceptionListItemById" fails', async () => { + const mockError = new Error('failed to delete item'); + jest.spyOn(api, 'deleteExceptionListItemById').mockRejectedValue(mockError); - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); - const { id, namespace_type: namespaceType } = getExceptionListItemSchemaMock(); + const { id, namespace_type: namespaceType } = getExceptionListItemSchemaMock(); - await result.current.deleteExceptionItem({ - id, - namespaceType, - onError: onErrorMock, - onSuccess: jest.fn(), - }); + await result.current.deleteExceptionItem({ + id, + namespaceType, + onError: onErrorMock, + onSuccess: jest.fn(), + }); - expect(onErrorMock).toHaveBeenCalledWith(mockError); + expect(onErrorMock).toHaveBeenCalledWith(mockError); + }); }); }); - test('it invokes "deleteExceptionListById" when "deleteExceptionList" used', async () => { - const payload = getExceptionListSchemaMock(); - const onSuccessMock = jest.fn(); - const spyOnDeleteExceptionListById = jest - .spyOn(api, 'deleteExceptionListById') - .mockResolvedValue(payload); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); - - const { id, namespace_type: namespaceType } = payload; - - await result.current.deleteExceptionList({ - id, - namespaceType, - onError: jest.fn(), - onSuccess: onSuccessMock, + describe('deleteExceptionList', () => { + test('it invokes "deleteExceptionListById" when "deleteExceptionList" used', async () => { + const payload = getExceptionListSchemaMock(); + const onSuccessMock = jest.fn(); + const spyOnDeleteExceptionListById = jest + .spyOn(api, 'deleteExceptionListById') + .mockResolvedValue(payload); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + const { id, namespace_type: namespaceType } = payload; + + await result.current.deleteExceptionList({ + id, + namespaceType, + onError: jest.fn(), + onSuccess: onSuccessMock, + }); + + const expected: ApiCallByIdProps = { + http: mockKibanaHttpService, + id, + namespaceType, + signal: new AbortController().signal, + }; + + expect(spyOnDeleteExceptionListById).toHaveBeenCalledWith(expected); + expect(onSuccessMock).toHaveBeenCalled(); }); - - const expected: ApiCallByIdProps = { - http: mockKibanaHttpService, - id, - namespaceType, - signal: new AbortController().signal, - }; - - expect(spyOnDeleteExceptionListById).toHaveBeenCalledWith(expected); - expect(onSuccessMock).toHaveBeenCalled(); }); - }); - test('invokes "onError" callback if "deleteExceptionListById" fails', async () => { - const mockError = new Error('failed to delete item'); - jest.spyOn(api, 'deleteExceptionListById').mockRejectedValue(mockError); + test('invokes "onError" callback if "deleteExceptionListById" fails', async () => { + const mockError = new Error('failed to delete item'); + jest.spyOn(api, 'deleteExceptionListById').mockRejectedValue(mockError); - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); - const { id, namespace_type: namespaceType } = getExceptionListSchemaMock(); + const { id, namespace_type: namespaceType } = getExceptionListSchemaMock(); - await result.current.deleteExceptionList({ - id, - namespaceType, - onError: onErrorMock, - onSuccess: jest.fn(), - }); + await result.current.deleteExceptionList({ + id, + namespaceType, + onError: onErrorMock, + onSuccess: jest.fn(), + }); - expect(onErrorMock).toHaveBeenCalledWith(mockError); + expect(onErrorMock).toHaveBeenCalledWith(mockError); + }); }); }); - test('it invokes "fetchExceptionListItemById" when "getExceptionItem" used', async () => { - const payload = getExceptionListItemSchemaMock(); - const onSuccessMock = jest.fn(); - const spyOnFetchExceptionListItemById = jest - .spyOn(api, 'fetchExceptionListItemById') - .mockResolvedValue(payload); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); - - const { id, namespace_type: namespaceType } = payload; - - await result.current.getExceptionItem({ - id, - namespaceType, - onError: jest.fn(), - onSuccess: onSuccessMock, + describe('getExceptionItem', () => { + test('it invokes "fetchExceptionListItemById" when "getExceptionItem" used', async () => { + const payload = getExceptionListItemSchemaMock(); + const onSuccessMock = jest.fn(); + const spyOnFetchExceptionListItemById = jest + .spyOn(api, 'fetchExceptionListItemById') + .mockResolvedValue(payload); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + const { id, namespace_type: namespaceType } = payload; + + await result.current.getExceptionItem({ + id, + namespaceType, + onError: jest.fn(), + onSuccess: onSuccessMock, + }); + + const expected: ApiCallByIdProps = { + http: mockKibanaHttpService, + id, + namespaceType, + signal: new AbortController().signal, + }; + const expectedExceptionListItem = { + ...getExceptionListItemSchemaMock(), + entries: ENTRIES_WITH_IDS, + }; + + expect(spyOnFetchExceptionListItemById).toHaveBeenCalledWith(expected); + expect(onSuccessMock).toHaveBeenCalledWith(expectedExceptionListItem); }); - - const expected: ApiCallByIdProps = { - http: mockKibanaHttpService, - id, - namespaceType, - signal: new AbortController().signal, - }; - - expect(spyOnFetchExceptionListItemById).toHaveBeenCalledWith(expected); - expect(onSuccessMock).toHaveBeenCalled(); }); - }); - test('invokes "onError" callback if "fetchExceptionListItemById" fails', async () => { - const mockError = new Error('failed to delete item'); - jest.spyOn(api, 'fetchExceptionListItemById').mockRejectedValue(mockError); + test('invokes "onError" callback if "fetchExceptionListItemById" fails', async () => { + const mockError = new Error('failed to delete item'); + jest.spyOn(api, 'fetchExceptionListItemById').mockRejectedValue(mockError); - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); - const { id, namespace_type: namespaceType } = getExceptionListSchemaMock(); + const { id, namespace_type: namespaceType } = getExceptionListSchemaMock(); - await result.current.getExceptionItem({ - id, - namespaceType, - onError: onErrorMock, - onSuccess: jest.fn(), - }); + await result.current.getExceptionItem({ + id, + namespaceType, + onError: onErrorMock, + onSuccess: jest.fn(), + }); - expect(onErrorMock).toHaveBeenCalledWith(mockError); + expect(onErrorMock).toHaveBeenCalledWith(mockError); + }); }); }); - test('it invokes "fetchExceptionListById" when "getExceptionList" used', async () => { - const payload = getExceptionListSchemaMock(); - const onSuccessMock = jest.fn(); - const spyOnFetchExceptionListById = jest - .spyOn(api, 'fetchExceptionListById') - .mockResolvedValue(payload); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); - - const { id, namespace_type: namespaceType } = payload; - - await result.current.getExceptionList({ - id, - namespaceType, - onError: jest.fn(), - onSuccess: onSuccessMock, + describe('getExceptionList', () => { + test('it invokes "fetchExceptionListById" when "getExceptionList" used', async () => { + const payload = getExceptionListSchemaMock(); + const onSuccessMock = jest.fn(); + const spyOnFetchExceptionListById = jest + .spyOn(api, 'fetchExceptionListById') + .mockResolvedValue(payload); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + const { id, namespace_type: namespaceType } = payload; + + await result.current.getExceptionList({ + id, + namespaceType, + onError: jest.fn(), + onSuccess: onSuccessMock, + }); + + const expected: ApiCallByIdProps = { + http: mockKibanaHttpService, + id, + namespaceType, + signal: new AbortController().signal, + }; + + expect(spyOnFetchExceptionListById).toHaveBeenCalledWith(expected); + expect(onSuccessMock).toHaveBeenCalled(); }); - - const expected: ApiCallByIdProps = { - http: mockKibanaHttpService, - id, - namespaceType, - signal: new AbortController().signal, - }; - - expect(spyOnFetchExceptionListById).toHaveBeenCalledWith(expected); - expect(onSuccessMock).toHaveBeenCalled(); }); - }); - - test('invokes "onError" callback if "fetchExceptionListById" fails', async () => { - const mockError = new Error('failed to delete item'); - jest.spyOn(api, 'fetchExceptionListById').mockRejectedValue(mockError); - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); + test('invokes "onError" callback if "fetchExceptionListById" fails', async () => { + const mockError = new Error('failed to delete item'); + jest.spyOn(api, 'fetchExceptionListById').mockRejectedValue(mockError); - const { id, namespace_type: namespaceType } = getExceptionListSchemaMock(); + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); - await result.current.getExceptionList({ - id, - namespaceType, - onError: onErrorMock, - onSuccess: jest.fn(), - }); + const { id, namespace_type: namespaceType } = getExceptionListSchemaMock(); - expect(onErrorMock).toHaveBeenCalledWith(mockError); - }); - }); + await result.current.getExceptionList({ + id, + namespaceType, + onError: onErrorMock, + onSuccess: jest.fn(), + }); - test('it invokes "fetchExceptionListsItemsByListIds" when "getExceptionItem" used', async () => { - const output = getFoundExceptionListItemSchemaMock(); - const onSuccessMock = jest.fn(); - const spyOnFetchExceptionListsItemsByListIds = jest - .spyOn(api, 'fetchExceptionListsItemsByListIds') - .mockResolvedValue(output); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); - - await result.current.getExceptionListsItems({ - filterOptions: [], - lists: [{ id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }], - onError: jest.fn(), - onSuccess: onSuccessMock, - pagination: { - page: 1, - perPage: 20, - total: 0, - }, - showDetectionsListsOnly: false, - showEndpointListsOnly: false, + expect(onErrorMock).toHaveBeenCalledWith(mockError); }); - - const expected: ApiCallByListIdProps = { - filterOptions: [], - http: mockKibanaHttpService, - listIds: ['list_id'], - namespaceTypes: ['single'], - pagination: { - page: 1, - perPage: 20, - total: 0, - }, - signal: new AbortController().signal, - }; - - expect(spyOnFetchExceptionListsItemsByListIds).toHaveBeenCalledWith(expected); - expect(onSuccessMock).toHaveBeenCalled(); }); }); - test('it does not invoke "fetchExceptionListsItemsByListIds" if no listIds', async () => { - const output = getFoundExceptionListItemSchemaMock(); - const onSuccessMock = jest.fn(); - const spyOnFetchExceptionListsItemsByListIds = jest - .spyOn(api, 'fetchExceptionListsItemsByListIds') - .mockResolvedValue(output); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); - - await result.current.getExceptionListsItems({ - filterOptions: [], - lists: [{ id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }], - onError: jest.fn(), - onSuccess: onSuccessMock, - pagination: { - page: 1, - perPage: 20, - total: 0, - }, - showDetectionsListsOnly: false, - showEndpointListsOnly: true, + describe('getExceptionListsItems', () => { + test('it invokes "fetchExceptionListsItemsByListIds" when "getExceptionListsItems" used', async () => { + const output = getFoundExceptionListItemSchemaMock(); + const onSuccessMock = jest.fn(); + const spyOnFetchExceptionListsItemsByListIds = jest + .spyOn(api, 'fetchExceptionListsItemsByListIds') + .mockResolvedValue(output); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + await result.current.getExceptionListsItems({ + filterOptions: [], + lists: [ + { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, + ], + onError: jest.fn(), + onSuccess: onSuccessMock, + pagination: { + page: 1, + perPage: 1, + total: 0, + }, + showDetectionsListsOnly: false, + showEndpointListsOnly: false, + }); + + const expected: ApiCallByListIdProps = { + filterOptions: [], + http: mockKibanaHttpService, + listIds: ['list_id'], + namespaceTypes: ['single'], + pagination: { + page: 1, + perPage: 1, + total: 0, + }, + signal: new AbortController().signal, + }; + + expect(spyOnFetchExceptionListsItemsByListIds).toHaveBeenCalledWith(expected); + expect(onSuccessMock).toHaveBeenCalledWith({ + exceptions: [{ ...getExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }], + pagination: { + page: 1, + perPage: 1, + total: 1, + }, + }); }); + }); - expect(spyOnFetchExceptionListsItemsByListIds).not.toHaveBeenCalled(); - expect(onSuccessMock).toHaveBeenCalledWith({ - exceptions: [], - pagination: { - page: 0, - perPage: 20, - total: 0, - }, + test('it does not invoke "fetchExceptionListsItemsByListIds" if no listIds', async () => { + const output = getFoundExceptionListItemSchemaMock(); + const onSuccessMock = jest.fn(); + const spyOnFetchExceptionListsItemsByListIds = jest + .spyOn(api, 'fetchExceptionListsItemsByListIds') + .mockResolvedValue(output); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + await result.current.getExceptionListsItems({ + filterOptions: [], + lists: [ + { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, + ], + onError: jest.fn(), + onSuccess: onSuccessMock, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + showDetectionsListsOnly: false, + showEndpointListsOnly: true, + }); + + expect(spyOnFetchExceptionListsItemsByListIds).not.toHaveBeenCalled(); + expect(onSuccessMock).toHaveBeenCalledWith({ + exceptions: [], + pagination: { + page: 0, + perPage: 20, + total: 0, + }, + }); }); }); - }); - test('invokes "onError" callback if "fetchExceptionListsItemsByListIds" fails', async () => { - const mockError = new Error('failed to delete item'); - jest.spyOn(api, 'fetchExceptionListsItemsByListIds').mockRejectedValue(mockError); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); - - await result.current.getExceptionListsItems({ - filterOptions: [], - lists: [{ id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }], - onError: onErrorMock, - onSuccess: jest.fn(), - pagination: { - page: 1, - perPage: 20, - total: 0, - }, - showDetectionsListsOnly: false, - showEndpointListsOnly: false, + test('invokes "onError" callback if "fetchExceptionListsItemsByListIds" fails', async () => { + const mockError = new Error('failed to delete item'); + jest.spyOn(api, 'fetchExceptionListsItemsByListIds').mockRejectedValue(mockError); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + await result.current.getExceptionListsItems({ + filterOptions: [], + lists: [ + { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, + ], + onError: onErrorMock, + onSuccess: jest.fn(), + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + showDetectionsListsOnly: false, + showEndpointListsOnly: false, + }); + + expect(onErrorMock).toHaveBeenCalledWith(mockError); }); - - expect(onErrorMock).toHaveBeenCalledWith(mockError); }); }); - test('it invokes "addExceptionListItem" when "addExceptionListItem" used', async () => { - const payload = getExceptionListItemSchemaMock(); - const itemToCreate = getCreateExceptionListItemSchemaMock(); - const spyOnFetchExceptionListItemById = jest - .spyOn(api, 'addExceptionListItem') - .mockResolvedValue(payload); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); - - await result.current.addExceptionListItem({ - listItem: itemToCreate, + describe('addExceptionListItem', () => { + test('it removes exception item entry ids', async () => { + const payload = getExceptionListItemSchemaMock(); + const itemToCreate = { ...getCreateExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }; + const spyOnFetchExceptionListItemById = jest + .spyOn(api, 'addExceptionListItem') + .mockResolvedValue(payload); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + await result.current.addExceptionListItem({ + listItem: itemToCreate, + }); + + const expected: AddExceptionListItemProps = { + http: mockKibanaHttpService, + listItem: getCreateExceptionListItemSchemaMock(), + signal: new AbortController().signal, + }; + + expect(spyOnFetchExceptionListItemById).toHaveBeenCalledWith(expected); }); - - const expected: AddExceptionListItemProps = { - http: mockKibanaHttpService, - listItem: itemToCreate, - signal: new AbortController().signal, - }; - - expect(spyOnFetchExceptionListItemById).toHaveBeenCalledWith(expected); }); }); - test('it invokes "updateExceptionListItem" when "getExceptionItem" used', async () => { - const payload = getExceptionListItemSchemaMock(); - const itemToUpdate = getUpdateExceptionListItemSchemaMock(); - const spyOnUpdateExceptionListItem = jest - .spyOn(api, 'updateExceptionListItem') - .mockResolvedValue(payload); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); - - await result.current.updateExceptionListItem({ - listItem: itemToUpdate, + describe('updateExceptionListItem', () => { + test('it removes exception item entry ids', async () => { + const payload = getExceptionListItemSchemaMock(); + const itemToUpdate = { ...getUpdateExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }; + const spyOnUpdateExceptionListItem = jest + .spyOn(api, 'updateExceptionListItem') + .mockResolvedValue(payload); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + await result.current.updateExceptionListItem({ + listItem: itemToUpdate, + }); + + const expected: UpdateExceptionListItemProps = { + http: mockKibanaHttpService, + listItem: getUpdateExceptionListItemSchemaMock(), + signal: new AbortController().signal, + }; + + expect(spyOnUpdateExceptionListItem).toHaveBeenCalledWith(expected); }); - - const expected: UpdateExceptionListItemProps = { - http: mockKibanaHttpService, - listItem: itemToUpdate, - signal: new AbortController().signal, - }; - - expect(spyOnUpdateExceptionListItem).toHaveBeenCalledWith(expected); }); }); }); diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_api.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_api.ts index b0c831ef3b857..9e4e338b09dbf 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_api.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_api.ts @@ -17,6 +17,7 @@ import { } from '../../../common/schemas'; import { ApiCallFindListsItemsMemoProps, ApiCallMemoProps, ApiListExportProps } from '../types'; import { getIdsAndNamespaces } from '../utils'; +import { transformInput, transformNewItemOutput, transformOutput } from '../transforms'; export interface ExceptionsApi { addExceptionListItem: (arg: { @@ -46,10 +47,11 @@ export const useApi = (http: HttpStart): ExceptionsApi => { listItem: CreateExceptionListItemSchema; }): Promise { const abortCtrl = new AbortController(); + const sanitizedItem: CreateExceptionListItemSchema = transformNewItemOutput(listItem); return Api.addExceptionListItem({ http, - listItem, + listItem: sanitizedItem, signal: abortCtrl.signal, }); }, @@ -124,12 +126,14 @@ export const useApi = (http: HttpStart): ExceptionsApi => { const abortCtrl = new AbortController(); try { - const item = await Api.fetchExceptionListItemById({ - http, - id, - namespaceType, - signal: abortCtrl.signal, - }); + const item = transformInput( + await Api.fetchExceptionListItemById({ + http, + id, + namespaceType, + signal: abortCtrl.signal, + }) + ); onSuccess(item); } catch (error) { onError(error); @@ -187,7 +191,10 @@ export const useApi = (http: HttpStart): ExceptionsApi => { signal: abortCtrl.signal, }); onSuccess({ - exceptions: data, + // This data transform is UI specific and useful for UI concerns + // to compensate for the differences and preferences of how ReactJS might prefer + // data vs. how we want to model data. View `transformInput` for more details + exceptions: data.map((item) => transformInput(item)), pagination: { page, perPage, @@ -214,10 +221,11 @@ export const useApi = (http: HttpStart): ExceptionsApi => { listItem: UpdateExceptionListItemSchema; }): Promise { const abortCtrl = new AbortController(); + const sanitizedItem: UpdateExceptionListItemSchema = transformOutput(listItem); return Api.updateExceptionListItem({ http, - listItem, + listItem: sanitizedItem, signal: abortCtrl.signal, }); }, diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.test.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.test.ts index d5d2638781879..1191b240d27bb 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.test.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.test.ts @@ -12,9 +12,14 @@ import * as api from '../api'; import { getFoundExceptionListItemSchemaMock } from '../../../common/schemas/response/found_exception_list_item_schema.mock'; import { ExceptionListItemSchema } from '../../../common/schemas'; import { UseExceptionListItemsSuccess, UseExceptionListProps } from '../types'; +import { transformInput } from '../transforms'; import { ReturnExceptionListAndItems, useExceptionListItems } from './use_exception_list_items'; +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('123'), +})); + const mockKibanaHttpService = coreMock.createStart().http; describe('useExceptionListItems', () => { @@ -99,8 +104,9 @@ describe('useExceptionListItems', () => { await waitForNextUpdate(); await waitForNextUpdate(); - const expectedListItemsResult: ExceptionListItemSchema[] = getFoundExceptionListItemSchemaMock() - .data; + const expectedListItemsResult: ExceptionListItemSchema[] = getFoundExceptionListItemSchemaMock().data.map( + (item) => transformInput(item) + ); const expectedResult: UseExceptionListItemsSuccess = { exceptions: expectedListItemsResult, pagination: { page: 1, perPage: 1, total: 1 }, diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.ts index 50271530b42e7..b9a8628d2ceac 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.ts @@ -11,6 +11,7 @@ import { fetchExceptionListsItemsByListIds } from '../api'; import { FilterExceptionsOptions, Pagination, UseExceptionListProps } from '../types'; import { ExceptionListItemSchema } from '../../../common/schemas'; import { getIdsAndNamespaces } from '../utils'; +import { transformInput } from '../transforms'; type Func = () => void; export type ReturnExceptionListAndItems = [ @@ -95,8 +96,12 @@ export const useExceptionListItems = ({ } setLoading(false); } else { - // eslint-disable-next-line @typescript-eslint/naming-convention - const { page, per_page, total, data } = await fetchExceptionListsItemsByListIds({ + const { + page, + per_page: perPage, + total, + data, + } = await fetchExceptionListsItemsByListIds({ filterOptions: filters, http, listIds: ids, @@ -108,20 +113,24 @@ export const useExceptionListItems = ({ signal: abortCtrl.signal, }); + // Please see `x-pack/plugins/lists/public/exceptions/transforms.ts` doc notes + // for context around the temporary `id` + const transformedData = data.map((item) => transformInput(item)); + if (isSubscribed) { setPagination({ page, - perPage: per_page, + perPage, total, }); - setExceptionListItems(data); + setExceptionListItems(transformedData); if (onSuccess != null) { onSuccess({ - exceptions: data, + exceptions: transformedData, pagination: { page, - perPage: per_page, + perPage, total, }, }); diff --git a/x-pack/plugins/lists/public/exceptions/transforms.test.ts b/x-pack/plugins/lists/public/exceptions/transforms.test.ts new file mode 100644 index 0000000000000..12b0f0bd8624a --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/transforms.test.ts @@ -0,0 +1,282 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ExceptionListItemSchema } from '../../common/schemas/response/exception_list_item_schema'; +import { UpdateExceptionListItemSchema } from '../../common/schemas/request/update_exception_list_item_schema'; +import { CreateExceptionListItemSchema } from '../../common/schemas/request/create_exception_list_item_schema'; +import { getCreateExceptionListItemSchemaMock } from '../../common/schemas/request/create_exception_list_item_schema.mock'; +import { getUpdateExceptionListItemSchemaMock } from '../../common/schemas/request/update_exception_list_item_schema.mock'; +import { getExceptionListItemSchemaMock } from '../../common/schemas/response/exception_list_item_schema.mock'; +import { ENTRIES_WITH_IDS } from '../../common/constants.mock'; +import { Entry, EntryMatch, EntryNested } from '../../common/schemas'; + +import { + addIdToExceptionItemEntries, + removeIdFromExceptionItemsEntries, + transformInput, + transformOutput, +} from './transforms'; + +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('123'), +})); + +describe('Exceptions transforms', () => { + describe('transformOutput', () => { + it('returns same output as input with stripped ids per entry - CreateExceptionListItemSchema', () => { + const mockCreateExceptionItem = { + ...getCreateExceptionListItemSchemaMock(), + entries: ENTRIES_WITH_IDS, + }; + const output = transformOutput(mockCreateExceptionItem); + const expectedOutput: CreateExceptionListItemSchema = getCreateExceptionListItemSchemaMock(); + + expect(output).toEqual(expectedOutput); + }); + + it('returns same output as input with stripped ids per entry - UpdateExceptionListItemSchema', () => { + const mockUpdateExceptionItem = { + ...getUpdateExceptionListItemSchemaMock(), + entries: ENTRIES_WITH_IDS, + }; + const output = transformOutput(mockUpdateExceptionItem); + const expectedOutput: UpdateExceptionListItemSchema = getUpdateExceptionListItemSchemaMock(); + + expect(output).toEqual(expectedOutput); + }); + }); + + describe('transformInput', () => { + it('returns same output as input with added ids per entry', () => { + const mockExceptionItem = getExceptionListItemSchemaMock(); + const output = transformInput(mockExceptionItem); + const expectedOutput: ExceptionListItemSchema = { + ...getExceptionListItemSchemaMock(), + entries: ENTRIES_WITH_IDS, + }; + + expect(output).toEqual(expectedOutput); + }); + }); + + describe('addIdToExceptionItemEntries', () => { + it('returns same output as input with added ids per entry', () => { + const mockExceptionItem: ExceptionListItemSchema = { + ...getExceptionListItemSchemaMock(), + entries: [ + { + field: 'some.not.nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + }; + const output = addIdToExceptionItemEntries(mockExceptionItem); + const expectedOutput: ExceptionListItemSchema = { + ...getExceptionListItemSchemaMock(), + entries: [ + { + field: 'some.not.nested.field', + id: '123', + operator: 'included', + type: 'match', + value: 'some value', + } as Entry & { id: string }, + ], + }; + + expect(output).toEqual(expectedOutput); + }); + + it('returns same output as input with added ids per nested entry', () => { + const mockExceptionItem: ExceptionListItemSchema = { + ...getExceptionListItemSchemaMock(), + entries: [ + { + entries: [ + { + field: 'nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + field: 'some.parentField', + type: 'nested', + }, + ], + }; + const output = addIdToExceptionItemEntries(mockExceptionItem); + const expectedOutput: ExceptionListItemSchema = { + ...getExceptionListItemSchemaMock(), + entries: [ + { + entries: [ + { + field: 'nested.field', + id: '123', + operator: 'included', + type: 'match', + value: 'some value', + } as EntryMatch & { id: string }, + ], + field: 'some.parentField', + id: '123', + type: 'nested', + } as EntryNested & { id: string }, + ], + }; + + expect(output).toEqual(expectedOutput); + }); + }); + + describe('removeIdFromExceptionItemsEntries', () => { + it('returns same output as input with stripped ids per entry - CreateExceptionListItemSchema', () => { + const mockCreateExceptionItem = { + ...getCreateExceptionListItemSchemaMock(), + entries: [ + { + field: 'some.not.nested.field', + id: '123', + operator: 'included', + type: 'match', + value: 'some value', + } as Entry & { id: string }, + ], + }; + const output = removeIdFromExceptionItemsEntries(mockCreateExceptionItem); + const expectedOutput: CreateExceptionListItemSchema = { + ...getCreateExceptionListItemSchemaMock(), + entries: [ + { + field: 'some.not.nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + }; + + expect(output).toEqual(expectedOutput); + }); + + it('returns same output as input with stripped ids per nested entry - CreateExceptionListItemSchema', () => { + const mockCreateExceptionItem = { + ...getCreateExceptionListItemSchemaMock(), + entries: [ + { + entries: [ + { + field: 'nested.field', + id: '123', + operator: 'included', + type: 'match', + value: 'some value', + } as EntryMatch & { id: string }, + ], + field: 'some.parentField', + id: '123', + type: 'nested', + } as EntryNested & { id: string }, + ], + }; + const output = removeIdFromExceptionItemsEntries(mockCreateExceptionItem); + const expectedOutput: CreateExceptionListItemSchema = { + ...getCreateExceptionListItemSchemaMock(), + entries: [ + { + entries: [ + { + field: 'nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + field: 'some.parentField', + type: 'nested', + }, + ], + }; + + expect(output).toEqual(expectedOutput); + }); + + it('returns same output as input with stripped ids per entry - UpdateExceptionListItemSchema', () => { + const mockUpdateExceptionItem = { + ...getUpdateExceptionListItemSchemaMock(), + entries: [ + { + field: 'some.not.nested.field', + id: '123', + operator: 'included', + type: 'match', + value: 'some value', + } as Entry & { id: string }, + ], + }; + const output = removeIdFromExceptionItemsEntries(mockUpdateExceptionItem); + const expectedOutput: UpdateExceptionListItemSchema = { + ...getUpdateExceptionListItemSchemaMock(), + entries: [ + { + field: 'some.not.nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + }; + + expect(output).toEqual(expectedOutput); + }); + + it('returns same output as input with stripped ids per nested entry - UpdateExceptionListItemSchema', () => { + const mockUpdateExceptionItem = { + ...getUpdateExceptionListItemSchemaMock(), + entries: [ + { + entries: [ + { + field: 'nested.field', + id: '123', + operator: 'included', + type: 'match', + value: 'some value', + } as EntryMatch & { id: string }, + ], + field: 'some.parentField', + id: '123', + type: 'nested', + } as EntryNested & { id: string }, + ], + }; + const output = removeIdFromExceptionItemsEntries(mockUpdateExceptionItem); + const expectedOutput: UpdateExceptionListItemSchema = { + ...getUpdateExceptionListItemSchemaMock(), + entries: [ + { + entries: [ + { + field: 'nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + field: 'some.parentField', + type: 'nested', + }, + ], + }; + + expect(output).toEqual(expectedOutput); + }); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/transforms.ts b/x-pack/plugins/lists/public/exceptions/transforms.ts new file mode 100644 index 0000000000000..0791760611bf5 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/transforms.ts @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { flow } from 'fp-ts/lib/function'; + +import { + CreateExceptionListItemSchema, + EntriesArray, + Entry, + ExceptionListItemSchema, + UpdateExceptionListItemSchema, +} from '../../common'; +import { addIdToItem, removeIdFromItem } from '../../common/shared_imports'; + +// These are a collection of transforms that are UI specific and useful for UI concerns +// that are inserted between the API and the actual user interface. In some ways these +// might be viewed as technical debt or to compensate for the differences and preferences +// of how ReactJS might prefer data vs. how we want to model data. Each function should have +// a description giving context around the transform. + +/** + * Transforms the output of exception items to compensate for technical debt or UI concerns such as + * ReactJS preferences for having ids within arrays if the data is not modeled that way. + * + * If you add a new transform of the output called "myNewTransform" do it + * in the form of: + * flow(removeIdFromExceptionItemsEntries, myNewTransform)(exceptionItem) + * + * @param exceptionItem The exceptionItem to transform the output of + * @returns The exceptionItem transformed from the output + */ +export const transformOutput = ( + exceptionItem: UpdateExceptionListItemSchema | ExceptionListItemSchema +): UpdateExceptionListItemSchema | ExceptionListItemSchema => + flow(removeIdFromExceptionItemsEntries)(exceptionItem); + +export const transformNewItemOutput = ( + exceptionItem: CreateExceptionListItemSchema +): CreateExceptionListItemSchema => flow(removeIdFromExceptionItemsEntries)(exceptionItem); + +/** + * Transforms the output of rules to compensate for technical debt or UI concerns such as + * ReactJS preferences for having ids within arrays if the data is not modeled that way. + * + * If you add a new transform of the input called "myNewTransform" do it + * in the form of: + * flow(addIdToExceptionItemEntries, myNewTransform)(exceptionItem) + * + * @param exceptionItem The exceptionItem to transform the output of + * @returns The exceptionItem transformed from the output + */ +export const transformInput = (exceptionItem: ExceptionListItemSchema): ExceptionListItemSchema => + flow(addIdToExceptionItemEntries)(exceptionItem); + +/** + * This adds an id to the incoming exception item entries as ReactJS prefers to have + * an id added to them for use as a stable id. Later if we decide to change the data + * model to have id's within the array then this code should be removed. If not, then + * this code should stay as an adapter for ReactJS. + * + * This does break the type system slightly as we are lying a bit to the type system as we return + * the same exceptionItem as we have previously but are augmenting the arrays with an id which TypeScript + * doesn't mind us doing here. However, downstream you will notice that you have an id when the type + * does not indicate it. In that case use (ExceptionItem & { id: string }) temporarily if you're using the id. If you're not, + * you can ignore the id and just use the normal TypeScript with ReactJS. + * + * @param exceptionItem The exceptionItem to add an id to the threat matches. + * @returns exceptionItem The exceptionItem but with id added to the exception item entries + */ +export const addIdToExceptionItemEntries = ( + exceptionItem: ExceptionListItemSchema +): ExceptionListItemSchema => { + const entries = exceptionItem.entries.map((entry) => { + if (entry.type === 'nested') { + return addIdToItem({ + ...entry, + entries: entry.entries.map((nestedEntry) => addIdToItem(nestedEntry)), + }); + } else { + return addIdToItem(entry); + } + }); + return { ...exceptionItem, entries }; +}; + +/** + * This removes an id from the exceptionItem entries as ReactJS prefers to have + * an id added to them for use as a stable id. Later if we decide to change the data + * model to have id's within the array then this code should be removed. If not, then + * this code should stay as an adapter for ReactJS. + * + * @param exceptionItem The exceptionItem to remove an id from the entries. + * @returns exceptionItem The exceptionItem but with id removed from the entries + */ +export const removeIdFromExceptionItemsEntries = ( + exceptionItem: T +): T => { + const { entries } = exceptionItem; + const entriesNoId = entries.map((entry) => { + if (entry.type === 'nested') { + return removeIdFromItem({ + ...entry, + entries: entry.entries.map((nestedEntry) => removeIdFromItem(nestedEntry)), + }); + } else { + return removeIdFromItem(entry); + } + }); + return { ...exceptionItem, entries: entriesNoId }; +}; diff --git a/x-pack/plugins/lists/public/exceptions/types.ts b/x-pack/plugins/lists/public/exceptions/types.ts index e37c03978c9f6..03cae387711f8 100644 --- a/x-pack/plugins/lists/public/exceptions/types.ts +++ b/x-pack/plugins/lists/public/exceptions/types.ts @@ -33,8 +33,6 @@ export interface Pagination { export type AddExceptionList = UpdateExceptionListSchema | CreateExceptionListSchema; -export type AddExceptionListItem = CreateExceptionListItemSchema | UpdateExceptionListItemSchema; - export interface PersistHookProps { http: HttpStart; onError: (arg: Error) => void; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/time_range_selector/_time_range_selector.scss b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/time_range_selector/_time_range_selector.scss index d946a0cf94032..65a72a1d4a48d 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/time_range_selector/_time_range_selector.scss +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/time_range_selector/_time_range_selector.scss @@ -46,6 +46,7 @@ } .body { display: block; + height: 315px; } } & > li.has-body.active { diff --git a/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.test.ts b/x-pack/plugins/security_solution/common/add_remove_id_to_item.test.ts similarity index 100% rename from x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.test.ts rename to x-pack/plugins/security_solution/common/add_remove_id_to_item.test.ts diff --git a/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.ts b/x-pack/plugins/security_solution/common/add_remove_id_to_item.ts similarity index 100% rename from x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.ts rename to x-pack/plugins/security_solution/common/add_remove_id_to_item.ts diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 31b4cef1a9d45..c277ce369dca0 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -45,10 +45,10 @@ 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; -// Document path where threat indicator fields are expected. Used as -// both the source of enrichment fields and the destination for enrichment in -// the generated detection alert -export const DEFAULT_INDICATOR_PATH = 'threat.indicator'; +// Document path where threat indicator fields are expected. Fields are used +// to enrich signals, and are copied to threat.indicator. +export const DEFAULT_INDICATOR_SOURCE_PATH = 'threatintel.indicator'; +export const INDICATOR_DESTINATION_PATH = 'threat.indicator'; export enum SecurityPageName { detections = 'detections', diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.mock.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.mock.ts index 3bae9551f4df7..a51c1f77844d5 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.mock.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.mock.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { DEFAULT_INDICATOR_PATH } from '../../../constants'; +import { DEFAULT_INDICATOR_SOURCE_PATH } from '../../../constants'; import { MachineLearningCreateSchema, MachineLearningUpdateSchema, @@ -57,7 +57,7 @@ export const getCreateThreatMatchRulesSchemaMock = ( rule_id: ruleId, threat_query: '*:*', threat_index: ['list-index'], - threat_indicator_path: DEFAULT_INDICATOR_PATH, + threat_indicator_path: DEFAULT_INDICATOR_SOURCE_PATH, threat_mapping: [ { entries: [ diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts index 8dc7427ed0933..730e2949d7a11 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { DEFAULT_INDICATOR_PATH } from '../../../constants'; +import { DEFAULT_INDICATOR_SOURCE_PATH } from '../../../constants'; import { getListArrayMock } from '../types/lists.mock'; import { RulesSchema } from './rules_schema'; @@ -151,7 +151,7 @@ export const getThreatMatchingSchemaPartialMock = (enabled = false): Partial { + before(() => { + cleanKibana(); + loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); + waitForAlertsIndexToBeCreated(); + createCustomRule(newRule); + goToManageAlertsDetectionRules(); + goToRuleDetails(); + + cy.get(RULE_STATUS).should('have.text', '—'); + + // this is a made-up index that has just the necessary + // mappings to conduct tests, avoiding loading large + // amounts of data like in auditbeat_exceptions + esArchiverLoad('exceptions'); + + goToExceptionsTab(); + }); + + after(() => { + esArchiverUnload('exceptions'); + }); + + it('Does not overwrite values and-ed together', () => { + cy.get(ADD_EXCEPTIONS_BTN).click({ force: true }); + + // add multiple entries with invalid field values + addExceptionEntryFieldValue('agent.name', 0); + cy.get(ADD_AND_BTN).click(); + addExceptionEntryFieldValue('@timestamp', 1); + cy.get(ADD_AND_BTN).click(); + addExceptionEntryFieldValue('c', 2); + + // delete second item, invalid values 'a' and 'c' should remain + cy.get(ENTRY_DELETE_BTN).eq(1).click(); + cy.get(FIELD_INPUT).eq(0).should('have.text', 'agent.name'); + cy.get(FIELD_INPUT).eq(1).should('have.text', 'c'); + + closeExceptionBuilderModal(); + }); + + it('Does not overwrite values or-ed together', () => { + cy.get(ADD_EXCEPTIONS_BTN).click({ force: true }); + + // exception item 1 + addExceptionEntryFieldValueOfItemX('agent.name', 0, 0); + cy.get(ADD_AND_BTN).click(); + addExceptionEntryFieldValueOfItemX('user.id.keyword', 0, 1); + + // exception item 2 + cy.get(ADD_OR_BTN).click(); + addExceptionEntryFieldValueOfItemX('user.first', 1, 0); + cy.get(ADD_AND_BTN).click(); + addExceptionEntryFieldValueOfItemX('user.last', 1, 1); + cy.get(ADD_AND_BTN).click(); + addExceptionEntryFieldValueOfItemX('e', 1, 2); + + // delete single entry from exception item 2 + cy.get(ENTRY_DELETE_BTN).eq(3).click(); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(0) + .find(FIELD_INPUT) + .eq(0) + .should('have.text', 'agent.name'); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(0) + .find(FIELD_INPUT) + .eq(1) + .should('have.text', 'user.id.keyword'); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(1) + .find(FIELD_INPUT) + .eq(0) + .should('have.text', 'user.first'); + cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(1).should('have.text', 'e'); + + // delete remaining entries in exception item 2 + cy.get(ENTRY_DELETE_BTN).eq(2).click(); + cy.get(ENTRY_DELETE_BTN).eq(2).click(); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(0) + .find(FIELD_INPUT) + .eq(0) + .should('have.text', 'agent.name'); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(0) + .find(FIELD_INPUT) + .eq(1) + .should('have.text', 'user.id.keyword'); + cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).should('not.exist'); + + closeExceptionBuilderModal(); + }); + + it('Does not overwrite values of nested entry items', () => { + openExceptionModalFromRuleSettings(); + cy.get(LOADING_SPINNER).should('not.exist'); + + // exception item 1 + addExceptionEntryFieldValueOfItemX('agent.name', 0, 0); + cy.get(ADD_AND_BTN).click(); + addExceptionEntryFieldValueOfItemX('b', 0, 1); + + // exception item 2 with nested field + cy.get(ADD_OR_BTN).click(); + addExceptionEntryFieldValueOfItemX('c', 1, 0); + cy.get(ADD_NESTED_BTN).click(); + addExceptionEntryFieldValueOfItemX('user.id{downarrow}{enter}', 1, 1); + cy.get(ADD_AND_BTN).click(); + addExceptionEntryFieldValueOfItemX('last{downarrow}{enter}', 1, 3); + // This button will now read `Add non-nested button` + cy.get(ADD_NESTED_BTN).click(); + addExceptionEntryFieldValueOfItemX('@timestamp', 1, 4); + + // should have only deleted `user.id` + cy.get(ENTRY_DELETE_BTN).eq(4).click(); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(0) + .find(FIELD_INPUT) + .eq(0) + .should('have.text', 'agent.name'); + cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(1).should('have.text', 'b'); + cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(0).should('have.text', 'c'); + cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(1).should('have.text', 'user'); + cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(2).should('have.text', 'last'); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(1) + .find(FIELD_INPUT) + .eq(3) + .should('have.text', '@timestamp'); + + // deleting the last value of a nested entry, should delete the child and parent + cy.get(ENTRY_DELETE_BTN).eq(4).click(); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(0) + .find(FIELD_INPUT) + .eq(0) + .should('have.text', 'agent.name'); + cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(1).should('have.text', 'b'); + cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(0).should('have.text', 'c'); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(1) + .find(FIELD_INPUT) + .eq(1) + .should('have.text', '@timestamp'); + + closeExceptionBuilderModal(); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/screens/exceptions.ts b/x-pack/plugins/security_solution/cypress/screens/exceptions.ts index 7cd273b1db746..2479b76cf1de4 100644 --- a/x-pack/plugins/security_solution/cypress/screens/exceptions.ts +++ b/x-pack/plugins/security_solution/cypress/screens/exceptions.ts @@ -24,6 +24,20 @@ export const OPERATOR_INPUT = '[data-test-subj="operatorAutocompleteComboBox"]'; export const VALUES_INPUT = '[data-test-subj="valuesAutocompleteMatch"] [data-test-subj="comboBoxInput"]'; +export const ADD_AND_BTN = '[data-test-subj="exceptionsAndButton"]'; + +export const ADD_OR_BTN = '[data-test-subj="exceptionsOrButton"]'; + +export const ADD_NESTED_BTN = '[data-test-subj="exceptionsNestedButton"]'; + +export const ENTRY_DELETE_BTN = '[data-test-subj="builderItemEntryDeleteButton"]'; + +export const FIELD_INPUT_LIST_BTN = '[data-test-subj="comboBoxToggleListButton"]'; + +export const CANCEL_BTN = '[data-test-subj="cancelExceptionAddButton"]'; + +export const BUILDER_MODAL_BODY = '[data-test-subj="exceptionsBuilderWrapper"]'; + export const EXCEPTIONS_TABLE_TAB = '[data-test-subj="allRulesTableTab-exceptions"]'; export const EXCEPTIONS_TABLE = '[data-test-subj="exceptions-table"]'; @@ -43,3 +57,5 @@ export const EXCEPTIONS_TABLE_LIST_NAME = '[data-test-subj="exceptionsTableName" export const EXCEPTIONS_TABLE_MODAL = '[data-test-subj="referenceErrorModal"]'; export const EXCEPTIONS_TABLE_MODAL_CONFIRM_BTN = '[data-test-subj="confirmModalConfirmButton"]'; + +export const EXCEPTION_ITEM_CONTAINER = '[data-test-subj="exceptionEntriesContainer"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts index 529ef4afdfa63..10644e046a68b 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts @@ -116,9 +116,19 @@ export const reloadDeletedRules = () => { cy.get(RELOAD_PREBUILT_RULES_BTN).click({ force: true }); }; +/** + * Selects the number of rules. Since there can be missing click handlers + * when the page loads at first, we use a pipe and a trigger of click + * on it and then check to ensure that it is checked before continuing + * with the tests. + * @param numberOfRules The number of rules to click/check + */ export const selectNumberOfRules = (numberOfRules: number) => { for (let i = 0; i < numberOfRules; i++) { - cy.get(RULE_CHECKBOX).eq(i).click({ force: true }); + cy.get(RULE_CHECKBOX) + .eq(i) + .pipe(($el) => $el.trigger('click')) + .should('be.checked'); } }; diff --git a/x-pack/plugins/security_solution/cypress/tasks/exceptions.ts b/x-pack/plugins/security_solution/cypress/tasks/exceptions.ts new file mode 100644 index 0000000000000..97e93ef8194a4 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/tasks/exceptions.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Exception } from '../objects/exception'; +import { + FIELD_INPUT, + OPERATOR_INPUT, + VALUES_INPUT, + CANCEL_BTN, + BUILDER_MODAL_BODY, + EXCEPTION_ITEM_CONTAINER, +} from '../screens/exceptions'; + +export const addExceptionEntryFieldValueOfItemX = ( + field: string, + itemIndex = 0, + fieldIndex = 0 +) => { + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(itemIndex) + .find(FIELD_INPUT) + .eq(fieldIndex) + .type(`${field}{enter}`); + cy.get(BUILDER_MODAL_BODY).click(); +}; + +export const addExceptionEntryFieldValue = (field: string, index = 0) => { + cy.get(FIELD_INPUT).eq(index).type(`${field}{enter}`); + cy.get(BUILDER_MODAL_BODY).click(); +}; + +export const addExceptionEntryOperatorValue = (operator: string, index = 0) => { + cy.get(OPERATOR_INPUT).eq(index).type(`${operator}{enter}`); + cy.get(BUILDER_MODAL_BODY).click(); +}; + +export const addExceptionEntryValue = (values: string[], index = 0) => { + values.forEach((value) => { + cy.get(VALUES_INPUT).eq(index).type(`${value}{enter}`); + }); + cy.get(BUILDER_MODAL_BODY).click(); +}; + +export const addExceptionEntry = (exception: Exception, index = 0) => { + addExceptionEntryFieldValue(exception.field, index); + addExceptionEntryOperatorValue(exception.operator, index); + addExceptionEntryValue(exception.values, index); +}; + +export const addNestedExceptionEntry = (exception: Exception, index = 0) => { + addExceptionEntryFieldValue(exception.field, index); + addExceptionEntryOperatorValue(exception.operator, index); + addExceptionEntryValue(exception.values, index); +}; + +export const closeExceptionBuilderModal = () => { + cy.get(CANCEL_BTN).click(); +}; diff --git a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts index 57037e9f269b4..411f326a0ace6 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts @@ -54,6 +54,12 @@ export const addsException = (exception: Exception) => { cy.get(CONFIRM_BTN).should('not.exist'); }; +export const openExceptionModalFromRuleSettings = () => { + cy.get(ADD_EXCEPTIONS_BTN).click(); + cy.get(LOADING_SPINNER).should('not.exist'); + cy.get(FIELD_INPUT).should('be.visible'); +}; + export const addsExceptionFromRuleSettings = (exception: Exception) => { cy.get(ADD_EXCEPTIONS_BTN).click(); cy.get(LOADING_SPINNER).should('exist'); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_fields_by_issue_type.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_fields_by_issue_type.tsx index b7a8a45edce5e..03000e8916617 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_fields_by_issue_type.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_fields_by_issue_type.tsx @@ -35,19 +35,20 @@ export const useGetFieldsByIssueType = ({ }: Props): UseGetFieldsByIssueType => { const [isLoading, setIsLoading] = useState(true); const [fields, setFields] = useState({}); + const didCancel = useRef(false); const abortCtrl = useRef(new AbortController()); useEffect(() => { - let didCancel = false; const fetchData = async () => { if (!connector || !issueType) { setIsLoading(false); return; } - abortCtrl.current = new AbortController(); - setIsLoading(true); try { + abortCtrl.current = new AbortController(); + setIsLoading(true); + const res = await getFieldsByIssueType({ http, signal: abortCtrl.current.signal, @@ -55,7 +56,7 @@ export const useGetFieldsByIssueType = ({ id: issueType, }); - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); setFields(res.data ?? {}); if (res.status && res.status === 'error') { @@ -66,22 +67,24 @@ export const useGetFieldsByIssueType = ({ } } } catch (error) { - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); - toastNotifications.addDanger({ - title: i18n.FIELDS_API_ERROR, - text: error.message, - }); + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.FIELDS_API_ERROR, + text: error.message, + }); + } } } }; + didCancel.current = false; abortCtrl.current.abort(); fetchData(); return () => { - didCancel = true; - setIsLoading(false); + didCancel.current = true; abortCtrl.current.abort(); }; }, [http, connector, issueType, toastNotifications]); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issue_types.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issue_types.tsx index 4b60a9840c82b..3c35d315a2bcd 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issue_types.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issue_types.tsx @@ -35,27 +35,27 @@ export const useGetIssueTypes = ({ }: Props): UseGetIssueTypes => { const [isLoading, setIsLoading] = useState(true); const [issueTypes, setIssueTypes] = useState([]); + const didCancel = useRef(false); const abortCtrl = useRef(new AbortController()); useEffect(() => { - let didCancel = false; const fetchData = async () => { if (!connector) { setIsLoading(false); return; } - abortCtrl.current = new AbortController(); - setIsLoading(true); - try { + abortCtrl.current = new AbortController(); + setIsLoading(true); + const res = await getIssueTypes({ http, signal: abortCtrl.current.signal, connectorId: connector.id, }); - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); const asOptions = (res.data ?? []).map((type) => ({ text: type.name ?? '', @@ -71,25 +71,29 @@ export const useGetIssueTypes = ({ } } } catch (error) { - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); - toastNotifications.addDanger({ - title: i18n.ISSUE_TYPES_API_ERROR, - text: error.message, - }); + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.ISSUE_TYPES_API_ERROR, + text: error.message, + }); + } } } }; + didCancel.current = false; abortCtrl.current.abort(); fetchData(); return () => { - didCancel = true; - setIsLoading(false); + didCancel.current = true; abortCtrl.current.abort(); }; - }, [http, connector, toastNotifications, handleIssueType]); + // handleIssueType unmounts the component at init causing the request to be aborted + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [http, connector, toastNotifications]); return { issueTypes, diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issues.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issues.tsx index 170cf2b53395e..b44b0558f1536 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issues.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issues.tsx @@ -36,20 +36,20 @@ export const useGetIssues = ({ }: Props): UseGetIssues => { const [isLoading, setIsLoading] = useState(false); const [issues, setIssues] = useState([]); + const didCancel = useRef(false); const abortCtrl = useRef(new AbortController()); useEffect(() => { - let didCancel = false; const fetchData = debounce(500, async () => { if (!actionConnector || isEmpty(query)) { setIsLoading(false); return; } - abortCtrl.current = new AbortController(); - setIsLoading(true); - try { + abortCtrl.current = new AbortController(); + setIsLoading(true); + const res = await getIssues({ http, signal: abortCtrl.current.signal, @@ -57,7 +57,7 @@ export const useGetIssues = ({ title: query ?? '', }); - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); setIssues(res.data ?? []); if (res.status && res.status === 'error') { @@ -68,22 +68,24 @@ export const useGetIssues = ({ } } } catch (error) { - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); - toastNotifications.addDanger({ - title: i18n.ISSUES_API_ERROR, - text: error.message, - }); + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.ISSUES_API_ERROR, + text: error.message, + }); + } } } }); + didCancel.current = false; abortCtrl.current.abort(); fetchData(); return () => { - didCancel = true; - setIsLoading(false); + didCancel.current = true; abortCtrl.current.abort(); }; }, [http, actionConnector, toastNotifications, query]); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_single_issue.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_single_issue.tsx index 89b42b1a88c1e..6c70286426168 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_single_issue.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_single_issue.tsx @@ -35,10 +35,10 @@ export const useGetSingleIssue = ({ }: Props): UseGetSingleIssue => { const [isLoading, setIsLoading] = useState(false); const [issue, setIssue] = useState(null); + const didCancel = useRef(false); const abortCtrl = useRef(new AbortController()); useEffect(() => { - let didCancel = false; const fetchData = async () => { if (!actionConnector || !id) { setIsLoading(false); @@ -55,7 +55,7 @@ export const useGetSingleIssue = ({ id, }); - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); setIssue(res.data ?? null); if (res.status && res.status === 'error') { @@ -66,22 +66,24 @@ export const useGetSingleIssue = ({ } } } catch (error) { - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); - toastNotifications.addDanger({ - title: i18n.GET_ISSUE_API_ERROR(id), - text: error.message, - }); + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.GET_ISSUE_API_ERROR(id), + text: error.message, + }); + } } } }; + didCancel.current = false; abortCtrl.current.abort(); fetchData(); return () => { - didCancel = true; - setIsLoading(false); + didCancel.current = true; abortCtrl.current.abort(); }; }, [http, actionConnector, id, toastNotifications]); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_incident_types.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_incident_types.tsx index 99964f466058f..34cbb0a69b0f4 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_incident_types.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_incident_types.tsx @@ -34,27 +34,27 @@ export const useGetIncidentTypes = ({ }: Props): UseGetIncidentTypes => { const [isLoading, setIsLoading] = useState(true); const [incidentTypes, setIncidentTypes] = useState([]); + const didCancel = useRef(false); const abortCtrl = useRef(new AbortController()); useEffect(() => { - let didCancel = false; const fetchData = async () => { if (!connector) { setIsLoading(false); return; } - abortCtrl.current = new AbortController(); - setIsLoading(true); - try { + abortCtrl.current = new AbortController(); + setIsLoading(true); + const res = await getIncidentTypes({ http, signal: abortCtrl.current.signal, connectorId: connector.id, }); - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); setIncidentTypes(res.data ?? []); if (res.status && res.status === 'error') { @@ -65,22 +65,24 @@ export const useGetIncidentTypes = ({ } } } catch (error) { - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); - toastNotifications.addDanger({ - title: i18n.INCIDENT_TYPES_API_ERROR, - text: error.message, - }); + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.INCIDENT_TYPES_API_ERROR, + text: error.message, + }); + } } } }; + didCancel.current = false; abortCtrl.current.abort(); fetchData(); return () => { - didCancel = true; - setIsLoading(false); + didCancel.current = true; abortCtrl.current.abort(); }; }, [http, connector, toastNotifications]); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_severity.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_severity.tsx index 0a71891ae41b2..5b44c6b4a32b2 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_severity.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_severity.tsx @@ -7,9 +7,9 @@ import { useState, useEffect, useRef } from 'react'; import { HttpSetup, ToastsApi } from 'kibana/public'; +import { ActionConnector } from '../../../containers/types'; import { getSeverity } from './api'; import * as i18n from './translations'; -import { ActionConnector } from '../../../containers/types'; type Severity = Array<{ id: number; name: string }>; @@ -31,26 +31,26 @@ export const useGetSeverity = ({ http, toastNotifications, connector }: Props): const [isLoading, setIsLoading] = useState(true); const [severity, setSeverity] = useState([]); const abortCtrl = useRef(new AbortController()); + const didCancel = useRef(false); useEffect(() => { - let didCancel = false; const fetchData = async () => { if (!connector) { setIsLoading(false); return; } - abortCtrl.current = new AbortController(); - setIsLoading(true); - try { + abortCtrl.current = new AbortController(); + setIsLoading(true); + const res = await getSeverity({ http, signal: abortCtrl.current.signal, connectorId: connector.id, }); - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); setSeverity(res.data ?? []); @@ -62,22 +62,24 @@ export const useGetSeverity = ({ http, toastNotifications, connector }: Props): } } } catch (error) { - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); - toastNotifications.addDanger({ - title: i18n.SEVERITY_API_ERROR, - text: error.message, - }); + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.SEVERITY_API_ERROR, + text: error.message, + }); + } } } }; + didCancel.current = false; abortCtrl.current.abort(); fetchData(); return () => { - didCancel = true; - setIsLoading(false); + didCancel.current = true; abortCtrl.current.abort(); }; }, [http, connector, toastNotifications]); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.tsx index 16e905bdabfee..a979f96d84ab2 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.tsx @@ -37,20 +37,20 @@ export const useGetChoices = ({ }: UseGetChoicesProps): UseGetChoices => { const [isLoading, setIsLoading] = useState(false); const [choices, setChoices] = useState([]); + const didCancel = useRef(false); const abortCtrl = useRef(new AbortController()); useEffect(() => { - let didCancel = false; const fetchData = async () => { if (!connector) { setIsLoading(false); return; } - abortCtrl.current = new AbortController(); - setIsLoading(true); - try { + abortCtrl.current = new AbortController(); + setIsLoading(true); + const res = await getChoices({ http, signal: abortCtrl.current.signal, @@ -58,7 +58,7 @@ export const useGetChoices = ({ fields, }); - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); setChoices(res.data ?? []); if (res.status && res.status === 'error') { @@ -71,22 +71,24 @@ export const useGetChoices = ({ } } } catch (error) { - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); - toastNotifications.addDanger({ - title: i18n.CHOICES_API_ERROR, - text: error.message, - }); + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.CHOICES_API_ERROR, + text: error.message, + }); + } } } }; + didCancel.current = false; abortCtrl.current.abort(); fetchData(); return () => { - didCancel = true; - setIsLoading(false); + didCancel.current = true; abortCtrl.current.abort(); }; // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.tsx b/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.tsx index ff5762b8476de..3590fffdef5b2 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.tsx @@ -22,25 +22,25 @@ export const useActionTypes = (): UseActionTypesResponse => { const [, dispatchToaster] = useStateToaster(); const [loading, setLoading] = useState(true); const [actionTypes, setActionTypes] = useState([]); - const didCancel = useRef(false); - const abortCtrl = useRef(new AbortController()); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); const queryFirstTime = useRef(true); const refetchActionTypes = useCallback(async () => { try { setLoading(true); - didCancel.current = false; - abortCtrl.current.abort(); - abortCtrl.current = new AbortController(); + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); - const res = await fetchActionTypes({ signal: abortCtrl.current.signal }); + const res = await fetchActionTypes({ signal: abortCtrlRef.current.signal }); - if (!didCancel.current) { + if (!isCancelledRef.current) { setLoading(false); setActionTypes(res); } } catch (error) { - if (!didCancel.current) { + if (!isCancelledRef.current) { setLoading(false); setActionTypes([]); errorToToaster({ @@ -59,8 +59,8 @@ export const useActionTypes = (): UseActionTypesResponse => { } return () => { - didCancel.current = true; - abortCtrl.current.abort(); + isCancelledRef.current = true; + abortCtrlRef.current.abort(); queryFirstTime.current = true; }; }, [refetchActionTypes]); 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 cc8c93fc990eb..21d1832796ba8 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 @@ -5,7 +5,7 @@ * 2.0. */ -import { useEffect, useCallback, useReducer } from 'react'; +import { useEffect, useCallback, useReducer, useRef } from 'react'; import { getCaseConfigure, patchCaseConfigure, postCaseConfigure } from './api'; import { @@ -207,129 +207,128 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { }, []); const [, dispatchToaster] = useStateToaster(); + const isCancelledRefetchRef = useRef(false); + const abortCtrlRefetchRef = useRef(new AbortController()); - const refetchCaseConfigure = useCallback(() => { - let didCancel = false; - const abortCtrl = new AbortController(); + const isCancelledPersistRef = useRef(false); + const abortCtrlPersistRef = useRef(new AbortController()); - const fetchCaseConfiguration = async () => { - try { - setLoading(true); - const res = await getCaseConfigure({ signal: abortCtrl.signal }); - if (!didCancel) { - if (res != null) { - setConnector(res.connector); - if (setClosureType != null) { - setClosureType(res.closureType); - } - setVersion(res.version); - setMappings(res.mappings); + const refetchCaseConfigure = useCallback(async () => { + try { + isCancelledRefetchRef.current = false; + abortCtrlRefetchRef.current.abort(); + abortCtrlRefetchRef.current = new AbortController(); - if (!state.firstLoad) { - setFirstLoad(true); - if (setCurrentConfiguration != null) { - setCurrentConfiguration({ - closureType: res.closureType, - connector: { - ...res.connector, - }, - }); - } - } - if (res.error != null) { - errorToToaster({ - dispatchToaster, - error: new Error(res.error), - title: i18n.ERROR_TITLE, + setLoading(true); + const res = await getCaseConfigure({ signal: abortCtrlRefetchRef.current.signal }); + + if (!isCancelledRefetchRef.current) { + if (res != null) { + setConnector(res.connector); + if (setClosureType != null) { + setClosureType(res.closureType); + } + setVersion(res.version); + setMappings(res.mappings); + + if (!state.firstLoad) { + setFirstLoad(true); + if (setCurrentConfiguration != null) { + setCurrentConfiguration({ + closureType: res.closureType, + connector: { + ...res.connector, + }, }); } } - setLoading(false); + if (res.error != null) { + errorToToaster({ + dispatchToaster, + error: new Error(res.error), + title: i18n.ERROR_TITLE, + }); + } } - } catch (error) { - if (!didCancel) { - setLoading(false); + setLoading(false); + } + } catch (error) { + if (!isCancelledRefetchRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ dispatchToaster, error: error.body && error.body.message ? new Error(error.body.message) : error, title: i18n.ERROR_TITLE, }); } + setLoading(false); } - }; - - fetchCaseConfiguration(); - - return () => { - didCancel = true; - abortCtrl.abort(); - }; + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [state.firstLoad]); const persistCaseConfigure = useCallback( async ({ connector, closureType }: ConnectorConfiguration) => { - let didCancel = false; - const abortCtrl = new AbortController(); - const saveCaseConfiguration = async () => { - try { - setPersistLoading(true); - const connectorObj = { - connector, - closure_type: closureType, - }; - const res = - state.version.length === 0 - ? await postCaseConfigure(connectorObj, abortCtrl.signal) - : await patchCaseConfigure( - { - ...connectorObj, - version: state.version, - }, - abortCtrl.signal - ); - if (!didCancel) { - setConnector(res.connector); - if (setClosureType) { - setClosureType(res.closureType); - } - setVersion(res.version); - setMappings(res.mappings); - if (setCurrentConfiguration != null) { - setCurrentConfiguration({ - closureType: res.closureType, - connector: { - ...res.connector, + try { + isCancelledPersistRef.current = false; + abortCtrlPersistRef.current.abort(); + abortCtrlPersistRef.current = new AbortController(); + setPersistLoading(true); + + const connectorObj = { + connector, + closure_type: closureType, + }; + + const res = + state.version.length === 0 + ? await postCaseConfigure(connectorObj, abortCtrlPersistRef.current.signal) + : await patchCaseConfigure( + { + ...connectorObj, + version: state.version, }, - }); - } - if (res.error != null) { - errorToToaster({ - dispatchToaster, - error: new Error(res.error), - title: i18n.ERROR_TITLE, - }); - } - displaySuccessToast(i18n.SUCCESS_CONFIGURE, dispatchToaster); - setPersistLoading(false); + abortCtrlPersistRef.current.signal + ); + + if (!isCancelledPersistRef.current) { + setConnector(res.connector); + if (setClosureType) { + setClosureType(res.closureType); + } + setVersion(res.version); + setMappings(res.mappings); + if (setCurrentConfiguration != null) { + setCurrentConfiguration({ + closureType: res.closureType, + connector: { + ...res.connector, + }, + }); } - } catch (error) { - if (!didCancel) { - setConnector(state.currentConfiguration.connector); - setPersistLoading(false); + if (res.error != null) { + errorToToaster({ + dispatchToaster, + error: new Error(res.error), + title: i18n.ERROR_TITLE, + }); + } + displaySuccessToast(i18n.SUCCESS_CONFIGURE, dispatchToaster); + setPersistLoading(false); + } + } catch (error) { + if (!isCancelledPersistRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); } + setConnector(state.currentConfiguration.connector); + setPersistLoading(false); } - }; - saveCaseConfiguration(); - return () => { - didCancel = true; - abortCtrl.abort(); - }; + } }, [ dispatchToaster, @@ -345,6 +344,12 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { useEffect(() => { refetchCaseConfigure(); + return () => { + isCancelledRefetchRef.current = true; + abortCtrlRefetchRef.current.abort(); + isCancelledPersistRef.current = true; + abortCtrlPersistRef.current.abort(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/use_connectors.tsx b/x-pack/plugins/security_solution/public/cases/containers/configure/use_connectors.tsx index d21e50902ca83..338d04f702c63 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/use_connectors.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/use_connectors.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { useStateToaster, errorToToaster } from '../../../common/components/toasters'; import * as i18n from '../translations'; @@ -22,40 +22,45 @@ export const useConnectors = (): UseConnectorsResponse => { const [, dispatchToaster] = useStateToaster(); const [loading, setLoading] = useState(true); const [connectors, setConnectors] = useState([]); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); - const refetchConnectors = useCallback(() => { - let didCancel = false; - const abortCtrl = new AbortController(); - const getConnectors = async () => { - try { - setLoading(true); - const res = await fetchConnectors({ signal: abortCtrl.signal }); - if (!didCancel) { - setLoading(false); - setConnectors(res); - } - } catch (error) { - if (!didCancel) { - setLoading(false); - setConnectors([]); + const refetchConnectors = useCallback(async () => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); + + setLoading(true); + const res = await fetchConnectors({ signal: abortCtrlRef.current.signal }); + + if (!isCancelledRef.current) { + setLoading(false); + setConnectors(res); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); } + + setLoading(false); + setConnectors([]); } - }; - getConnectors(); - return () => { - didCancel = true; - abortCtrl.abort(); - }; + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { refetchConnectors(); + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.tsx index 0fe45aaab799b..da069ee6f1075 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { useCallback, useReducer } from 'react'; +import { useCallback, useReducer, useRef, useEffect } from 'react'; import { CaseStatuses } from '../../../../case/common/api'; import { displaySuccessToast, @@ -87,49 +87,45 @@ export const useUpdateCases = (): UseUpdateCases => { isUpdated: false, }); const [, dispatchToaster] = useStateToaster(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); - const dispatchUpdateCases = useCallback((cases: BulkUpdateStatus[], action: string) => { - let cancel = false; - const abortCtrl = new AbortController(); + const dispatchUpdateCases = useCallback(async (cases: BulkUpdateStatus[], action: string) => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); - const patchData = async () => { - try { - dispatch({ type: 'FETCH_INIT' }); - const patchResponse = await patchCasesStatus(cases, abortCtrl.signal); - if (!cancel) { - const resultCount = Object.keys(patchResponse).length; - const firstTitle = patchResponse[0].title; + dispatch({ type: 'FETCH_INIT' }); + const patchResponse = await patchCasesStatus(cases, abortCtrlRef.current.signal); - dispatch({ type: 'FETCH_SUCCESS', payload: true }); + if (!isCancelledRef.current) { + const resultCount = Object.keys(patchResponse).length; + const firstTitle = patchResponse[0].title; - const messageArgs = { - totalCases: resultCount, - caseTitle: resultCount === 1 ? firstTitle : '', - }; + dispatch({ type: 'FETCH_SUCCESS', payload: true }); + const messageArgs = { + totalCases: resultCount, + caseTitle: resultCount === 1 ? firstTitle : '', + }; - const message = - action === 'status' - ? getStatusToasterMessage(patchResponse[0].status, messageArgs) - : ''; + const message = + action === 'status' ? getStatusToasterMessage(patchResponse[0].status, messageArgs) : ''; - displaySuccessToast(message, dispatchToaster); - } - } catch (error) { - if (!cancel) { + displaySuccessToast(message, dispatchToaster); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - dispatch({ type: 'FETCH_FAILURE' }); } + dispatch({ type: 'FETCH_FAILURE' }); } - }; - patchData(); - return () => { - cancel = true; - abortCtrl.abort(); - }; + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -137,14 +133,25 @@ export const useUpdateCases = (): UseUpdateCases => { dispatch({ type: 'RESET_IS_UPDATED' }); }, []); - const updateBulkStatus = useCallback((cases: Case[], status: string) => { - const updateCasesStatus: BulkUpdateStatus[] = cases.map((theCase) => ({ - status, - id: theCase.id, - version: theCase.version, - })); - dispatchUpdateCases(updateCasesStatus, 'status'); + const updateBulkStatus = useCallback( + (cases: Case[], status: string) => { + const updateCasesStatus: BulkUpdateStatus[] = cases.map((theCase) => ({ + status, + id: theCase.id, + version: theCase.version, + })); + dispatchUpdateCases(updateCasesStatus, 'status'); + }, // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + useEffect(() => { + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; }, []); + return { ...state, updateBulkStatus, dispatchResetIsUpdated }; }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_delete_cases.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_delete_cases.tsx index 923c20dcf8ebd..f3d59a2883f2a 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_delete_cases.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_delete_cases.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { useCallback, useReducer } from 'react'; +import { useCallback, useReducer, useRef, useEffect } from 'react'; import { displaySuccessToast, errorToToaster, @@ -78,45 +78,43 @@ export const useDeleteCases = (): UseDeleteCase => { isDeleted: false, }); const [, dispatchToaster] = useStateToaster(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); - const dispatchDeleteCases = useCallback((cases: DeleteCase[]) => { - let cancel = false; - const abortCtrl = new AbortController(); + const dispatchDeleteCases = useCallback(async (cases: DeleteCase[]) => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); + dispatch({ type: 'FETCH_INIT' }); - const deleteData = async () => { - try { - dispatch({ type: 'FETCH_INIT' }); - const caseIds = cases.map((theCase) => theCase.id); - // We don't allow user batch delete sub cases on UI at the moment. - if (cases[0].type != null || cases.length > 1) { - await deleteCases(caseIds, abortCtrl.signal); - } else { - await deleteSubCases(caseIds, abortCtrl.signal); - } + const caseIds = cases.map((theCase) => theCase.id); + // We don't allow user batch delete sub cases on UI at the moment. + if (cases[0].type != null || cases.length > 1) { + await deleteCases(caseIds, abortCtrlRef.current.signal); + } else { + await deleteSubCases(caseIds, abortCtrlRef.current.signal); + } - if (!cancel) { - dispatch({ type: 'FETCH_SUCCESS', payload: true }); - displaySuccessToast( - i18n.DELETED_CASES(cases.length, cases.length === 1 ? cases[0].title : ''), - dispatchToaster - ); - } - } catch (error) { - if (!cancel) { + if (!isCancelledRef.current) { + dispatch({ type: 'FETCH_SUCCESS', payload: true }); + displaySuccessToast( + i18n.DELETED_CASES(cases.length, cases.length === 1 ? cases[0].title : ''), + dispatchToaster + ); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_DELETING, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - dispatch({ type: 'FETCH_FAILURE' }); } + dispatch({ type: 'FETCH_FAILURE' }); } - }; - deleteData(); - return () => { - abortCtrl.abort(); - cancel = true; - }; + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -142,5 +140,12 @@ export const useDeleteCases = (): UseDeleteCase => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [state.isDisplayConfirmDeleteModal]); + useEffect(() => { + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; + }, []); + return { ...state, dispatchResetIsDeleted, handleOnDeleteConfirm, handleToggleModal }; }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.tsx index 9b536f32e7eb8..9b10247794c8d 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState, useRef } from 'react'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; import { getActionLicense } from './api'; @@ -28,53 +28,58 @@ const MINIMUM_LICENSE_REQUIRED_CONNECTOR = '.jira'; export const useGetActionLicense = (): ActionLicenseState => { const [actionLicenseState, setActionLicensesState] = useState(initialData); - const [, dispatchToaster] = useStateToaster(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); - const fetchActionLicense = useCallback(() => { - let didCancel = false; - const abortCtrl = new AbortController(); - const fetchData = async () => { + const fetchActionLicense = useCallback(async () => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); setActionLicensesState({ - ...actionLicenseState, + ...initialData, isLoading: true, }); - try { - const response = await getActionLicense(abortCtrl.signal); - if (!didCancel) { - setActionLicensesState({ - actionLicense: - response.find((l) => l.id === MINIMUM_LICENSE_REQUIRED_CONNECTOR) ?? null, - isLoading: false, - isError: false, - }); - } - } catch (error) { - if (!didCancel) { + + const response = await getActionLicense(abortCtrlRef.current.signal); + + if (!isCancelledRef.current) { + setActionLicensesState({ + actionLicense: response.find((l) => l.id === MINIMUM_LICENSE_REQUIRED_CONNECTOR) ?? null, + isLoading: false, + isError: false, + }); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - setActionLicensesState({ - actionLicense: null, - isLoading: false, - isError: true, - }); } + + setActionLicensesState({ + actionLicense: null, + isLoading: false, + isError: true, + }); } - }; - fetchData(); - return () => { - didCancel = true; - abortCtrl.abort(); - }; + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [actionLicenseState]); useEffect(() => { fetchActionLicense(); + + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + return { ...actionLicenseState }; }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx index 1c4476e3cb2b7..fb8da8d0663ee 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import { isEmpty } from 'lodash'; import { useEffect, useReducer, useCallback, useRef } from 'react'; import { CaseStatuses, CaseType } from '../../../../case/common/api'; @@ -96,48 +95,48 @@ export const useGetCase = (caseId: string, subCaseId?: string): UseGetCase => { data: initialData, }); const [, dispatchToaster] = useStateToaster(); - const abortCtrl = useRef(new AbortController()); - const didCancel = useRef(false); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); const updateCase = useCallback((newCase: Case) => { dispatch({ type: 'UPDATE_CASE', payload: newCase }); }, []); const callFetch = useCallback(async () => { - const fetchData = async () => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); dispatch({ type: 'FETCH_INIT' }); - try { - const response = await (subCaseId - ? getSubCase(caseId, subCaseId, true, abortCtrl.current.signal) - : getCase(caseId, true, abortCtrl.current.signal)); - if (!didCancel.current) { - dispatch({ type: 'FETCH_SUCCESS', payload: response }); - } - } catch (error) { - if (!didCancel.current) { + + const response = await (subCaseId + ? getSubCase(caseId, subCaseId, true, abortCtrlRef.current.signal) + : getCase(caseId, true, abortCtrlRef.current.signal)); + + if (!isCancelledRef.current) { + dispatch({ type: 'FETCH_SUCCESS', payload: response }); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - dispatch({ type: 'FETCH_FAILURE' }); } + dispatch({ type: 'FETCH_FAILURE' }); } - }; - didCancel.current = false; - abortCtrl.current.abort(); - abortCtrl.current = new AbortController(); - fetchData(); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [caseId, subCaseId]); useEffect(() => { - if (!isEmpty(caseId)) { - callFetch(); - } + callFetch(); + return () => { - didCancel.current = true; - abortCtrl.current.abort(); + isCancelledRef.current = true; + abortCtrlRef.current.abort(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [caseId, subCaseId]); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.tsx index 12e5f6643351f..cc8deaf72eef6 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.tsx @@ -6,7 +6,7 @@ */ import { isEmpty, uniqBy } from 'lodash/fp'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useState, useRef } from 'react'; import deepEqual from 'fast-deep-equal'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; @@ -244,64 +244,67 @@ export const useGetCaseUserActions = ( const [caseUserActionsState, setCaseUserActionsState] = useState( initialData ); - const abortCtrl = useRef(new AbortController()); - const didCancel = useRef(false); + const abortCtrlRef = useRef(new AbortController()); + const isCancelledRef = useRef(false); const [, dispatchToaster] = useStateToaster(); const fetchCaseUserActions = useCallback( - (thisCaseId: string, thisSubCaseId?: string) => { - const fetchData = async () => { - try { + async (thisCaseId: string, thisSubCaseId?: string) => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); + setCaseUserActionsState({ + ...caseUserActionsState, + isLoading: true, + }); + + const response = await (thisSubCaseId + ? getSubCaseUserActions(thisCaseId, thisSubCaseId, abortCtrlRef.current.signal) + : getCaseUserActions(thisCaseId, abortCtrlRef.current.signal)); + + if (!isCancelledRef.current) { + // Attention Future developer + // We are removing the first item because it will always be the creation of the case + // and we do not want it to simplify our life + const participants = !isEmpty(response) + ? uniqBy('actionBy.username', response).map((cau) => cau.actionBy) + : []; + + const caseUserActions = !isEmpty(response) + ? thisSubCaseId + ? response + : response.slice(1) + : []; + setCaseUserActionsState({ - ...caseUserActionsState, - isLoading: true, + caseUserActions, + ...getPushedInfo(caseUserActions, caseConnectorId), + isLoading: false, + isError: false, + participants, }); - - const response = await (thisSubCaseId - ? getSubCaseUserActions(thisCaseId, thisSubCaseId, abortCtrl.current.signal) - : getCaseUserActions(thisCaseId, abortCtrl.current.signal)); - if (!didCancel.current) { - // Attention Future developer - // We are removing the first item because it will always be the creation of the case - // and we do not want it to simplify our life - const participants = !isEmpty(response) - ? uniqBy('actionBy.username', response).map((cau) => cau.actionBy) - : []; - - const caseUserActions = !isEmpty(response) - ? thisSubCaseId - ? response - : response.slice(1) - : []; - setCaseUserActionsState({ - caseUserActions, - ...getPushedInfo(caseUserActions, caseConnectorId), - isLoading: false, - isError: false, - participants, - }); - } - } catch (error) { - if (!didCancel.current) { + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - setCaseUserActionsState({ - caseServices: {}, - caseUserActions: [], - hasDataToPush: false, - isError: true, - isLoading: false, - participants: [], - }); } + + setCaseUserActionsState({ + caseServices: {}, + caseUserActions: [], + hasDataToPush: false, + isError: true, + isLoading: false, + participants: [], + }); } - }; - abortCtrl.current.abort(); - abortCtrl.current = new AbortController(); - fetchData(); + } }, // eslint-disable-next-line react-hooks/exhaustive-deps [caseConnectorId] @@ -313,8 +316,8 @@ export const useGetCaseUserActions = ( } return () => { - didCancel.current = true; - abortCtrl.current.abort(); + isCancelledRef.current = true; + abortCtrlRef.current.abort(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [caseId, subCaseId]); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx index 298d817fffa88..c83cc02dedb97 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { useCallback, useEffect, useReducer } from 'react'; +import { useCallback, useEffect, useReducer, useRef } from 'react'; import { CaseStatuses } from '../../../../case/common/api'; import { DEFAULT_TABLE_ACTIVE_PAGE, DEFAULT_TABLE_LIMIT } from './constants'; import { AllCases, SortFieldCase, FilterOptions, QueryParams, Case, UpdateByKey } from './types'; @@ -139,6 +139,10 @@ export const useGetCases = (initialQueryParams?: QueryParams): UseGetCases => { selectedCases: [], }); const [, dispatchToaster] = useStateToaster(); + const didCancelFetchCases = useRef(false); + const didCancelUpdateCases = useRef(false); + const abortCtrlFetchCases = useRef(new AbortController()); + const abortCtrlUpdateCases = useRef(new AbortController()); const setSelectedCases = useCallback((mySelectedCases: Case[]) => { dispatch({ type: 'UPDATE_TABLE_SELECTIONS', payload: mySelectedCases }); @@ -152,81 +156,69 @@ export const useGetCases = (initialQueryParams?: QueryParams): UseGetCases => { dispatch({ type: 'UPDATE_FILTER_OPTIONS', payload: newFilters }); }, []); - const fetchCases = useCallback((filterOptions: FilterOptions, queryParams: QueryParams) => { - let didCancel = false; - const abortCtrl = new AbortController(); - - const fetchData = async () => { + const fetchCases = useCallback(async (filterOptions: FilterOptions, queryParams: QueryParams) => { + try { + didCancelFetchCases.current = false; + abortCtrlFetchCases.current.abort(); + abortCtrlFetchCases.current = new AbortController(); dispatch({ type: 'FETCH_INIT', payload: 'cases' }); - try { - const response = await getCases({ - filterOptions, - queryParams, - signal: abortCtrl.signal, + + const response = await getCases({ + filterOptions, + queryParams, + signal: abortCtrlFetchCases.current.signal, + }); + + if (!didCancelFetchCases.current) { + dispatch({ + type: 'FETCH_CASES_SUCCESS', + payload: response, }); - if (!didCancel) { - dispatch({ - type: 'FETCH_CASES_SUCCESS', - payload: response, - }); - } - } catch (error) { - if (!didCancel) { + } + } catch (error) { + if (!didCancelFetchCases.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - dispatch({ type: 'FETCH_FAILURE', payload: 'cases' }); } + dispatch({ type: 'FETCH_FAILURE', payload: 'cases' }); } - }; - fetchData(); - return () => { - abortCtrl.abort(); - didCancel = true; - }; + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => fetchCases(state.filterOptions, state.queryParams), [ - state.queryParams, - state.filterOptions, - ]); - const dispatchUpdateCaseProperty = useCallback( - ({ updateKey, updateValue, caseId, refetchCasesStatus, version }: UpdateCase) => { - let didCancel = false; - const abortCtrl = new AbortController(); - - const fetchData = async () => { + async ({ updateKey, updateValue, caseId, refetchCasesStatus, version }: UpdateCase) => { + try { + didCancelUpdateCases.current = false; + abortCtrlUpdateCases.current.abort(); + abortCtrlUpdateCases.current = new AbortController(); dispatch({ type: 'FETCH_INIT', payload: 'caseUpdate' }); - try { - await patchCase( - caseId, - { [updateKey]: updateValue }, - // saved object versions are typed as string | undefined, hope that's not true - version ?? '', - abortCtrl.signal - ); - if (!didCancel) { - dispatch({ type: 'FETCH_UPDATE_CASE_SUCCESS' }); - fetchCases(state.filterOptions, state.queryParams); - refetchCasesStatus(); - } - } catch (error) { - if (!didCancel) { + + await patchCase( + caseId, + { [updateKey]: updateValue }, + // saved object versions are typed as string | undefined, hope that's not true + version ?? '', + abortCtrlUpdateCases.current.signal + ); + + if (!didCancelUpdateCases.current) { + dispatch({ type: 'FETCH_UPDATE_CASE_SUCCESS' }); + fetchCases(state.filterOptions, state.queryParams); + refetchCasesStatus(); + } + } catch (error) { + if (!didCancelUpdateCases.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); - dispatch({ type: 'FETCH_FAILURE', payload: 'caseUpdate' }); } + dispatch({ type: 'FETCH_FAILURE', payload: 'caseUpdate' }); } - }; - fetchData(); - return () => { - abortCtrl.abort(); - didCancel = true; - }; + } }, // eslint-disable-next-line react-hooks/exhaustive-deps [state.filterOptions, state.queryParams] @@ -237,6 +229,17 @@ export const useGetCases = (initialQueryParams?: QueryParams): UseGetCases => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [state.filterOptions, state.queryParams]); + useEffect(() => { + fetchCases(state.filterOptions, state.queryParams); + return () => { + didCancelFetchCases.current = true; + didCancelUpdateCases.current = true; + abortCtrlFetchCases.current.abort(); + abortCtrlUpdateCases.current.abort(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.queryParams, state.filterOptions]); + return { ...state, dispatchUpdateCaseProperty, diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases_status.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_cases_status.tsx index 057fc05008bb0..087f7ef455cba 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases_status.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_cases_status.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState, useRef } from 'react'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; import { getCasesStatus } from './api'; @@ -32,51 +32,56 @@ export interface UseGetCasesStatus extends CasesStatusState { export const useGetCasesStatus = (): UseGetCasesStatus => { const [casesStatusState, setCasesStatusState] = useState(initialData); const [, dispatchToaster] = useStateToaster(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); - const fetchCasesStatus = useCallback(() => { - let didCancel = false; - const abortCtrl = new AbortController(); - const fetchData = async () => { + const fetchCasesStatus = useCallback(async () => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); setCasesStatusState({ - ...casesStatusState, + ...initialData, isLoading: true, }); - try { - const response = await getCasesStatus(abortCtrl.signal); - if (!didCancel) { - setCasesStatusState({ - ...response, - isLoading: false, - isError: false, - }); - } - } catch (error) { - if (!didCancel) { + + const response = await getCasesStatus(abortCtrlRef.current.signal); + + if (!isCancelledRef.current) { + setCasesStatusState({ + ...response, + isLoading: false, + isError: false, + }); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - setCasesStatusState({ - countClosedCases: 0, - countInProgressCases: 0, - countOpenCases: 0, - isLoading: false, - isError: true, - }); } + setCasesStatusState({ + countClosedCases: 0, + countInProgressCases: 0, + countOpenCases: 0, + isLoading: false, + isError: true, + }); } - }; - fetchData(); - return () => { - didCancel = true; - abortCtrl.abort(); - }; + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [casesStatusState]); + }, []); useEffect(() => { fetchCasesStatus(); + + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_reporters.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_reporters.tsx index 25c483045b84f..f2c33ec4730fe 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_reporters.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_reporters.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import { useCallback, useEffect, useState } from 'react'; - +import { useCallback, useEffect, useState, useRef } from 'react'; import { isEmpty } from 'lodash/fp'; + import { User } from '../../../../case/common/api'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; import { getReporters } from './api'; @@ -35,57 +35,61 @@ export const useGetReporters = (): UseGetReporters => { const [reportersState, setReporterState] = useState(initialData); const [, dispatchToaster] = useStateToaster(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); - const fetchReporters = useCallback(() => { - let didCancel = false; - const abortCtrl = new AbortController(); - const fetchData = async () => { + const fetchReporters = useCallback(async () => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); setReporterState({ ...reportersState, isLoading: true, }); - try { - const response = await getReporters(abortCtrl.signal); - const myReporters = response - .map((r) => - r.full_name == null || isEmpty(r.full_name) ? r.username ?? '' : r.full_name - ) - .filter((u) => !isEmpty(u)); - if (!didCancel) { - setReporterState({ - reporters: myReporters, - respReporters: response, - isLoading: false, - isError: false, - }); - } - } catch (error) { - if (!didCancel) { + + const response = await getReporters(abortCtrlRef.current.signal); + const myReporters = response + .map((r) => (r.full_name == null || isEmpty(r.full_name) ? r.username ?? '' : r.full_name)) + .filter((u) => !isEmpty(u)); + + if (!isCancelledRef.current) { + setReporterState({ + reporters: myReporters, + respReporters: response, + isLoading: false, + isError: false, + }); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - setReporterState({ - reporters: [], - respReporters: [], - isLoading: false, - isError: true, - }); } + + setReporterState({ + reporters: [], + respReporters: [], + isLoading: false, + isError: true, + }); } - }; - fetchData(); - return () => { - didCancel = true; - abortCtrl.abort(); - }; + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [reportersState]); useEffect(() => { fetchReporters(); + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + return { ...reportersState, fetchReporters }; }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_tags.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_tags.tsx index 208516d302eb4..4a7a298e2cd86 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_tags.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_tags.tsx @@ -5,8 +5,7 @@ * 2.0. */ -import { useEffect, useReducer } from 'react'; - +import { useEffect, useReducer, useRef, useCallback } from 'react'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; import { getTags } from './api'; import * as i18n from './translations'; @@ -59,37 +58,42 @@ export const useGetTags = (): UseGetTags => { tags: initialData, }); const [, dispatchToaster] = useStateToaster(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); - const callFetch = () => { - let didCancel = false; - const abortCtrl = new AbortController(); - - const fetchData = async () => { + const callFetch = useCallback(async () => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); dispatch({ type: 'FETCH_INIT' }); - try { - const response = await getTags(abortCtrl.signal); - if (!didCancel) { - dispatch({ type: 'FETCH_SUCCESS', payload: response }); - } - } catch (error) { - if (!didCancel) { + + const response = await getTags(abortCtrlRef.current.signal); + + if (!isCancelledRef.current) { + dispatch({ type: 'FETCH_SUCCESS', payload: response }); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - dispatch({ type: 'FETCH_FAILURE' }); } + dispatch({ type: 'FETCH_FAILURE' }); } - }; - fetchData(); - return () => { - abortCtrl.abort(); - didCancel = true; - }; - }; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useEffect(() => { callFetch(); + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return { ...state, fetchTags: callFetch }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_case.tsx index c4fa030473534..d890c050f5034 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_case.tsx @@ -6,7 +6,6 @@ */ import { useReducer, useCallback, useRef, useEffect } from 'react'; - import { CasePostRequest } from '../../../../case/common/api'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; import { postCase } from './api'; @@ -51,38 +50,41 @@ export const usePostCase = (): UsePostCase => { isError: false, }); const [, dispatchToaster] = useStateToaster(); - const cancel = useRef(false); - const abortCtrl = useRef(new AbortController()); - const postMyCase = useCallback( - async (data: CasePostRequest) => { - try { - dispatch({ type: 'FETCH_INIT' }); - abortCtrl.current.abort(); - cancel.current = false; - abortCtrl.current = new AbortController(); - const response = await postCase(data, abortCtrl.current.signal); - if (!cancel.current) { - dispatch({ type: 'FETCH_SUCCESS' }); - } - return response; - } catch (error) { - if (!cancel.current) { + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); + + const postMyCase = useCallback(async (data: CasePostRequest) => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); + + dispatch({ type: 'FETCH_INIT' }); + const response = await postCase(data, abortCtrlRef.current.signal); + + if (!isCancelledRef.current) { + dispatch({ type: 'FETCH_SUCCESS' }); + } + return response; + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - dispatch({ type: 'FETCH_FAILURE' }); } + dispatch({ type: 'FETCH_FAILURE' }); } - }, - [dispatchToaster] - ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); useEffect(() => { return () => { - abortCtrl.current.abort(); - cancel.current = true; + isCancelledRef.current = true; + abortCtrlRef.current.abort(); }; }, []); return { ...state, postCase: postMyCase }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx index 8fc8053c14f70..5eb875287ba88 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx @@ -5,8 +5,7 @@ * 2.0. */ -import { useReducer, useCallback } from 'react'; - +import { useReducer, useCallback, useRef, useEffect } from 'react'; import { CommentRequest } from '../../../../case/common/api'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; @@ -58,38 +57,47 @@ export const usePostComment = (): UsePostComment => { isError: false, }); const [, dispatchToaster] = useStateToaster(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); const postMyComment = useCallback( async ({ caseId, data, updateCase, subCaseId }: PostComment) => { - let cancel = false; - const abortCtrl = new AbortController(); - try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); dispatch({ type: 'FETCH_INIT' }); - const response = await postComment(data, caseId, abortCtrl.signal, subCaseId); - if (!cancel) { + + const response = await postComment(data, caseId, abortCtrlRef.current.signal, subCaseId); + + if (!isCancelledRef.current) { dispatch({ type: 'FETCH_SUCCESS' }); if (updateCase) { updateCase(response); } } } catch (error) { - if (!cancel) { - errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + } dispatch({ type: 'FETCH_FAILURE' }); } } - return () => { - abortCtrl.abort(); - cancel = true; - }; }, [dispatchToaster] ); + useEffect(() => { + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; + }, []); + return { ...state, postComment: postMyComment }; }; 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 03d881d7934e9..27a02d9300cc0 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 @@ -67,17 +67,17 @@ export const usePostPushToService = (): UsePostPushToService => { }); const [, dispatchToaster] = useStateToaster(); const cancel = useRef(false); - const abortCtrl = useRef(new AbortController()); + const abortCtrlRef = useRef(new AbortController()); const pushCaseToExternalService = useCallback( async ({ caseId, connector }: PushToServiceRequest) => { try { - dispatch({ type: 'FETCH_INIT' }); - abortCtrl.current.abort(); + abortCtrlRef.current.abort(); cancel.current = false; - abortCtrl.current = new AbortController(); + abortCtrlRef.current = new AbortController(); + dispatch({ type: 'FETCH_INIT' }); - const response = await pushCase(caseId, connector.id, abortCtrl.current.signal); + const response = await pushCase(caseId, connector.id, abortCtrlRef.current.signal); if (!cancel.current) { dispatch({ type: 'FETCH_SUCCESS' }); @@ -90,11 +90,13 @@ export const usePostPushToService = (): UsePostPushToService => { return response; } catch (error) { if (!cancel.current) { - errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); + if (error.name !== 'AbortError') { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + } dispatch({ type: 'FETCH_FAILURE' }); } } @@ -105,7 +107,7 @@ export const usePostPushToService = (): UsePostPushToService => { useEffect(() => { return () => { - abortCtrl.current.abort(); + abortCtrlRef.current.abort(); cancel.current = true; }; }, []); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx index 23a23caeb71bd..e8de2257009e6 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx @@ -5,10 +5,9 @@ * 2.0. */ -import { useReducer, useCallback, useEffect, useRef } from 'react'; +import { useReducer, useCallback, useRef, useEffect } from 'react'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; - import { patchCase, patchSubCase } from './api'; import { UpdateKey, UpdateByKey, CaseStatuses } from './types'; import * as i18n from './translations'; @@ -70,8 +69,8 @@ export const useUpdateCase = ({ updateKey: null, }); const [, dispatchToaster] = useStateToaster(); - const abortCtrl = useRef(new AbortController()); - const didCancel = useRef(false); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); const dispatchUpdateCaseProperty = useCallback( async ({ @@ -84,24 +83,27 @@ export const useUpdateCase = ({ onError, }: UpdateByKey) => { try { - didCancel.current = false; - abortCtrl.current = new AbortController(); + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); dispatch({ type: 'FETCH_INIT', payload: updateKey }); + const response = await (updateKey === 'status' && subCaseId ? patchSubCase( caseId, subCaseId, { status: updateValue as CaseStatuses }, caseData.version, - abortCtrl.current.signal + abortCtrlRef.current.signal ) : patchCase( caseId, { [updateKey]: updateValue }, caseData.version, - abortCtrl.current.signal + abortCtrlRef.current.signal )); - if (!didCancel.current) { + + if (!isCancelledRef.current) { if (fetchCaseUserActions != null) { fetchCaseUserActions(caseId, subCaseId); } @@ -119,7 +121,7 @@ export const useUpdateCase = ({ } } } catch (error) { - if (!didCancel.current) { + if (!isCancelledRef.current) { if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, @@ -140,8 +142,8 @@ export const useUpdateCase = ({ useEffect(() => { return () => { - didCancel.current = true; - abortCtrl.current.abort(); + isCancelledRef.current = true; + abortCtrlRef.current.abort(); }; }, []); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_update_comment.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_update_comment.tsx index e36b21823310e..81bce248852fe 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_update_comment.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_update_comment.tsx @@ -5,10 +5,8 @@ * 2.0. */ -import { useReducer, useCallback } from 'react'; - +import { useReducer, useCallback, useRef, useEffect } from 'react'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; - import { patchComment } from './api'; import * as i18n from './translations'; import { Case } from './types'; @@ -72,6 +70,8 @@ export const useUpdateComment = (): UseUpdateComment => { isError: false, }); const [, dispatchToaster] = useStateToaster(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); const dispatchUpdateComment = useCallback( async ({ @@ -83,41 +83,49 @@ export const useUpdateComment = (): UseUpdateComment => { updateCase, version, }: UpdateComment) => { - let cancel = false; - const abortCtrl = new AbortController(); try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); dispatch({ type: 'FETCH_INIT', payload: commentId }); + const response = await patchComment( caseId, commentId, commentUpdate, version, - abortCtrl.signal, + abortCtrlRef.current.signal, subCaseId ); - if (!cancel) { + + if (!isCancelledRef.current) { updateCase(response); fetchUserActions(); dispatch({ type: 'FETCH_SUCCESS', payload: { commentId } }); } } catch (error) { - if (!cancel) { - errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + } dispatch({ type: 'FETCH_FAILURE', payload: commentId }); } } - return () => { - cancel = true; - abortCtrl.abort(); - }; }, // eslint-disable-next-line react-hooks/exhaustive-deps [] ); + useEffect(() => { + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; + }, []); + return { ...state, patchComment: dispatchUpdateComment }; }; diff --git a/x-pack/plugins/security_solution/public/common/components/and_or_badge/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/and_or_badge/index.test.tsx index 11f424d83c530..a6dd64737f5ee 100644 --- a/x-pack/plugins/security_solution/public/common/components/and_or_badge/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/and_or_badge/index.test.tsx @@ -8,14 +8,15 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { AndOrBadge } from './'; +const mockTheme = { eui: { euiColorLightShade: '#ece' } }; + describe('AndOrBadge', () => { test('it renders top and bottom antenna bars when "includeAntennas" is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + ); @@ -27,7 +28,7 @@ describe('AndOrBadge', () => { test('it does not render top and bottom antenna bars when "includeAntennas" is false', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + ); @@ -39,7 +40,7 @@ describe('AndOrBadge', () => { test('it renders "and" when "type" is "and"', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + ); @@ -49,7 +50,7 @@ describe('AndOrBadge', () => { test('it renders "or" when "type" is "or"', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + ); diff --git a/x-pack/plugins/security_solution/public/common/components/and_or_badge/rounded_badge.test.tsx b/x-pack/plugins/security_solution/public/common/components/and_or_badge/rounded_badge.test.tsx index 5bc89baa3d415..489d02990b1f4 100644 --- a/x-pack/plugins/security_solution/public/common/components/and_or_badge/rounded_badge.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/and_or_badge/rounded_badge.test.tsx @@ -6,29 +6,19 @@ */ import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { RoundedBadge } from './rounded_badge'; describe('RoundedBadge', () => { test('it renders "and" when "type" is "and"', () => { - const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - ); + const wrapper = mount(); expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('AND'); }); test('it renders "or" when "type" is "or"', () => { - const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - ); + const wrapper = mount(); expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('OR'); }); diff --git a/x-pack/plugins/security_solution/public/common/components/and_or_badge/rounded_badge_antenna.test.tsx b/x-pack/plugins/security_solution/public/common/components/and_or_badge/rounded_badge_antenna.test.tsx index 61cf44293005f..c6536a05be45d 100644 --- a/x-pack/plugins/security_solution/public/common/components/and_or_badge/rounded_badge_antenna.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/and_or_badge/rounded_badge_antenna.test.tsx @@ -8,14 +8,15 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { RoundedBadgeAntenna } from './rounded_badge_antenna'; +const mockTheme = { eui: { euiColorLightShade: '#ece' } }; + describe('RoundedBadgeAntenna', () => { test('it renders top and bottom antenna bars', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + ); @@ -27,7 +28,7 @@ describe('RoundedBadgeAntenna', () => { test('it renders "and" when "type" is "and"', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + ); @@ -37,7 +38,7 @@ describe('RoundedBadgeAntenna', () => { test('it renders "or" when "type" is "or"', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + ); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field.test.tsx index 6a3d82d47045a..79e6fe5506b84 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field.test.tsx @@ -6,9 +6,7 @@ */ import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { @@ -20,21 +18,19 @@ import { FieldComponent } from './field'; describe('FieldComponent', () => { test('it renders disabled if "isDisabled" is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -44,21 +40,19 @@ describe('FieldComponent', () => { test('it renders loading if "isLoading" is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find(`[data-test-subj="fieldAutocompleteComboBox"] button`).at(0).simulate('click'); expect( @@ -70,21 +64,19 @@ describe('FieldComponent', () => { test('it allows user to clear values if "isClearable" is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -96,21 +88,19 @@ describe('FieldComponent', () => { test('it correctly displays selected field', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -121,21 +111,19 @@ describe('FieldComponent', () => { test('it invokes "onChange" when option selected', () => { const mockOnChange = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); ((wrapper.find(EuiComboBox).props() as unknown) as { diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_exists.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_exists.test.tsx index f577799827b89..b6300581f12dd 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_exists.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_exists.test.tsx @@ -6,19 +6,13 @@ */ import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { AutocompleteFieldExistsComponent } from './field_value_exists'; describe('AutocompleteFieldExistsComponent', () => { test('it renders field disabled', () => { - const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - ); + const wrapper = mount(); expect( wrapper diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx index e01cc5ff1e042..c605a71c50e33 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx @@ -6,9 +6,7 @@ */ import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { waitFor } from '@testing-library/react'; @@ -47,17 +45,15 @@ jest.mock('../../../lists_plugin_deps', () => { describe('AutocompleteFieldListsComponent', () => { test('it renders disabled if "isDisabled" is true', async () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -69,17 +65,15 @@ describe('AutocompleteFieldListsComponent', () => { test('it renders loading if "isLoading" is true', async () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper @@ -97,17 +91,15 @@ describe('AutocompleteFieldListsComponent', () => { test('it allows user to clear values if "isClearable" is true', async () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( wrapper @@ -118,17 +110,15 @@ describe('AutocompleteFieldListsComponent', () => { test('it correctly displays lists that match the selected "keyword" field esType', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="comboBoxToggleListButton"] button').simulate('click'); @@ -142,17 +132,15 @@ describe('AutocompleteFieldListsComponent', () => { test('it correctly displays lists that match the selected "ip" field esType', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="comboBoxToggleListButton"] button').simulate('click'); @@ -166,17 +154,15 @@ describe('AutocompleteFieldListsComponent', () => { test('it correctly displays selected list', async () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -190,17 +176,15 @@ describe('AutocompleteFieldListsComponent', () => { test('it invokes "onChange" when option selected', async () => { const mockOnChange = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); ((wrapper.find(EuiComboBox).props() as unknown) as { diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.test.tsx index d4712092867e9..38d103fe65130 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.test.tsx @@ -6,9 +6,7 @@ */ import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { mount, ReactWrapper } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { EuiSuperSelect, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { act } from '@testing-library/react'; @@ -44,23 +42,21 @@ describe('AutocompleteFieldMatchComponent', () => { test('it renders row label if one passed in', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -70,23 +66,21 @@ describe('AutocompleteFieldMatchComponent', () => { test('it renders disabled if "isDisabled" is true', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -96,23 +90,21 @@ describe('AutocompleteFieldMatchComponent', () => { test('it renders loading if "isLoading" is true', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="valuesAutocompleteMatch"] button').at(0).simulate('click'); expect( @@ -124,23 +116,21 @@ describe('AutocompleteFieldMatchComponent', () => { test('it allows user to clear values if "isClearable" is true', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -152,23 +142,21 @@ describe('AutocompleteFieldMatchComponent', () => { test('it correctly displays selected value', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -179,23 +167,21 @@ describe('AutocompleteFieldMatchComponent', () => { test('it invokes "onChange" when new value created', async () => { const mockOnChange = jest.fn(); wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); ((wrapper.find(EuiComboBox).props() as unknown) as { @@ -208,23 +194,21 @@ describe('AutocompleteFieldMatchComponent', () => { test('it invokes "onChange" when new value selected', async () => { const mockOnChange = jest.fn(); wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); ((wrapper.find(EuiComboBox).props() as unknown) as { @@ -236,23 +220,21 @@ describe('AutocompleteFieldMatchComponent', () => { test('it refreshes autocomplete with search query when new value searched', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); act(() => { ((wrapper.find(EuiComboBox).props() as unknown) as { @@ -287,23 +269,21 @@ describe('AutocompleteFieldMatchComponent', () => { test('it displays only two options - "true" or "false"', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -326,23 +306,21 @@ describe('AutocompleteFieldMatchComponent', () => { test('it invokes "onChange" with "true" when selected', () => { const mockOnChange = jest.fn(); wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); ((wrapper.find(EuiSuperSelect).props() as unknown) as { @@ -355,23 +333,21 @@ describe('AutocompleteFieldMatchComponent', () => { test('it invokes "onChange" with "false" when selected', () => { const mockOnChange = jest.fn(); wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); ((wrapper.find(EuiSuperSelect).props() as unknown) as { @@ -396,23 +372,21 @@ describe('AutocompleteFieldMatchComponent', () => { test('it number input when field type is number', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -423,23 +397,21 @@ describe('AutocompleteFieldMatchComponent', () => { test('it invokes "onChange" with numeric value when inputted', () => { const mockOnChange = jest.fn(); wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.test.tsx index aa2038262f40c..6b479c5ab8c4c 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.test.tsx @@ -6,9 +6,7 @@ */ import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { mount, ReactWrapper } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { act } from '@testing-library/react'; @@ -43,24 +41,22 @@ describe('AutocompleteFieldMatchAnyComponent', () => { test('it renders disabled if "isDisabled" is true', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -70,24 +66,22 @@ describe('AutocompleteFieldMatchAnyComponent', () => { test('it renders loading if "isLoading" is true', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find(`[data-test-subj="valuesAutocompleteMatchAny"] button`).at(0).simulate('click'); expect( @@ -99,24 +93,22 @@ describe('AutocompleteFieldMatchAnyComponent', () => { test('it allows user to clear values if "isClearable" is true', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -128,24 +120,22 @@ describe('AutocompleteFieldMatchAnyComponent', () => { test('it correctly displays selected value', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -156,24 +146,22 @@ describe('AutocompleteFieldMatchAnyComponent', () => { test('it invokes "onChange" when new value created', async () => { const mockOnChange = jest.fn(); wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); ((wrapper.find(EuiComboBox).props() as unknown) as { @@ -186,24 +174,22 @@ describe('AutocompleteFieldMatchAnyComponent', () => { test('it invokes "onChange" when new value selected', async () => { const mockOnChange = jest.fn(); wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); ((wrapper.find(EuiComboBox).props() as unknown) as { @@ -215,23 +201,21 @@ describe('AutocompleteFieldMatchAnyComponent', () => { test('it refreshes autocomplete with search query when new value searched', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); act(() => { ((wrapper.find(EuiComboBox).props() as unknown) as { diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.test.tsx index 56ae6d762e7ee..db16cbde2acb4 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.test.tsx @@ -6,9 +6,7 @@ */ import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { getField } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; @@ -18,17 +16,15 @@ import { isOperator, isNotOperator } from './operators'; describe('OperatorComponent', () => { test('it renders disabled if "isDisabled" is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -38,17 +34,15 @@ describe('OperatorComponent', () => { test('it renders loading if "isLoading" is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"] button`).at(0).simulate('click'); expect( @@ -60,17 +54,15 @@ describe('OperatorComponent', () => { test('it allows user to clear values if "isClearable" is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect(wrapper.find(`button[data-test-subj="comboBoxClearButton"]`).exists()).toBeTruthy(); @@ -78,18 +70,16 @@ describe('OperatorComponent', () => { test('it displays "operatorOptions" if param is passed in with items', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -99,18 +89,16 @@ describe('OperatorComponent', () => { test('it does not display "operatorOptions" if param is passed in with no items', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -145,17 +133,15 @@ describe('OperatorComponent', () => { test('it correctly displays selected operator', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -165,27 +151,25 @@ describe('OperatorComponent', () => { test('it only displays subset of operators if field type is nested', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -195,17 +179,15 @@ describe('OperatorComponent', () => { test('it only displays subset of operators if field type is boolean', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -221,17 +203,15 @@ describe('OperatorComponent', () => { test('it invokes "onChange" when option selected', () => { const mockOnChange = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); ((wrapper.find(EuiComboBox).props() as unknown) as { diff --git a/x-pack/plugins/security_solution/public/common/components/charts/barchart.test.tsx b/x-pack/plugins/security_solution/public/common/components/charts/barchart.test.tsx index 096bea37566b3..6d87b5d3a68b9 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/barchart.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/barchart.test.tsx @@ -6,10 +6,8 @@ */ import { Chart, BarSeries, Axis, ScaleType } from '@elastic/charts'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { mount, ReactWrapper, shallow, ShallowWrapper } from 'enzyme'; import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { escapeDataProviderId } from '../drag_and_drop/helpers'; import { TestProviders } from '../../mock'; @@ -37,8 +35,6 @@ jest.mock('uuid', () => { }; }); -const theme = () => ({ eui: euiDarkVars, darkMode: true }); - const customHeight = '100px'; const customWidth = '120px'; const chartDataSets = [ @@ -323,11 +319,9 @@ describe.each(chartDataSets)('BarChart with stackByField', () => { beforeAll(() => { wrapper = mount( - - - - - + + + ); }); @@ -407,11 +401,9 @@ describe.each(chartDataSets)('BarChart with custom color', () => { beforeAll(() => { wrapper = mount( - - - - - + + + ); }); diff --git a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend.test.tsx b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend.test.tsx index fb91a4a3ce92b..544f9b1abf8f2 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend.test.tsx @@ -5,10 +5,8 @@ * 2.0. */ -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { mount, ReactWrapper } from 'enzyme'; import React from 'react'; -import { ThemeProvider } from 'styled-components'; import '../../mock/match_media'; import '../../mock/react_beautiful_dnd'; @@ -26,8 +24,6 @@ jest.mock('@elastic/eui', () => { }; }); -const theme = () => ({ eui: euiDarkVars, darkMode: true }); - const allOthersDataProviderId = 'draggable-legend-item-527adabe-8e1c-4a1f-965c-2f3d65dda9e1-event_dataset-All others'; @@ -74,11 +70,9 @@ describe('DraggableLegend', () => { beforeEach(() => { wrapper = mount( - - - - - + + + ); }); @@ -120,11 +114,9 @@ describe('DraggableLegend', () => { it('does NOT render the legend when an empty collection of legendItems is provided', () => { const wrapper = mount( - - - - - + + + ); expect(wrapper.find('[data-test-subj="draggable-legend"]').exists()).toBe(false); @@ -132,11 +124,9 @@ describe('DraggableLegend', () => { it(`renders a legend with the minimum height when 'height' is zero`, () => { const wrapper = mount( - - - - - + + + ); expect(wrapper.find('[data-test-subj="draggable-legend"]').first()).toHaveStyleRule( diff --git a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx index 15c164e59557d..4958f6bae4a30 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx @@ -5,10 +5,8 @@ * 2.0. */ -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { mount, ReactWrapper } from 'enzyme'; import React from 'react'; -import { ThemeProvider } from 'styled-components'; import '../../mock/match_media'; import '../../mock/react_beautiful_dnd'; @@ -25,8 +23,6 @@ jest.mock('@elastic/eui', () => { }; }); -const theme = () => ({ eui: euiDarkVars, darkMode: true }); - describe('DraggableLegendItem', () => { describe('rendering a regular (non "All others") legend item', () => { const legendItem: LegendItem = { @@ -41,11 +37,9 @@ describe('DraggableLegendItem', () => { beforeEach(() => { wrapper = mount( - - - - - + + + ); }); @@ -79,11 +73,9 @@ describe('DraggableLegendItem', () => { beforeEach(() => { wrapper = mount( - - - - - + + + ); }); @@ -118,11 +110,9 @@ describe('DraggableLegendItem', () => { }; const wrapper = mount( - - - - - + + + ); expect(wrapper.find('[data-test-subj="legend-color"]').exists()).toBe(false); diff --git a/x-pack/plugins/security_solution/public/common/components/empty_value/empty_value.test.tsx b/x-pack/plugins/security_solution/public/common/components/empty_value/empty_value.test.tsx index 764d9109816b5..e3c74bf425628 100644 --- a/x-pack/plugins/security_solution/public/common/components/empty_value/empty_value.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/empty_value/empty_value.test.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { mount, shallow } from 'enzyme'; import React from 'react'; import { ThemeProvider } from 'styled-components'; @@ -21,7 +20,7 @@ import { } from '.'; describe('EmptyValue', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); + const mockTheme = { eui: { euiColorMediumShade: '#ece' } }; test('it renders against snapshot', () => { const wrapper = shallow(

{getEmptyString()}

); @@ -35,7 +34,7 @@ describe('EmptyValue', () => { describe('#getEmptyString', () => { test('should turn into an empty string place holder', () => { const wrapper = mountWithIntl( - +

{getEmptyString()}

); @@ -45,7 +44,7 @@ describe('EmptyValue', () => { describe('#getEmptyTagValue', () => { const wrapper = mount( - +

{getEmptyTagValue()}

); @@ -55,7 +54,7 @@ describe('EmptyValue', () => { describe('#getEmptyStringTag', () => { test('should turn into an span that has length of 1', () => { const wrapper = mountWithIntl( - +

{getEmptyStringTag()}

); @@ -64,7 +63,7 @@ describe('EmptyValue', () => { test('should turn into an empty string tag place holder', () => { const wrapper = mountWithIntl( - +

{getEmptyStringTag()}

); @@ -75,7 +74,7 @@ describe('EmptyValue', () => { describe('#defaultToEmptyTag', () => { test('should default to an empty value when a value is null', () => { const wrapper = mount( - +

{defaultToEmptyTag(null)}

); @@ -84,7 +83,7 @@ describe('EmptyValue', () => { test('should default to an empty value when a value is undefined', () => { const wrapper = mount( - +

{defaultToEmptyTag(undefined)}

); @@ -114,7 +113,7 @@ describe('EmptyValue', () => { }, }; const wrapper = mount( - +

{getOrEmptyTag('a.b.c', test)}

); @@ -130,7 +129,7 @@ describe('EmptyValue', () => { }, }; const wrapper = mount( - +

{getOrEmptyTag('a.b.c', test)}

); @@ -144,7 +143,7 @@ describe('EmptyValue', () => { }, }; const wrapper = mount( - +

{getOrEmptyTag('a.b.c', test)}

); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.test.tsx index af76a79f0e330..9ba6fe104be45 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.test.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import { mount, ReactWrapper } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { waitFor } from '@testing-library/react'; import { AddExceptionModal } from './'; @@ -32,6 +31,17 @@ import { import { useRuleAsync } from '../../../../detections/containers/detection_engine/rules/use_rule_async'; import { AlertData } from '../types'; +const mockTheme = { + eui: { + euiBreakpoints: { + l: '1200px', + }, + paddingSizes: { + m: '10px', + }, + }, +}; + jest.mock('../../../../detections/containers/detection_engine/alerts/use_signal_index'); jest.mock('../../../../common/lib/kibana'); jest.mock('../../../containers/source'); @@ -101,7 +111,7 @@ describe('When the add exception modal is opened', () => { }, ]); wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { let wrapper: ReactWrapper; beforeEach(async () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { file: { path: 'test/path' }, }; wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { file: { path: 'test/path' }, }; wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { file: { path: 'test/path' }, }; wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { file: { path: 'test/path' }, }; wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('when there are exception builder errors submit button is disabled', async () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + - {i18n.CANCEL} + + {i18n.CANCEL} + { test('it renders exceptionItemEntryFirstRowAndBadge for very first exception item in builder', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + ); @@ -27,7 +28,7 @@ describe('BuilderAndBadgeComponent', () => { test('it renders exceptionItemEntryInvisibleAndBadge if "entriesLength" is 1 or less', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + ); @@ -39,7 +40,7 @@ describe('BuilderAndBadgeComponent', () => { test('it renders regular "and" badge if exception item is not the first one and includes more than one entry', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.test.tsx index da236d130e930..9c9035d7e66e9 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.test.tsx @@ -58,6 +58,7 @@ describe('BuilderEntryItem', () => { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { }).onChange([{ label: 'machine.os' }]); expect(mockOnChange).toHaveBeenCalledWith( - { field: 'machine.os', operator: 'included', type: 'match', value: '' }, + { id: '123', field: 'machine.os', operator: 'included', type: 'match', value: '' }, 0 ); }); @@ -445,6 +456,7 @@ describe('BuilderEntryItem', () => { wrapper = mount( { }).onChange([{ label: 'is not' }]); expect(mockOnChange).toHaveBeenCalledWith( - { field: 'ip', operator: 'excluded', type: 'match', value: '1234' }, + { id: '123', field: 'ip', operator: 'excluded', type: 'match', value: '1234' }, 0 ); }); @@ -480,6 +492,7 @@ describe('BuilderEntryItem', () => { wrapper = mount( { }).onCreateOption('126.45.211.34'); expect(mockOnChange).toHaveBeenCalledWith( - { field: 'ip', operator: 'excluded', type: 'match', value: '126.45.211.34' }, + { id: '123', field: 'ip', operator: 'excluded', type: 'match', value: '126.45.211.34' }, 0 ); }); @@ -515,6 +528,7 @@ describe('BuilderEntryItem', () => { wrapper = mount( { }).onCreateOption('126.45.211.34'); expect(mockOnChange).toHaveBeenCalledWith( - { field: 'ip', operator: 'included', type: 'match_any', value: ['126.45.211.34'] }, + { id: '123', field: 'ip', operator: 'included', type: 'match_any', value: ['126.45.211.34'] }, 0 ); }); @@ -550,6 +564,7 @@ describe('BuilderEntryItem', () => { wrapper = mount( { expect(mockOnChange).toHaveBeenCalledWith( { + id: '123', field: 'ip', operator: 'excluded', type: 'list', @@ -590,6 +606,7 @@ describe('BuilderEntryItem', () => { wrapper = mount( { wrapper = mount( = ({ ); } else { - return comboBox; + return ( + + {comboBox} + + ); } }, [handleFieldChange, indexPattern, entry, listType] @@ -176,7 +180,11 @@ export const BuilderEntryItem: React.FC = ({ ); } else { - return comboBox; + return ( + + {comboBox} + + ); } }; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.test.tsx index 6505c5eb2b310..1ea54473032cc 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.test.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { useKibana } from '../../../../common/lib/kibana'; import { fields } from '../../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; @@ -18,6 +17,12 @@ import { getEntryMatchAnyMock } from '../../../../../../lists/common/schemas/typ import { BuilderExceptionListItemComponent } from './exception_item'; +const mockTheme = { + eui: { + euiColorLightShade: '#ece', + }, +}; + jest.mock('../../../../common/lib/kibana'); describe('BuilderExceptionListItemComponent', () => { @@ -46,7 +51,7 @@ describe('BuilderExceptionListItemComponent', () => { entries: [getEntryMatchMock(), getEntryMatchMock()], }; const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.entries = [getEntryMatchMock(), getEntryMatchMock()]; const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.entries = [getEntryMatchMock()]; const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.entries = [getEntryMatchMock()]; const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + - {entries.map((item, index) => ( - - - {item.nested === 'child' && } - - { + const key = (item as typeof item & { id?: string }).id ?? `${index}`; + return ( + + + {item.nested === 'child' && } + + + + - - - - - ))} + + + ); + })}
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx index ea948ab9b3b5c..8d0f042e7a498 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx @@ -28,7 +28,15 @@ import { } from '../../autocomplete/operators'; import { BuilderEntry, ExceptionsBuilderExceptionItem, FormattedBuilderEntry } from '../types'; import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/data/common'; -import { Entry, EntryNested } from '../../../../lists_plugin_deps'; +import { + EntryMatch, + EntryMatchAny, + EntryNested, + EntryList, + EntryExists, + OperatorTypeEnum, + OperatorEnum, +} from '../../../../shared_imports'; import { getEntryFromOperator, @@ -46,6 +54,31 @@ import { getCorrespondingKeywordField, } from './helpers'; import { OperatorOption } from '../../autocomplete/types'; +import { ENTRIES_WITH_IDS } from '../../../../../../lists/common/constants.mock'; + +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('123'), +})); + +const getEntryNestedWithIdMock = () => ({ + id: '123', + ...getEntryNestedMock(), +}); + +const getEntryExistsWithIdMock = () => ({ + id: '123', + ...getEntryExistsMock(), +}); + +const getEntryMatchWithIdMock = () => ({ + id: '123', + ...getEntryMatchMock(), +}); + +const getEntryMatchAnyWithIdMock = () => ({ + id: '123', + ...getEntryMatchAnyMock(), +}); const getMockIndexPattern = (): IIndexPattern => ({ id: '1234', @@ -54,6 +87,7 @@ const getMockIndexPattern = (): IIndexPattern => ({ }); const getMockBuilderEntry = (): FormattedBuilderEntry => ({ + id: '123', field: getField('ip'), operator: isOperator, value: 'some value', @@ -64,15 +98,16 @@ const getMockBuilderEntry = (): FormattedBuilderEntry => ({ }); const getMockNestedBuilderEntry = (): FormattedBuilderEntry => ({ + id: '123', field: getField('nestedField.child'), operator: isOperator, value: 'some value', nested: 'child', parent: { parent: { - ...getEntryNestedMock(), + ...getEntryNestedWithIdMock(), field: 'nestedField', - entries: [{ ...getEntryMatchMock(), field: 'child' }], + entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], }, parentIndex: 0, }, @@ -81,6 +116,7 @@ const getMockNestedBuilderEntry = (): FormattedBuilderEntry => ({ }); const getMockNestedParentBuilderEntry = (): FormattedBuilderEntry => ({ + id: '123', field: { ...getField('nestedField.child'), name: 'nestedField', esTypes: ['nested'] }, operator: isOperator, value: undefined, @@ -225,15 +261,16 @@ describe('Exception builder helpers', () => { test('it returns nested fields that match parent value when "item.nested" is "child"', () => { const payloadItem: FormattedBuilderEntry = { + id: '123', field: getEndpointField('file.Ext.code_signature.status'), operator: isOperator, value: 'some value', nested: 'child', parent: { parent: { - ...getEntryNestedMock(), + ...getEntryNestedWithIdMock(), field: 'file.Ext.code_signature', - entries: [{ ...getEntryMatchMock(), field: 'child' }], + entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], }, parentIndex: 0, }, @@ -351,7 +388,7 @@ describe('Exception builder helpers', () => { ], }; const payloadItem: BuilderEntry = { - ...getEntryMatchMock(), + ...getEntryMatchWithIdMock(), field: 'machine.os.raw.text', value: 'some os', }; @@ -363,6 +400,7 @@ describe('Exception builder helpers', () => { undefined ); const expected: FormattedBuilderEntry = { + id: '123', entryIndex: 0, field: { name: 'machine.os.raw.text', @@ -385,11 +423,11 @@ describe('Exception builder helpers', () => { test('it returns "FormattedBuilderEntry" with value "nested" of "child" when "parent" and "parentIndex" are defined', () => { const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: BuilderEntry = { ...getEntryMatchMock(), field: 'child' }; + const payloadItem: BuilderEntry = { ...getEntryMatchWithIdMock(), field: 'child' }; const payloadParent: EntryNested = { - ...getEntryNestedMock(), + ...getEntryNestedWithIdMock(), field: 'nestedField', - entries: [{ ...getEntryMatchMock(), field: 'child' }], + entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], }; const output = getFormattedBuilderEntry( payloadIndexPattern, @@ -399,6 +437,7 @@ describe('Exception builder helpers', () => { 1 ); const expected: FormattedBuilderEntry = { + id: '123', entryIndex: 0, field: { aggregatable: false, @@ -419,9 +458,10 @@ describe('Exception builder helpers', () => { operator: isOperator, parent: { parent: { + id: '123', entries: [{ ...payloadItem }], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, parentIndex: 1, }, @@ -433,7 +473,11 @@ describe('Exception builder helpers', () => { test('it returns non nested "FormattedBuilderEntry" when "parent" and "parentIndex" are not defined', () => { const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: BuilderEntry = { ...getEntryMatchMock(), field: 'ip', value: 'some ip' }; + const payloadItem: BuilderEntry = { + ...getEntryMatchWithIdMock(), + field: 'ip', + value: 'some ip', + }; const output = getFormattedBuilderEntry( payloadIndexPattern, payloadItem, @@ -442,6 +486,7 @@ describe('Exception builder helpers', () => { undefined ); const expected: FormattedBuilderEntry = { + id: '123', entryIndex: 0, field: { aggregatable: true, @@ -465,14 +510,14 @@ describe('Exception builder helpers', () => { describe('#isEntryNested', () => { test('it returns "false" if payload is not of type EntryNested', () => { - const payload: BuilderEntry = getEntryMatchMock(); + const payload: BuilderEntry = getEntryMatchWithIdMock(); const output = isEntryNested(payload); const expected = false; expect(output).toEqual(expected); }); test('it returns "true if payload is of type EntryNested', () => { - const payload: EntryNested = getEntryNestedMock(); + const payload: EntryNested = getEntryNestedWithIdMock(); const output = isEntryNested(payload); const expected = true; expect(output).toEqual(expected); @@ -482,10 +527,11 @@ describe('Exception builder helpers', () => { describe('#getFormattedBuilderEntries', () => { test('it returns formatted entry with field undefined if it unable to find a matching index pattern field', () => { const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItems: BuilderEntry[] = [getEntryMatchMock()]; + const payloadItems: BuilderEntry[] = [getEntryMatchWithIdMock()]; const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); const expected: FormattedBuilderEntry[] = [ { + id: '123', entryIndex: 0, field: undefined, nested: undefined, @@ -501,12 +547,13 @@ describe('Exception builder helpers', () => { test('it returns formatted entries when no nested entries exist', () => { const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); const payloadItems: BuilderEntry[] = [ - { ...getEntryMatchMock(), field: 'ip', value: 'some ip' }, - { ...getEntryMatchAnyMock(), field: 'extension', value: ['some extension'] }, + { ...getEntryMatchWithIdMock(), field: 'ip', value: 'some ip' }, + { ...getEntryMatchAnyWithIdMock(), field: 'extension', value: ['some extension'] }, ]; const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); const expected: FormattedBuilderEntry[] = [ { + id: '123', entryIndex: 0, field: { aggregatable: true, @@ -525,6 +572,7 @@ describe('Exception builder helpers', () => { correspondingKeywordField: undefined, }, { + id: '123', entryIndex: 1, field: { aggregatable: true, @@ -549,18 +597,19 @@ describe('Exception builder helpers', () => { test('it returns formatted entries when nested entries exist', () => { const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); const payloadParent: EntryNested = { - ...getEntryNestedMock(), + ...getEntryNestedWithIdMock(), field: 'nestedField', - entries: [{ ...getEntryMatchMock(), field: 'child' }], + entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], }; const payloadItems: BuilderEntry[] = [ - { ...getEntryMatchMock(), field: 'ip', value: 'some ip' }, + { ...getEntryMatchWithIdMock(), field: 'ip', value: 'some ip' }, { ...payloadParent }, ]; const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); const expected: FormattedBuilderEntry[] = [ { + id: '123', entryIndex: 0, field: { aggregatable: true, @@ -579,6 +628,7 @@ describe('Exception builder helpers', () => { correspondingKeywordField: undefined, }, { + id: '123', entryIndex: 1, field: { aggregatable: false, @@ -594,6 +644,7 @@ describe('Exception builder helpers', () => { correspondingKeywordField: undefined, }, { + id: '123', entryIndex: 0, field: { aggregatable: false, @@ -614,16 +665,18 @@ describe('Exception builder helpers', () => { operator: isOperator, parent: { parent: { + id: '123', entries: [ { + id: '123', field: 'child', - operator: 'included', - type: 'match', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, value: 'some host name', }, ], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, parentIndex: 1, }, @@ -637,15 +690,19 @@ describe('Exception builder helpers', () => { describe('#getUpdatedEntriesOnDelete', () => { test('it removes entry corresponding to "entryIndex"', () => { - const payloadItem: ExceptionsBuilderExceptionItem = { ...getExceptionListItemSchemaMock() }; + const payloadItem: ExceptionsBuilderExceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: ENTRIES_WITH_IDS, + }; const output = getUpdatedEntriesOnDelete(payloadItem, 0, null); const expected: ExceptionsBuilderExceptionItem = { ...getExceptionListItemSchemaMock(), entries: [ { + id: '123', field: 'some.not.nested.field', - operator: 'included', - type: 'match', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, value: 'some value', }, ], @@ -658,15 +715,17 @@ describe('Exception builder helpers', () => { ...getExceptionListItemSchemaMock(), entries: [ { - ...getEntryNestedMock(), - entries: [{ ...getEntryExistsMock() }, { ...getEntryMatchAnyMock() }], + ...getEntryNestedWithIdMock(), + entries: [{ ...getEntryExistsWithIdMock() }, { ...getEntryMatchAnyWithIdMock() }], }, ], }; const output = getUpdatedEntriesOnDelete(payloadItem, 0, 0); const expected: ExceptionsBuilderExceptionItem = { ...getExceptionListItemSchemaMock(), - entries: [{ ...getEntryNestedMock(), entries: [{ ...getEntryMatchAnyMock() }] }], + entries: [ + { ...getEntryNestedWithIdMock(), entries: [{ ...getEntryMatchAnyWithIdMock() }] }, + ], }; expect(output).toEqual(expected); }); @@ -676,8 +735,8 @@ describe('Exception builder helpers', () => { ...getExceptionListItemSchemaMock(), entries: [ { - ...getEntryNestedMock(), - entries: [{ ...getEntryExistsMock() }], + ...getEntryNestedWithIdMock(), + entries: [{ ...getEntryExistsWithIdMock() }], }, ], }; @@ -698,10 +757,11 @@ describe('Exception builder helpers', () => { value: 'I should stay the same', }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryMatch & { id?: string } = { + id: '123', field: 'ip', operator: 'excluded', - type: 'match', + type: OperatorTypeEnum.MATCH, value: 'I should stay the same', }; expect(output).toEqual(expected); @@ -715,10 +775,11 @@ describe('Exception builder helpers', () => { value: 'I should stay the same', }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryMatch & { id?: string } = { + id: '123', field: 'ip', - operator: 'included', - type: 'match', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, value: 'I should stay the same', }; expect(output).toEqual(expected); @@ -732,10 +793,11 @@ describe('Exception builder helpers', () => { value: ['I should stay the same'], }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryMatch & { id?: string } = { + id: '123', field: 'ip', - operator: 'included', - type: 'match', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, value: '', }; expect(output).toEqual(expected); @@ -749,10 +811,11 @@ describe('Exception builder helpers', () => { value: ['I should stay the same'], }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryMatchAny & { id?: string } = { + id: '123', field: 'ip', operator: 'excluded', - type: 'match_any', + type: OperatorTypeEnum.MATCH_ANY, value: ['I should stay the same'], }; expect(output).toEqual(expected); @@ -766,10 +829,11 @@ describe('Exception builder helpers', () => { value: ['I should stay the same'], }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryMatchAny & { id?: string } = { + id: '123', field: 'ip', - operator: 'included', - type: 'match_any', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, value: ['I should stay the same'], }; expect(output).toEqual(expected); @@ -783,10 +847,11 @@ describe('Exception builder helpers', () => { value: 'I should stay the same', }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryMatchAny & { id?: string } = { + id: '123', field: 'ip', - operator: 'included', - type: 'match_any', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, value: [], }; expect(output).toEqual(expected); @@ -799,7 +864,8 @@ describe('Exception builder helpers', () => { operator: existsOperator, }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryExists & { id?: string } = { + id: '123', field: 'ip', operator: 'excluded', type: 'exists', @@ -814,9 +880,10 @@ describe('Exception builder helpers', () => { operator: doesNotExistOperator, }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryExists & { id?: string } = { + id: '123', field: 'ip', - operator: 'included', + operator: OperatorEnum.INCLUDED, type: 'exists', }; expect(output).toEqual(expected); @@ -830,9 +897,10 @@ describe('Exception builder helpers', () => { value: 'I should stay the same', }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryExists & { id?: string } = { + id: '123', field: 'ip', - operator: 'included', + operator: OperatorEnum.INCLUDED, type: 'exists', }; expect(output).toEqual(expected); @@ -846,9 +914,10 @@ describe('Exception builder helpers', () => { value: 'I should stay the same', }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryList & { id?: string } = { + id: '123', field: 'ip', - operator: 'included', + operator: OperatorEnum.INCLUDED, type: 'list', list: { id: '', type: 'ip' }, }; @@ -943,12 +1012,21 @@ describe('Exception builder helpers', () => { const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry(); const payloadIFieldType: IFieldType = getField('nestedField.child'); const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { index: 0, updatedEntry: { - entries: [{ field: 'child', operator: 'included', type: 'match', value: '' }], + id: '123', + entries: [ + { + id: '123', + field: 'child', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: '', + }, + ], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, }; expect(output).toEqual(expected); @@ -959,24 +1037,34 @@ describe('Exception builder helpers', () => { ...getMockNestedBuilderEntry(), parent: { parent: { - ...getEntryNestedMock(), + ...getEntryNestedWithIdMock(), field: 'nestedField', - entries: [{ ...getEntryMatchMock(), field: 'child' }, getEntryMatchAnyMock()], + entries: [ + { ...getEntryMatchWithIdMock(), field: 'child' }, + getEntryMatchAnyWithIdMock(), + ], }, parentIndex: 0, }, }; const payloadIFieldType: IFieldType = getField('nestedField.child'); const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { index: 0, updatedEntry: { + id: '123', entries: [ - { field: 'child', operator: 'included', type: 'match', value: '' }, - getEntryMatchAnyMock(), + { + id: '123', + field: 'child', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: '', + }, + getEntryMatchAnyWithIdMock(), ], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, }; expect(output).toEqual(expected); @@ -986,12 +1074,13 @@ describe('Exception builder helpers', () => { const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); const payloadIFieldType: IFieldType = getField('ip'); const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { index: 0, updatedEntry: { + id: '123', field: 'ip', - operator: 'included', - type: 'match', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, value: '', }, }; @@ -1004,8 +1093,14 @@ describe('Exception builder helpers', () => { const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); const payloadOperator: OperatorOption = isNotOperator; const output = getEntryOnOperatorChange(payloadItem, payloadOperator); - const expected: { updatedEntry: BuilderEntry; index: number } = { - updatedEntry: { field: 'ip', type: 'match', value: 'some value', operator: 'excluded' }, + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + updatedEntry: { + id: '123', + field: 'ip', + type: OperatorTypeEnum.MATCH, + value: 'some value', + operator: 'excluded', + }, index: 0, }; expect(output).toEqual(expected); @@ -1015,8 +1110,14 @@ describe('Exception builder helpers', () => { const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); const payloadOperator: OperatorOption = isOneOfOperator; const output = getEntryOnOperatorChange(payloadItem, payloadOperator); - const expected: { updatedEntry: BuilderEntry; index: number } = { - updatedEntry: { field: 'ip', type: 'match_any', value: [], operator: 'included' }, + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + updatedEntry: { + id: '123', + field: 'ip', + type: OperatorTypeEnum.MATCH_ANY, + value: [], + operator: OperatorEnum.INCLUDED, + }, index: 0, }; expect(output).toEqual(expected); @@ -1026,19 +1127,21 @@ describe('Exception builder helpers', () => { const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); const payloadOperator: OperatorOption = isNotOperator; const output = getEntryOnOperatorChange(payloadItem, payloadOperator); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { index: 0, updatedEntry: { + id: '123', entries: [ { + id: '123', field: 'child', - operator: 'excluded', - type: 'match', + operator: OperatorEnum.EXCLUDED, + type: OperatorTypeEnum.MATCH, value: 'some value', }, ], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, }; expect(output).toEqual(expected); @@ -1048,19 +1151,21 @@ describe('Exception builder helpers', () => { const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); const payloadOperator: OperatorOption = isOneOfOperator; const output = getEntryOnOperatorChange(payloadItem, payloadOperator); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { index: 0, updatedEntry: { + id: '123', entries: [ { + id: '123', field: 'child', - operator: 'included', - type: 'match_any', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, value: [], }, ], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, }; expect(output).toEqual(expected); @@ -1071,8 +1176,14 @@ describe('Exception builder helpers', () => { test('it returns entry with updated value', () => { const payload: FormattedBuilderEntry = getMockBuilderEntry(); const output = getEntryOnMatchChange(payload, 'jibber jabber'); - const expected: { updatedEntry: BuilderEntry; index: number } = { - updatedEntry: { field: 'ip', type: 'match', value: 'jibber jabber', operator: 'included' }, + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + updatedEntry: { + id: '123', + field: 'ip', + type: OperatorTypeEnum.MATCH, + value: 'jibber jabber', + operator: OperatorEnum.INCLUDED, + }, index: 0, }; expect(output).toEqual(expected); @@ -1081,8 +1192,14 @@ describe('Exception builder helpers', () => { test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { const payload: FormattedBuilderEntry = { ...getMockBuilderEntry(), field: undefined }; const output = getEntryOnMatchChange(payload, 'jibber jabber'); - const expected: { updatedEntry: BuilderEntry; index: number } = { - updatedEntry: { field: '', type: 'match', value: 'jibber jabber', operator: 'included' }, + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + updatedEntry: { + id: '123', + field: '', + type: OperatorTypeEnum.MATCH, + value: 'jibber jabber', + operator: OperatorEnum.INCLUDED, + }, index: 0, }; expect(output).toEqual(expected); @@ -1091,19 +1208,21 @@ describe('Exception builder helpers', () => { test('it returns nested entry with updated value', () => { const payload: FormattedBuilderEntry = getMockNestedBuilderEntry(); const output = getEntryOnMatchChange(payload, 'jibber jabber'); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { index: 0, updatedEntry: { + id: '123', entries: [ { + id: '123', field: 'child', - operator: 'included', - type: 'match', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, value: 'jibber jabber', }, ], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, }; expect(output).toEqual(expected); @@ -1112,19 +1231,21 @@ describe('Exception builder helpers', () => { test('it returns nested entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { const payload: FormattedBuilderEntry = { ...getMockNestedBuilderEntry(), field: undefined }; const output = getEntryOnMatchChange(payload, 'jibber jabber'); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { index: 0, updatedEntry: { + id: '123', entries: [ { + id: '123', field: '', - operator: 'included', - type: 'match', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, value: 'jibber jabber', }, ], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, }; expect(output).toEqual(expected); @@ -1139,12 +1260,13 @@ describe('Exception builder helpers', () => { value: ['some value'], }; const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { updatedEntry: { + id: '123', field: 'ip', - type: 'match_any', + type: OperatorTypeEnum.MATCH_ANY, value: ['jibber jabber'], - operator: 'included', + operator: OperatorEnum.INCLUDED, }, index: 0, }; @@ -1159,12 +1281,13 @@ describe('Exception builder helpers', () => { field: undefined, }; const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { updatedEntry: { + id: '123', field: '', - type: 'match_any', + type: OperatorTypeEnum.MATCH_ANY, value: ['jibber jabber'], - operator: 'included', + operator: OperatorEnum.INCLUDED, }, index: 0, }; @@ -1176,27 +1299,29 @@ describe('Exception builder helpers', () => { ...getMockNestedBuilderEntry(), parent: { parent: { - ...getEntryNestedMock(), + ...getEntryNestedWithIdMock(), field: 'nestedField', - entries: [{ ...getEntryMatchAnyMock(), field: 'child' }], + entries: [{ ...getEntryMatchAnyWithIdMock(), field: 'child' }], }, parentIndex: 0, }, }; const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { index: 0, updatedEntry: { + id: '123', entries: [ { + id: '123', field: 'child', - operator: 'included', - type: 'match_any', + type: OperatorTypeEnum.MATCH_ANY, value: ['jibber jabber'], + operator: OperatorEnum.INCLUDED, }, ], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, }; expect(output).toEqual(expected); @@ -1208,27 +1333,29 @@ describe('Exception builder helpers', () => { field: undefined, parent: { parent: { - ...getEntryNestedMock(), + ...getEntryNestedWithIdMock(), field: 'nestedField', - entries: [{ ...getEntryMatchAnyMock(), field: 'child' }], + entries: [{ ...getEntryMatchAnyWithIdMock(), field: 'child' }], }, parentIndex: 0, }, }; const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { index: 0, updatedEntry: { + id: '123', entries: [ { + id: '123', field: '', - operator: 'included', - type: 'match_any', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, value: ['jibber jabber'], }, ], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, }; expect(output).toEqual(expected); @@ -1243,12 +1370,13 @@ describe('Exception builder helpers', () => { value: '1234', }; const output = getEntryOnListChange(payload, getListResponseMock()); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { updatedEntry: { + id: '123', field: 'ip', type: 'list', list: { id: 'some-list-id', type: 'ip' }, - operator: 'included', + operator: OperatorEnum.INCLUDED, }, index: 0, }; @@ -1263,12 +1391,13 @@ describe('Exception builder helpers', () => { field: undefined, }; const output = getEntryOnListChange(payload, getListResponseMock()); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { updatedEntry: { + id: '123', field: '', type: 'list', list: { id: 'some-list-id', type: 'ip' }, - operator: 'included', + operator: OperatorEnum.INCLUDED, }, index: 0, }; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx index a08f869b41d6f..8afdbce68c69a 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx @@ -5,15 +5,15 @@ * 2.0. */ +import uuid from 'uuid'; + +import { addIdToItem } from '../../../../../common/add_remove_id_to_item'; import { IIndexPattern, IFieldType } from '../../../../../../../../src/plugins/data/common'; import { Entry, OperatorTypeEnum, EntryNested, ExceptionListType, - EntryMatch, - EntryMatchAny, - EntryExists, entriesList, ListSchema, OperatorEnum, @@ -160,6 +160,7 @@ export const getFormattedBuilderEntry = ( ? { ...foundField, name: foundField.name.split('.').slice(-1)[0] } : foundField, correspondingKeywordField, + id: item.id ?? `${itemIndex}`, operator: getExceptionOperatorSelect(item), value: getEntryValue(item), nested: 'child', @@ -169,6 +170,7 @@ export const getFormattedBuilderEntry = ( } else { return { field: foundField, + id: item.id ?? `${itemIndex}`, correspondingKeywordField, operator: getExceptionOperatorSelect(item), value: getEntryValue(item), @@ -215,6 +217,7 @@ export const getFormattedBuilderEntries = ( } else { const parentEntry: FormattedBuilderEntry = { operator: isOperator, + id: item.id ?? `${index}`, nested: 'parent', field: isNewNestedEntry ? undefined @@ -265,7 +268,7 @@ export const getUpdatedEntriesOnDelete = ( const itemOfInterest: BuilderEntry = exceptionItem.entries[nestedParentIndex ?? entryIndex]; if (nestedParentIndex != null && itemOfInterest.type === OperatorTypeEnum.NESTED) { - const updatedEntryEntries: Array = [ + const updatedEntryEntries = [ ...itemOfInterest.entries.slice(0, entryIndex), ...itemOfInterest.entries.slice(entryIndex + 1), ]; @@ -282,6 +285,7 @@ export const getUpdatedEntriesOnDelete = ( const { field } = itemOfInterest; const updatedItemOfInterest: EntryNested | EmptyNestedEntry = { field, + id: itemOfInterest.id ?? `${entryIndex}`, type: OperatorTypeEnum.NESTED, entries: updatedEntryEntries, }; @@ -317,12 +321,13 @@ export const getUpdatedEntriesOnDelete = ( export const getEntryFromOperator = ( selectedOperator: OperatorOption, currentEntry: FormattedBuilderEntry -): Entry => { +): Entry & { id?: string } => { const isSameOperatorType = currentEntry.operator.type === selectedOperator.type; const fieldValue = currentEntry.field != null ? currentEntry.field.name : ''; switch (selectedOperator.type) { case 'match': return { + id: currentEntry.id, field: fieldValue, type: OperatorTypeEnum.MATCH, operator: selectedOperator.operator, @@ -331,6 +336,7 @@ export const getEntryFromOperator = ( }; case 'match_any': return { + id: currentEntry.id, field: fieldValue, type: OperatorTypeEnum.MATCH_ANY, operator: selectedOperator.operator, @@ -338,6 +344,7 @@ export const getEntryFromOperator = ( }; case 'list': return { + id: currentEntry.id, field: fieldValue, type: OperatorTypeEnum.LIST, operator: selectedOperator.operator, @@ -345,6 +352,7 @@ export const getEntryFromOperator = ( }; default: return { + id: currentEntry.id, field: fieldValue, type: OperatorTypeEnum.EXISTS, operator: selectedOperator.operator, @@ -397,7 +405,7 @@ export const getEntryOnFieldChange = ( if (nested === 'parent') { // For nested entries, when user first selects to add a nested - // entry, they first see a row similiar to what is shown for when + // entry, they first see a row similar to what is shown for when // a user selects "exists", as soon as they make a selection // we can now identify the 'parent' and 'child' this is where // we first convert the entry into type "nested" @@ -408,15 +416,16 @@ export const getEntryOnFieldChange = ( return { updatedEntry: { + id: item.id, field: newParentFieldValue, type: OperatorTypeEnum.NESTED, entries: [ - { + addIdToItem({ field: newChildFieldValue ?? '', type: OperatorTypeEnum.MATCH, operator: isOperator.operator, value: '', - }, + }), ], }, index: entryIndex, @@ -428,6 +437,7 @@ export const getEntryOnFieldChange = ( entries: [ ...parent.parent.entries.slice(0, entryIndex), { + id: item.id, field: newChildFieldValue ?? '', type: OperatorTypeEnum.MATCH, operator: isOperator.operator, @@ -441,6 +451,7 @@ export const getEntryOnFieldChange = ( } else { return { updatedEntry: { + id: item.id, field: newField != null ? newField.name : '', type: OperatorTypeEnum.MATCH, operator: isOperator.operator, @@ -508,6 +519,7 @@ export const getEntryOnMatchChange = ( entries: [ ...parent.parent.entries.slice(0, entryIndex), { + id: item.id, field: fieldName, type: OperatorTypeEnum.MATCH, operator: operator.operator, @@ -521,6 +533,7 @@ export const getEntryOnMatchChange = ( } else { return { updatedEntry: { + id: item.id, field: field != null ? field.name : '', type: OperatorTypeEnum.MATCH, operator: operator.operator, @@ -554,6 +567,7 @@ export const getEntryOnMatchAnyChange = ( entries: [ ...parent.parent.entries.slice(0, entryIndex), { + id: item.id, field: fieldName, type: OperatorTypeEnum.MATCH_ANY, operator: operator.operator, @@ -567,6 +581,7 @@ export const getEntryOnMatchAnyChange = ( } else { return { updatedEntry: { + id: item.id, field: field != null ? field.name : '', type: OperatorTypeEnum.MATCH_ANY, operator: operator.operator, @@ -594,6 +609,7 @@ export const getEntryOnListChange = ( return { updatedEntry: { + id: item.id, field: field != null ? field.name : '', type: OperatorTypeEnum.LIST, operator: operator.operator, @@ -604,6 +620,7 @@ export const getEntryOnListChange = ( }; export const getDefaultEmptyEntry = (): EmptyEntry => ({ + id: uuid.v4(), field: '', type: OperatorTypeEnum.MATCH, operator: OperatorEnum.INCLUDED, @@ -611,6 +628,7 @@ export const getDefaultEmptyEntry = (): EmptyEntry => ({ }); export const getDefaultNestedEmptyEntry = (): EmptyNestedEntry => ({ + id: uuid.v4(), field: '', type: OperatorTypeEnum.NESTED, entries: [], diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.test.tsx index 99ce4566daff6..d9402272bd9a2 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.test.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import { ReactWrapper, mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { waitFor } from '@testing-library/react'; import { @@ -23,6 +22,12 @@ import { getEmptyValue } from '../../empty_value'; import { ExceptionBuilderComponent } from './'; +const mockTheme = { + eui: { + euiColorLightShade: '#ece', + }, +}; + jest.mock('../../../../common/lib/kibana'); describe('ExceptionBuilderComponent', () => { @@ -50,7 +55,7 @@ describe('ExceptionBuilderComponent', () => { test('it displays empty entry if no "exceptionListItems" are passed in', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it displays "exceptionListItems" that are passed in', async () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it displays "or", "and" and "add nested button" enabled', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it adds an entry when "and" clicked', async () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it adds an exception item when "or" clicked', async () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it displays empty entry if user deletes last remaining entry', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it displays "and" badge if at least one exception item includes more than one entry', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it does not display "and" badge if none of the exception items include more than one entry', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { describe('nested entry', () => { test('it adds a nested entry when "add nested entry" clicked', async () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + 1); - setUpdateExceptions([...exceptions.slice(0, exceptions.length - 1), { ...updatedException }]); }, [setUpdateExceptions, exceptions] @@ -287,12 +286,12 @@ export const ExceptionBuilderComponent = ({ ...lastEntry, entries: [ ...lastEntry.entries, - { + addIdToItem({ field: '', type: OperatorTypeEnum.MATCH, operator: OperatorEnum.INCLUDED, value: '', - }, + }), ], }, ], @@ -352,7 +351,7 @@ export const ExceptionBuilderComponent = ({ }, []); return ( - + {exceptions.map((exceptionListItem, index) => ( diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/reducer.test.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/reducer.test.ts index 0741f561c1933..dbac7d325b63a 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/reducer.test.ts @@ -14,6 +14,10 @@ import { ExceptionsBuilderExceptionItem } from '../types'; import { Action, State, exceptionsBuilderReducer } from './reducer'; import { getDefaultEmptyEntry } from './helpers'; +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('123'), +})); + const initialState: State = { disableAnd: false, disableNested: false, diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.test.tsx index 00491ba495cfc..ec44f815ae243 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.test.tsx @@ -9,7 +9,6 @@ import React from 'react'; import { waitFor } from '@testing-library/react'; import { ThemeProvider } from 'styled-components'; import { mount, ReactWrapper } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { EditExceptionModal } from './'; import { useCurrentUser } from '../../../../common/lib/kibana'; @@ -29,6 +28,17 @@ import { } from '../../../../../common/detection_engine/schemas/response/rules_schema.mocks'; import { useRuleAsync } from '../../../../detections/containers/detection_engine/rules/use_rule_async'; +const mockTheme = { + eui: { + euiBreakpoints: { + l: '1200px', + }, + paddingSizes: { + m: '10px', + }, + }, +}; + jest.mock('../../../../common/lib/kibana'); jest.mock('../../../../detections/containers/detection_engine/rules'); jest.mock('../use_add_exception'); @@ -84,7 +94,7 @@ describe('When the edit exception modal is opened', () => { }, ]); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { ] as EntriesArray, }; wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { let wrapper: ReactWrapper; beforeEach(async () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { }, })); wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { let wrapper: ReactWrapper; beforeEach(async () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { beforeEach(async () => { const exceptionItemMock = { ...getExceptionListItemSchemaMock(), entries: [] }; wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('when there are exception builder errors has the add exception button disabled', async () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + - {i18n.CANCEL} + + {i18n.CANCEL} + { it('it renders error details', () => { const wrapper = mountWithIntl( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -57,21 +53,19 @@ describe('ErrorCallout', () => { it('it invokes "onCancel" when cancel button clicked', () => { const mockOnCancel = jest.fn(); const wrapper = mountWithIntl( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="errorCalloutCancelButton"]').at(0).simulate('click'); @@ -81,21 +75,19 @@ describe('ErrorCallout', () => { it('it does not render status code if not available', () => { const wrapper = mountWithIntl( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -109,21 +101,19 @@ describe('ErrorCallout', () => { it('it renders specific missing exceptions list error', () => { const wrapper = mountWithIntl( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -137,21 +127,19 @@ describe('ErrorCallout', () => { it('it dissasociates list from rule when remove exception list clicked ', () => { const wrapper = mountWithIntl( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="errorCalloutDissasociateButton"]').at(0).simulate('click'); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx index fdf7594a550a2..8651dac8c8cfd 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx @@ -48,7 +48,11 @@ import { getEntryMatchAnyMock } from '../../../../../lists/common/schemas/types/ import { getEntryExistsMock } from '../../../../../lists/common/schemas/types/entry_exists.mock'; import { getEntryListMock } from '../../../../../lists/common/schemas/types/entry_list.mock'; import { getCommentsArrayMock } from '../../../../../lists/common/schemas/types/comment.mock'; -import { ENTRIES, OLD_DATE_RELATIVE_TO_DATE_NOW } from '../../../../../lists/common/constants.mock'; +import { + ENTRIES, + ENTRIES_WITH_IDS, + OLD_DATE_RELATIVE_TO_DATE_NOW, +} from '../../../../../lists/common/constants.mock'; import { CreateExceptionListItemSchema, ExceptionListItemSchema, @@ -57,6 +61,10 @@ import { } from '../../../../../lists/common/schemas'; import { IIndexPattern } from 'src/plugins/data/common'; +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('123'), +})); + describe('Exception helpers', () => { beforeEach(() => { moment.tz.setDefault('UTC'); @@ -229,9 +237,22 @@ describe('Exception helpers', () => { }); describe('#filterExceptionItems', () => { + // Please see `x-pack/plugins/lists/public/exceptions/transforms.ts` doc notes + // for context around the temporary `id` + test('it correctly validates entries that include a temporary `id`', () => { + const output: Array< + ExceptionListItemSchema | CreateExceptionListItemSchema + > = filterExceptionItems([ + { ...getExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }, + ]); + + expect(output).toEqual([{ ...getExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }]); + }); + test('it removes entry items with "value" of "undefined"', () => { const { entries, ...rest } = getExceptionListItemSchemaMock(); const mockEmptyException: EmptyEntry = { + id: '123', field: 'host.name', type: OperatorTypeEnum.MATCH, operator: OperatorEnum.INCLUDED, @@ -250,6 +271,7 @@ describe('Exception helpers', () => { test('it removes "match" entry items with "value" of empty string', () => { const { entries, ...rest } = { ...getExceptionListItemSchemaMock() }; const mockEmptyException: EmptyEntry = { + id: '123', field: 'host.name', type: OperatorTypeEnum.MATCH, operator: OperatorEnum.INCLUDED, @@ -270,6 +292,7 @@ describe('Exception helpers', () => { test('it removes "match" entry items with "field" of empty string', () => { const { entries, ...rest } = { ...getExceptionListItemSchemaMock() }; const mockEmptyException: EmptyEntry = { + id: '123', field: '', type: OperatorTypeEnum.MATCH, operator: OperatorEnum.INCLUDED, @@ -290,6 +313,7 @@ describe('Exception helpers', () => { test('it removes "match_any" entry items with "field" of empty string', () => { const { entries, ...rest } = { ...getExceptionListItemSchemaMock() }; const mockEmptyException: EmptyEntry = { + id: '123', field: '', type: OperatorTypeEnum.MATCH_ANY, operator: OperatorEnum.INCLUDED, diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx index 13ee06e8cbac9..c44de4f05e7f6 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -44,6 +44,7 @@ import { validate } from '../../../../common/validate'; import { Ecs } from '../../../../common/ecs'; import { CodeSignature } from '../../../../common/ecs/file'; import { WithCopyToClipboard } from '../../lib/clipboard/with_copy_to_clipboard'; +import { addIdToItem, removeIdFromItem } from '../../../../common'; /** * Returns the operator type, may not need this if using io-ts types @@ -150,12 +151,12 @@ export const getNewExceptionItem = ({ comments: [], description: `${ruleName} - exception list item`, entries: [ - { + addIdToItem({ field: '', operator: 'included', type: 'match', value: '', - }, + }), ], item_id: undefined, list_id: listId, @@ -175,26 +176,32 @@ export const filterExceptionItems = ( return exceptions.reduce>( (acc, exception) => { const entries = exception.entries.reduce((nestedAcc, singleEntry) => { - if (singleEntry.type === 'nested') { - const nestedEntriesArray = singleEntry.entries.filter((singleNestedEntry) => { - const [validatedNestedEntry] = validate(singleNestedEntry, nestedEntryItem); + const strippedSingleEntry = removeIdFromItem(singleEntry); + + if (entriesNested.is(strippedSingleEntry)) { + const nestedEntriesArray = strippedSingleEntry.entries.filter((singleNestedEntry) => { + const noIdSingleNestedEntry = removeIdFromItem(singleNestedEntry); + const [validatedNestedEntry] = validate(noIdSingleNestedEntry, nestedEntryItem); return validatedNestedEntry != null; }); + const noIdNestedEntries = nestedEntriesArray.map((singleNestedEntry) => + removeIdFromItem(singleNestedEntry) + ); const [validatedNestedEntry] = validate( - { ...singleEntry, entries: nestedEntriesArray }, + { ...strippedSingleEntry, entries: noIdNestedEntries }, entriesNested ); if (validatedNestedEntry != null) { - return [...nestedAcc, validatedNestedEntry]; + return [...nestedAcc, { ...singleEntry, entries: nestedEntriesArray }]; } return nestedAcc; } else { - const [validatedEntry] = validate(singleEntry, entry); + const [validatedEntry] = validate(strippedSingleEntry, entry); if (validatedEntry != null) { - return [...nestedAcc, validatedEntry]; + return [...nestedAcc, singleEntry]; } return nestedAcc; } diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts index 6108a21ce5624..c7a125daa54f8 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts @@ -60,16 +60,18 @@ export interface ExceptionsPagination { } export interface FormattedBuilderEntry { + id: string; field: IFieldType | undefined; operator: OperatorOption; value: string | string[] | undefined; nested: 'parent' | 'child' | undefined; entryIndex: number; - parent: { parent: EntryNested; parentIndex: number } | undefined; + parent: { parent: BuilderEntryNested; parentIndex: number } | undefined; correspondingKeywordField: IFieldType | undefined; } export interface EmptyEntry { + id: string; field: string | undefined; operator: OperatorEnum; type: OperatorTypeEnum.MATCH | OperatorTypeEnum.MATCH_ANY; @@ -77,6 +79,7 @@ export interface EmptyEntry { } export interface EmptyListEntry { + id: string; field: string | undefined; operator: OperatorEnum; type: OperatorTypeEnum.LIST; @@ -84,12 +87,31 @@ export interface EmptyListEntry { } export interface EmptyNestedEntry { + id: string; field: string | undefined; type: OperatorTypeEnum.NESTED; - entries: Array; + entries: Array< + | (EntryMatch & { id?: string }) + | (EntryMatchAny & { id?: string }) + | (EntryExists & { id?: string }) + >; } -export type BuilderEntry = Entry | EmptyListEntry | EmptyEntry | EntryNested | EmptyNestedEntry; +export type BuilderEntry = + | (Entry & { id?: string }) + | EmptyListEntry + | EmptyEntry + | BuilderEntryNested + | EmptyNestedEntry; + +export type BuilderEntryNested = Omit & { + id?: string; + entries: Array< + | (EntryMatch & { id?: string }) + | (EntryMatchAny & { id?: string }) + | (EntryExists & { id?: string }) + >; +}; export type ExceptionListItemBuilderSchema = Omit & { entries: BuilderEntry[]; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx index c7d7d2d39393c..b96ae5c06dd22 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx @@ -8,13 +8,18 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import moment from 'moment-timezone'; import { ExceptionDetails } from './exception_details'; import { getExceptionListItemSchemaMock } from '../../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { getCommentsArrayMock } from '../../../../../../../lists/common/schemas/types/comment.mock'; +const mockTheme = { + eui: { + euiColorLightestShade: '#ece', + }, +}; + describe('ExceptionDetails', () => { beforeEach(() => { moment.tz.setDefault('UTC'); @@ -29,7 +34,7 @@ describe('ExceptionDetails', () => { exceptionItem.comments = []; const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.comments = getCommentsArrayMock(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.comments = [getCommentsArrayMock()[0]]; const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.comments = getCommentsArrayMock(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.comments = getCommentsArrayMock(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.comments = getCommentsArrayMock(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.comments = getCommentsArrayMock(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders the operating system if one is specified in the exception item', () => { const exceptionItem = getExceptionListItemSchemaMock(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders the exception item creator', () => { const exceptionItem = getExceptionListItemSchemaMock(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders the exception item creation timestamp', () => { const exceptionItem = getExceptionListItemSchemaMock(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders the description if one is included on the exception item', () => { const exceptionItem = getExceptionListItemSchemaMock(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it does NOT render the and badge if only one exception item entry exists', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders the and badge if more than one exception item exists', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it invokes "onEdit" when edit button clicked', () => { const mockOnEdit = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it invokes "onDelete" when delete button clicked', () => { const mockOnDelete = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders edit button disabled if "disableDelete" is "true"', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders delete button in loading state if "disableDelete" is "true"', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { parentEntry.value = undefined; const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders non-nested entries', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { it('it renders ExceptionDetails and ExceptionEntries', () => { const exceptionItem = getExceptionListItemSchemaMock(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { const exceptionItem = getExceptionListItemSchemaMock(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { const exceptionItem = getExceptionListItemSchemaMock(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.comments = getCommentsArrayMock(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.comments = getCommentsArrayMock(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { it('it renders passed in "pageSize" as selected option', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect(wrapper.find('[data-test-subj="exceptionsPerPageBtn"]').at(0).text()).toEqual( @@ -35,17 +31,15 @@ describe('ExceptionsViewerPagination', () => { it('it renders all passed in page size options when per page button clicked', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="exceptionsPerPageBtn"] button').simulate('click'); @@ -64,17 +58,15 @@ describe('ExceptionsViewerPagination', () => { it('it invokes "onPaginationChange" when per page item is clicked', () => { const mockOnPaginationChange = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="exceptionsPerPageBtn"] button').simulate('click'); @@ -87,17 +79,15 @@ describe('ExceptionsViewerPagination', () => { it('it renders correct total page count', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect(wrapper.find('[data-test-subj="exceptionsPagination"]').at(0).prop('pageCount')).toEqual( @@ -111,17 +101,15 @@ describe('ExceptionsViewerPagination', () => { it('it invokes "onPaginationChange" when next clicked', () => { const mockOnPaginationChange = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="pagination-button-next"]').at(1).simulate('click'); @@ -134,17 +122,15 @@ describe('ExceptionsViewerPagination', () => { it('it invokes "onPaginationChange" when page clicked', () => { const mockOnPaginationChange = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('button[data-test-subj="pagination-button-3"]').simulate('click'); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_utility.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_utility.test.tsx index 6167a29a4a17d..42ce0c792dfa3 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_utility.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_utility.test.tsx @@ -8,14 +8,25 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import { mountWithIntl } from '@kbn/test/jest'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { ExceptionsViewerUtility } from './exceptions_utility'; +const mockTheme = { + eui: { + euiBreakpoints: { + l: '1200px', + }, + paddingSizes: { + m: '10px', + }, + euiBorderThin: '1px solid #ece', + }, +}; + describe('ExceptionsViewerUtility', () => { it('it renders correct pluralized text when more than one exception exists', () => { const wrapper = mountWithIntl( - ({ eui: euiLightVars, darkMode: false })}> + { it('it renders correct singular text when less than two exceptions exists', () => { const wrapper = mountWithIntl( - ({ eui: euiLightVars, darkMode: false })}> + { it('it invokes "onRefreshClick" when refresh button clicked', () => { const mockOnRefreshClick = jest.fn(); const wrapper = mountWithIntl( - ({ eui: euiLightVars, darkMode: false })}> + { it('it does not render any messages when "showEndpointList" and "showDetectionsList" are "false"', () => { const wrapper = mountWithIntl( - ({ eui: euiLightVars, darkMode: false })}> + { it('it does render detections messages when "showDetectionsList" is "true"', () => { const wrapper = mountWithIntl( - ({ eui: euiLightVars, darkMode: false })}> + { it('it does render endpoint messages when "showEndpointList" is "true"', () => { const wrapper = mountWithIntl( - ({ eui: euiLightVars, darkMode: false })}> + { it('it renders all disabled if "isInitLoading" is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -47,16 +43,14 @@ describe('ExceptionsViewerHeader', () => { it('it displays toggles and add exception popover when more than one list type available', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect(wrapper.find('[data-test-subj="exceptionsFilterGroupBtns"]').exists()).toBeTruthy(); @@ -67,16 +61,14 @@ describe('ExceptionsViewerHeader', () => { it('it does not display toggles and add exception popover if only one list type is available', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect(wrapper.find('[data-test-subj="exceptionsFilterGroupBtns"]')).toHaveLength(0); @@ -87,16 +79,14 @@ describe('ExceptionsViewerHeader', () => { it('it displays add exception button without popover if only one list type is available', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -107,16 +97,14 @@ describe('ExceptionsViewerHeader', () => { it('it renders detections filter toggle selected when clicked', () => { const mockOnFilterChange = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="exceptionsDetectionFilterBtn"] button').simulate('click'); @@ -149,16 +137,14 @@ describe('ExceptionsViewerHeader', () => { it('it renders endpoint filter toggle selected and invokes "onFilterChange" when clicked', () => { const mockOnFilterChange = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="exceptionsEndpointFilterBtn"] button').simulate('click'); @@ -191,16 +177,14 @@ describe('ExceptionsViewerHeader', () => { it('it invokes "onAddExceptionClick" when user selects to add an exception item and only endpoint exception lists are available', () => { const mockOnAddExceptionClick = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="exceptionsHeaderAddExceptionBtn"] button').simulate('click'); @@ -211,16 +195,14 @@ describe('ExceptionsViewerHeader', () => { it('it invokes "onAddDetectionsExceptionClick" when user selects to add an exception item and only endpoint detections lists are available', () => { const mockOnAddExceptionClick = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="exceptionsHeaderAddExceptionBtn"] button').simulate('click'); @@ -231,16 +213,14 @@ describe('ExceptionsViewerHeader', () => { it('it invokes "onAddEndpointExceptionClick" when user selects to add an exception item to endpoint list from popover', () => { const mockOnAddExceptionClick = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper @@ -254,16 +234,14 @@ describe('ExceptionsViewerHeader', () => { it('it invokes "onAddDetectionsExceptionClick" when user selects to add an exception item to endpoint list from popover', () => { const mockOnAddExceptionClick = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper @@ -277,16 +255,14 @@ describe('ExceptionsViewerHeader', () => { it('it invokes "onFilterChange" when search used and "Enter" pressed', () => { const mockOnFilterChange = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('EuiFieldSearch').at(0).simulate('keyup', { diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_items.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_items.test.tsx index 3171735d905de..167b95995212b 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_items.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_items.test.tsx @@ -8,27 +8,32 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import * as i18n from '../translations'; import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { ExceptionsViewerItems } from './exceptions_viewer_items'; +const mockTheme = { + eui: { + euiSize: '10px', + euiColorPrimary: '#ece', + euiColorDanger: '#ece', + }, +}; + describe('ExceptionsViewerItems', () => { it('it renders empty prompt if "showEmpty" is "true"', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect(wrapper.find('[data-test-subj="exceptionsEmptyPrompt"]').exists()).toBeTruthy(); @@ -43,7 +48,7 @@ describe('ExceptionsViewerItems', () => { it('it renders no search results found prompt if "showNoResults" is "true"', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { it('it renders exceptions if "showEmpty" and "isInitLoading" is "false", and exceptions exist', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { it('it does not render exceptions if "isInitLoading" is "true"', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { exception2.id = 'newId'; const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { exception2.id = 'newId'; const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { const mockOnDeleteException = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { jest.fn(), ]); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { it('it renders empty prompt if no "exceptionListMeta" passed in', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { ]); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); const closeModal = jest.fn(); describe('rendering', () => { test('when isShowing is positive and request and response are not null', () => { const wrapper = mount( - + { describe('functionality from tab statistics/request/response', () => { test('Click on statistic Tab', () => { const wrapper = mount( - + { test('Click on request Tab', () => { const wrapper = mount( - + { test('Click on response Tab', () => { const wrapper = mount( - + { describe('events', () => { test('Make sure that toggle function has been called when you click on the close button', () => { const wrapper = mount( - + - +

My test supplement.

- } - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={ - Array [ - Object { - "numberOfRow": 2, - "text": "2 rows", - }, - Object { - "numberOfRow": 5, - "text": "5 rows", - }, - Object { - "numberOfRow": 10, - "text": "10 rows", - }, - Object { - "numberOfRow": 20, - "text": "20 rows", - }, - Object { - "numberOfRow": 50, - "text": "50 rows", - }, - ] - } - limit={1} - loadPage={[MockFunction]} - loading={false} - pageOfItems={ - Array [ - Object { - "cursor": Object { - "value": "98966fa2013c396155c460d35c0902be", - }, - "host": Object { - "_id": "cPsuhGcB0WOhS6qyTKC0", - "firstSeen": "2018-12-06T15:40:53.319Z", - "name": "elrond.elstc.co", - "os": "Ubuntu", - "version": "18.04.1 LTS (Bionic Beaver)", - }, - }, - Object { - "cursor": Object { - "value": "aa7ca589f1b8220002f2fc61c64cfbf1", - }, - "host": Object { - "_id": "KwQDiWcB0WOhS6qyXmrW", - "firstSeen": "2018-12-07T14:12:38.560Z", - "name": "siem-kibana", - "os": "Debian GNU/Linux", - "version": "9 (stretch)", - }, - }, - ] - } - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={[MockFunction]} - updateLimitPagination={[Function]} - /> - +
+ + + + + Rows per page: 1 + + } + closePopover={[Function]} + data-test-subj="loadingMoreSizeRowPopover" + display="inlineBlock" + hasArrow={true} + id="customizablePagination" + isOpen={false} + ownFocus={false} + panelPaddingSize="none" + repositionOnScroll={true} + > + + 2 rows + , + + 5 rows + , + + 10 rows + , + + 20 rows + , + + 50 rows + , + ] + } + /> + + + + + + + + `; diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.test.tsx index 57d4c8451de24..c20f1ae66c797 100644 --- a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.test.tsx @@ -14,7 +14,6 @@ import { Direction } from '../../../graphql/types'; import { BasicTableProps, PaginatedTable } from './index'; import { getHostsColumns, mockData, rowItems, sortedHosts } from './index.mock'; import { ThemeProvider } from 'styled-components'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; jest.mock('react', () => { const r = jest.requireActual('react'); @@ -22,8 +21,20 @@ jest.mock('react', () => { return { ...r, memo: (x: any) => x }; }); +const mockTheme = { + eui: { + euiColorEmptyShade: '#ece', + euiSizeL: '10px', + euiBreakpoints: { + s: '450px', + }, + paddingSizes: { + m: '10px', + }, + }, +}; + describe('Paginated Table Component', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); let loadPage: jest.Mock; let updateLimitPagination: jest.Mock; let updateActivePage: jest.Mock; @@ -36,26 +47,24 @@ describe('Paginated Table Component', () => { describe('rendering', () => { test('it renders the default load more table', () => { const wrapper = shallow( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> -
+ {'My test supplement.'}

} + headerTitle="Hosts" + headerTooltip="My test tooltip" + headerUnit="Test Unit" + itemsPerRow={rowItems} + limit={1} + loading={false} + loadPage={loadPage} + pageOfItems={mockData.Hosts.edges} + showMorePagesIndicator={true} + totalCount={10} + updateActivePage={updateActivePage} + updateLimitPagination={(limit) => updateLimitPagination({ limit })} + /> ); expect(wrapper).toMatchSnapshot(); @@ -63,7 +72,7 @@ describe('Paginated Table Component', () => { test('it renders the loading panel at the beginning ', () => { const wrapper = mount( - + { test('it renders the over loading panel after data has been in the table ', () => { const wrapper = mount( - + { test('it renders the correct amount of pages and starts at activePage: 0', () => { const wrapper = mount( - + { test('it render popover to select new limit in table', () => { const wrapper = mount( - + { test('it will NOT render popover to select new limit in table if props itemsPerRow is empty', () => { const wrapper = mount( - + { test('It should render a sort icon if sorting is defined', () => { const mockOnChange = jest.fn(); const wrapper = mount( - + { test('Should display toast when user reaches end of results max', () => { const wrapper = mount( - + { test('Should show items per row if totalCount is greater than items', () => { const wrapper = mount( - + { test('Should hide items per row if totalCount is less than items', () => { const wrapper = mount( - + { describe('Events', () => { test('should call updateActivePage with 1 when clicking to the first page', () => { const wrapper = mount( - + { test('Should call updateActivePage with 0 when you pick a new limit', () => { const wrapper = mount( - + { // eslint-disable-next-line @typescript-eslint/no-explicit-any const ComponentWithContext = (props: BasicTableProps) => { return ( - + ); @@ -424,7 +433,7 @@ describe('Paginated Table Component', () => { test('Should call updateLimitPagination when you pick a new limit', () => { const wrapper = mount( - + { test('Should call onChange when you choose a new sort in the table', () => { const mockOnChange = jest.fn(); const wrapper = mount( - + { }); describe('Stat Items Component', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); + const mockTheme = { eui: { euiColorMediumShade: '#ece' } }; const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); const store = createStore( @@ -71,7 +70,7 @@ describe('Stat Items Component', () => { describe.each([ [ mount( - + { ], [ mount( - + { test('it renders entryItemIndexItemEntryFirstRowAndBadge for very first item', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + ); @@ -25,7 +30,7 @@ describe('AndBadgeComponent', () => { test('it renders entryItemEntryInvisibleAndBadge if "entriesLength" is 1 or less', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + ); @@ -37,7 +42,7 @@ describe('AndBadgeComponent', () => { test('it renders regular "and" badge if item is not the first one and includes more than one entry', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + ); diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx index eced8d785792d..ef3e9280e6e6b 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx @@ -14,7 +14,7 @@ import { import { IndexPattern, IFieldType } from '../../../../../../../src/plugins/data/common'; import { Entry, FormattedEntry, ThreatMapEntries, EmptyEntry } from './types'; -import { addIdToItem } from '../../utils/add_remove_id_to_item'; +import { addIdToItem } from '../../../../common/add_remove_id_to_item'; /** * Formats the entry into one that is easily usable for the UI. diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/index.test.tsx index 73174bc5fc113..6aa33c3bcf4ca 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/index.test.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { waitFor } from '@testing-library/react'; import { fields } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; @@ -19,6 +18,12 @@ import { ThreatMatchComponent } from './'; import { ThreatMapEntries } from './types'; import { IndexPattern } from 'src/plugins/data/public'; +const mockTheme = { + eui: { + euiColorLightShade: '#ece', + }, +}; + jest.mock('../../../common/lib/kibana'); const getPayLoad = (): ThreatMapEntries[] => [ @@ -51,7 +56,7 @@ describe('ThreatMatchComponent', () => { test('it displays empty entry if no "listItems" are passed in', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it displays "Search" for "listItems" that are passed in', async () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it displays "or", "and" enabled', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it adds an entry when "and" clicked', async () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it adds an item when "or" clicked', async () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it removes one row if user deletes a row', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it displays "and" badge if at least one item includes more than one entry', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it does not display "and" badge if none of the items include more than one entry', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + ({ @@ -66,7 +71,7 @@ describe('ListItemComponent', () => { describe('and badge logic', () => { test('it renders "and" badge with extra top padding for the first item when "andLogicIncluded" is "true"', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders "and" badge when more than one item entry exists and it is not the first item', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders indented "and" badge when "andLogicIncluded" is "true" and only one entry exists', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders no "and" badge when "andLogicIncluded" is "false"', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders loader when isLoading is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - - + + + ); expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeTruthy(); @@ -58,23 +54,21 @@ describe('PreviewCustomQueryHistogram', () => { test('it configures data and subtitle', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - - + + + ); expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeFalsy(); @@ -111,19 +105,17 @@ describe('PreviewCustomQueryHistogram', () => { const mockRefetch = jest.fn(); mount( - ({ eui: euiLightVars, darkMode: false })}> - - - - + + + ); expect(mockSetQuery).toHaveBeenCalledWith({ diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.test.tsx index 65bb029e2e32f..df6a8975a5b97 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.test.tsx @@ -6,9 +6,7 @@ */ import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import * as i18n from './translations'; import { useGlobalTime } from '../../../../common/containers/use_global_time'; @@ -35,19 +33,17 @@ describe('PreviewEqlQueryHistogram', () => { test('it renders loader when isLoading is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - - + + + ); expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeTruthy(); @@ -58,23 +54,21 @@ describe('PreviewEqlQueryHistogram', () => { test('it configures data and subtitle', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - - + + + ); expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeFalsy(); @@ -109,19 +103,17 @@ describe('PreviewEqlQueryHistogram', () => { const mockRefetch = jest.fn(); mount( - ({ eui: euiLightVars, darkMode: false })}> - - - - + + + ); expect(mockSetQuery).toHaveBeenCalledWith({ @@ -134,23 +126,21 @@ describe('PreviewEqlQueryHistogram', () => { test('it displays histogram', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - - + + + ); expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeFalsy(); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/histogram.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/histogram.test.tsx index d9bd32ce082ca..85e31e7ed36e5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/histogram.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/histogram.test.tsx @@ -6,9 +6,7 @@ */ import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { TestProviders } from '../../../../common/mock'; import { PreviewHistogram } from './histogram'; @@ -17,19 +15,17 @@ import { getHistogramConfig } from './helpers'; describe('PreviewHistogram', () => { test('it renders loading icon if "isLoading" is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - - + + + ); expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeTruthy(); @@ -38,40 +34,38 @@ describe('PreviewHistogram', () => { test('it renders chart if "isLoading" is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - - + + + ); expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeFalsy(); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.test.tsx index bb87242d9bf10..700c2d516b995 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.test.tsx @@ -9,7 +9,6 @@ import React from 'react'; import { of } from 'rxjs'; import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { TestProviders } from '../../../../common/mock'; import { useKibana } from '../../../../common/lib/kibana'; @@ -18,6 +17,12 @@ import { getMockEqlResponse } from '../../../../common/hooks/eql/eql_search_resp import { useMatrixHistogram } from '../../../../common/containers/matrix_histogram'; import { useEqlPreview } from '../../../../common/hooks/eql/'; +const mockTheme = { + eui: { + euiSuperDatePickerWidth: '180px', + }, +}; + jest.mock('../../../../common/lib/kibana'); jest.mock('../../../../common/containers/matrix_histogram'); jest.mock('../../../../common/hooks/eql/'); @@ -63,7 +68,7 @@ describe('PreviewQuery', () => { test('it renders timeframe select and preview button on render', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders preview button disabled if "isDisabled" is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders preview button disabled if "query" is undefined', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders query histogram when rule type is query and preview button clicked', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders noise warning when rule type is query, timeframe is last hour and hit average is greater than 1/hour', async () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders query histogram when rule type is saved_query and preview button clicked', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders eql histogram when preview button clicked and rule type is eql', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders noise warning when rule type is eql, timeframe is last hour and hit average is greater than 1/hour', async () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders threshold histogram when preview button clicked, rule type is threshold, and threshold field is defined', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders noise warning when rule type is threshold, and threshold field is defined, timeframe is last hour and hit average is greater than 1/hour', async () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders query histogram when preview button clicked, rule type is threshold, and threshold field is not defined', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders query histogram when preview button clicked, rule type is threshold, and threshold field is empty string', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it hides histogram when timeframe changes', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders loader when isLoading is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - - + + + ); expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeTruthy(); @@ -51,20 +47,18 @@ describe('PreviewThresholdQueryHistogram', () => { test('it configures buckets data', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - - + + + ); expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeFalsy(); @@ -86,20 +80,18 @@ describe('PreviewThresholdQueryHistogram', () => { const mockRefetch = jest.fn(); mount( - ({ eui: euiLightVars, darkMode: false })}> - - - - + + + ); expect(mockSetQuery).toHaveBeenCalledWith({ diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.test.tsx index 71670658c88a9..fc91c26148c17 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.test.tsx @@ -7,8 +7,6 @@ import { mount } from 'enzyme'; import React from 'react'; -import { ThemeProvider } from 'styled-components'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { waitFor } from '@testing-library/react'; import { enableRules } from '../../../containers/detection_engine/rules'; @@ -34,9 +32,7 @@ describe('RuleSwitch', () => { test('it renders loader if "isLoading" is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect(wrapper.find('[data-test-subj="ruleSwitchLoader"]').exists()).toBeTruthy(); @@ -45,42 +41,27 @@ describe('RuleSwitch', () => { test('it renders switch disabled if "isDisabled" is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect(wrapper.find('[data-test-subj="ruleSwitch"]').at(0).props().disabled).toBeTruthy(); }); test('it renders switch enabled if "enabled" is true', () => { - const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - ); + const wrapper = mount(); expect(wrapper.find('[data-test-subj="ruleSwitch"]').at(0).props().checked).toBeTruthy(); }); test('it renders switch disabled if "enabled" is false', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect(wrapper.find('[data-test-subj="ruleSwitch"]').at(0).props().checked).toBeFalsy(); }); test('it renders an off switch enabled on click', async () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="ruleSwitch"]').at(2).simulate('click'); @@ -96,9 +77,7 @@ describe('RuleSwitch', () => { (enableRules as jest.Mock).mockResolvedValue([rule]); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="ruleSwitch"]').at(2).simulate('click'); @@ -113,14 +92,7 @@ describe('RuleSwitch', () => { (enableRules as jest.Mock).mockRejectedValue(mockError); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="ruleSwitch"]').at(2).simulate('click'); @@ -138,14 +110,7 @@ describe('RuleSwitch', () => { ]); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="ruleSwitch"]').at(2).simulate('click'); @@ -157,15 +122,13 @@ describe('RuleSwitch', () => { test('it invokes "enableRulesAction" if dispatch is passed through', async () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="ruleSwitch"]').at(2).simulate('click'); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx index edd5c0d4e6e4c..c1773b2fffbab 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { mount, shallow } from 'enzyme'; import { ThemeProvider } from 'styled-components'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_light.json'; import { act } from '@testing-library/react'; import { stubIndexPattern } from 'src/plugins/data/common/index_patterns/index_pattern.stub'; @@ -24,8 +23,13 @@ import { } from '../../../pages/detection_engine/rules/types'; import { fillEmptySeverityMappings } from '../../../pages/detection_engine/rules/helpers'; +const mockTheme = { + eui: { + euiColorLightestShade: '#ece', + }, +}; + jest.mock('../../../../common/containers/source'); -const theme = () => ({ eui: euiDarkVars, darkMode: true }); jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { @@ -37,6 +41,7 @@ jest.mock('@elastic/eui', () => { }, }; }); + describe('StepAboutRuleComponent', () => { let formHook: RuleStepsFormHooks[RuleStep.aboutRule] | null = null; const setFormHook = ( @@ -72,7 +77,7 @@ describe('StepAboutRuleComponent', () => { it('is invalid if description is not present', async () => { const wrapper = mount( - + { it('is invalid if no "name" is present', async () => { const wrapper = mount( - + { it('is valid if both "name" and "description" are present', async () => { const wrapper = mount( - + { it('it allows user to set the risk score as a number (and not a string)', async () => { const wrapper = mount( - + { it('does not modify the provided risk score until the user changes the severity', async () => { const wrapper = mount( - + = ({ euiFieldProps: { fullWidth: true, disabled: isLoading, - placeholder: DEFAULT_INDICATOR_PATH, + placeholder: DEFAULT_INDICATOR_SOURCE_PATH, }, }} /> diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/schema.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/schema.tsx index 5e4d08c4e7939..07012af17e734 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/schema.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/schema.tsx @@ -205,7 +205,7 @@ export const schema: FormSchema = { 'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldThreatIndicatorPathHelpText', { defaultMessage: - 'Specify the document path containing your threat indicator fields. Used for enrichment of indicator match alerts. Defaults to threat.indicator unless otherwise specified.', + 'Specify the document path containing your threat indicator fields. Used for enrichment of indicator match alerts.', } ), labelAppend: OptionalFieldLabel, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.test.tsx index 0e9564ed410db..52bb844d24339 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.test.tsx @@ -9,7 +9,6 @@ import React from 'react'; import { mount, shallow } from 'enzyme'; import { EuiProgress, EuiButtonGroup } from '@elastic/eui'; import { ThemeProvider } from 'styled-components'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_light.json'; import { StepAboutRuleToggleDetails } from '.'; import { mockAboutStepRule } from '../../../pages/detection_engine/rules/all/__mocks__/mock'; @@ -19,7 +18,9 @@ import { AboutStepRule } from '../../../pages/detection_engine/rules/types'; jest.mock('../../../../common/lib/kibana'); -const theme = () => ({ eui: euiDarkVars, darkMode: true }); +const mockTheme = { + eui: { euiSizeL: '10px', euiBreakpoints: { s: '450px' }, paddingSizes: { m: '10px' } }, +}; describe('StepAboutRuleToggleDetails', () => { let mockRule: AboutStepRule; @@ -93,7 +94,7 @@ describe('StepAboutRuleToggleDetails', () => { describe('note value does exist', () => { test('it renders toggle buttons, defaulted to "details"', () => { const wrapper = mount( - + { test('it allows users to toggle between "details" and "note"', () => { const wrapper = mount( - + { test('it displays notes markdown when user toggles to "notes"', () => { const wrapper = mount( - + ({ eui: euiDarkVars, darkMode: true }); +const mockTheme = { eui: { euiBreakpoints: { l: '1200px' }, paddingSizes: { m: '10px' } } }; describe('AllRules', () => { it('renders AllRulesUtilityBar total rules and selected rules', () => { const wrapper = mount( - + { it('does not render total selected and bulk actions when "showBulkActions" is false', () => { const wrapper = mount( - + { it('renders utility actions if user has permissions', () => { const wrapper = mount( - + { it('renders no utility actions if user has no permissions', () => { const wrapper = mount( - + { it('invokes refresh on refresh action click', () => { const mockRefresh = jest.fn(); const wrapper = mount( - + { it('invokes onRefreshSwitch when auto refresh switch is clicked', async () => { const mockSwitch = jest.fn(); const wrapper = mount( - + ({ htmlIdGenerator: () => () => 'mockId', })); @@ -32,9 +37,7 @@ const now = 111111; const renderList = (store: ReturnType) => { const Wrapper: React.FC = ({ children }) => ( - ({ eui: euiLightVars, darkMode: false })}> - {children} - + {children} ); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_list/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_list/__snapshots__/index.test.tsx.snap index 8f70c61ba4afc..5a176018f0e3f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_list/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_list/__snapshots__/index.test.tsx.snap @@ -486,12 +486,7 @@ exports[`TrustedAppsList renders correctly when failed loading data for the seco exports[`TrustedAppsList renders correctly when item details expanded 1`] = ` .c0 { - background-color: #f5f7fa; - padding: 16px; -} - -.c3 { - padding: 16px; + background-color: #ece; } .c1.c1.c1 { @@ -864,7 +859,7 @@ exports[`TrustedAppsList renders correctly when item details expanded 1`] = `
({ @@ -32,9 +37,7 @@ const now = 111111; const renderList = (store: ReturnType) => { const Wrapper: React.FC = ({ children }) => ( - ({ eui: euiLightVars, darkMode: false })}> - {children} - + {children} ); diff --git a/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx index 4b62139a8679f..0ec12a00d578b 100644 --- a/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx @@ -5,10 +5,8 @@ * 2.0. */ -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { mount, ReactWrapper } from 'enzyme'; import React from 'react'; -import { ThemeProvider } from 'styled-components'; import '../../../common/mock/match_media'; import '../../../common/mock/react_beautiful_dnd'; @@ -24,7 +22,6 @@ jest.mock('../../../common/containers/matrix_histogram', () => ({ useMatrixHistogram: jest.fn(), })); -const theme = () => ({ eui: { ...euiDarkVars, euiSizeL: '24px' }, darkMode: true }); const from = '2020-03-31T06:00:00.000Z'; const to = '2019-03-31T06:00:00.000Z'; @@ -55,11 +52,9 @@ describe('Alerts by category', () => { ]); wrapper = mount( - - - - - + + + ); await waitFor(() => { @@ -123,11 +118,9 @@ describe('Alerts by category', () => { ]); wrapper = mount( - - - - - + + + ); wrapper.update(); diff --git a/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx b/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx index ae8a00d2b4aa0..004e675cb3516 100644 --- a/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx @@ -113,7 +113,7 @@ const StatefulRecentTimelinesComponent: React.FC = ({ apolloClient, filte )} diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/categories_pane.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/categories_pane.test.tsx index 278e01bcd8923..0f7a2070b8ef4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/categories_pane.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/categories_pane.test.tsx @@ -7,8 +7,6 @@ import { mount } from 'enzyme'; import React from 'react'; -import { ThemeProvider } from 'styled-components'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { mockBrowserFields } from '../../../common/containers/source/mock'; @@ -19,18 +17,15 @@ import * as i18n from './translations'; const timelineId = 'test'; describe('CategoriesPane', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); test('it renders the expected title', () => { const wrapper = mount( - - - + ); expect(wrapper.find('[data-test-subj="categories-pane-title"]').first().text()).toEqual( @@ -40,15 +35,13 @@ describe('CategoriesPane', () => { test('it renders a "No fields match" message when filteredBrowserFields is empty', () => { const wrapper = mount( - - - + ); expect(wrapper.find('[data-test-subj="categories-container"] tbody').first().text()).toEqual( diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/category_columns.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/category_columns.test.tsx index 44b65185627ff..7b00b768b56a0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/category_columns.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/category_columns.test.tsx @@ -12,25 +12,20 @@ import { mockBrowserFields } from '../../../common/containers/source/mock'; import { CATEGORY_PANE_WIDTH, getFieldCount } from './helpers'; import { CategoriesPane } from './categories_pane'; -import { ThemeProvider } from 'styled-components'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; const timelineId = 'test'; -const theme = () => ({ eui: euiDarkVars, darkMode: true }); describe('getCategoryColumns', () => { Object.keys(mockBrowserFields).forEach((categoryId) => { test(`it renders the ${categoryId} category name (from filteredBrowserFields)`, () => { const wrapper = mount( - - - + ); const fieldCount = Object.keys(mockBrowserFields[categoryId].fields ?? {}).length; @@ -44,15 +39,13 @@ describe('getCategoryColumns', () => { Object.keys(mockBrowserFields).forEach((categoryId) => { test(`it renders the correct field count for the ${categoryId} category (from filteredBrowserFields)`, () => { const wrapper = mount( - - - + ); expect( @@ -65,15 +58,13 @@ describe('getCategoryColumns', () => { const selectedCategoryId = 'auditd'; const wrapper = mount( - - - + ); expect( @@ -89,15 +80,13 @@ describe('getCategoryColumns', () => { const notTheSelectedCategoryId = 'base'; const wrapper = mount( - - - + ); expect( @@ -115,15 +104,13 @@ describe('getCategoryColumns', () => { const onCategorySelected = jest.fn(); const wrapper = mount( - - - + ); wrapper diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx index 49f68281ae103..c130ea4c96814 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx @@ -7,12 +7,26 @@ import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; +import { useParams } from 'react-router-dom'; import { DeleteTimelineModal } from './delete_timeline_modal'; import * as i18n from '../translations'; +import { TimelineType } from '../../../../../common/types/timeline'; + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { + ...actual, + useParams: jest.fn(), + }; +}); describe('DeleteTimelineModal', () => { + beforeAll(() => { + (useParams as jest.Mock).mockReturnValue({ tabName: TimelineType.default }); + }); + test('it renders the expected title when a timeline is selected', () => { const wrapper = mountWithIntl( { /> ); - expect(wrapper.find('[data-test-subj="warning"]').first().text()).toEqual(i18n.DELETE_WARNING); + expect(wrapper.find('[data-test-subj="warning"]').first().text()).toEqual( + i18n.DELETE_TIMELINE_WARNING + ); }); test('it invokes closeModal when the Cancel button is clicked', () => { @@ -115,3 +131,23 @@ describe('DeleteTimelineModal', () => { expect(onDelete).toBeCalled(); }); }); + +describe('DeleteTimelineTemplateModal', () => { + beforeAll(() => { + (useParams as jest.Mock).mockReturnValue({ tabName: TimelineType.template }); + }); + + test('it renders a deletion warning', () => { + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find('[data-test-subj="warning"]').first().text()).toEqual( + i18n.DELETE_TIMELINE_TEMPLATE_WARNING + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx index 5538610487899..f0efda6528507 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx @@ -10,7 +10,9 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { useCallback } from 'react'; import { isEmpty } from 'lodash/fp'; +import { useParams } from 'react-router-dom'; import * as i18n from '../translations'; +import { TimelineType } from '../../../../../common/types/timeline'; interface Props { title?: string | null; @@ -24,6 +26,12 @@ export const DELETE_TIMELINE_MODAL_WIDTH = 600; // px * Renders a modal that confirms deletion of a timeline */ export const DeleteTimelineModal = React.memo(({ title, closeModal, onDelete }) => { + const { tabName } = useParams<{ tabName: TimelineType }>(); + const warning = + tabName === TimelineType.template + ? i18n.DELETE_TIMELINE_TEMPLATE_WARNING + : i18n.DELETE_TIMELINE_WARNING; + const getTitle = useCallback(() => { const trimmedTitle = title != null ? title.trim() : ''; const titleResult = !isEmpty(trimmedTitle) ? trimmedTitle : i18n.UNTITLED_TIMELINE; @@ -48,7 +56,7 @@ export const DeleteTimelineModal = React.memo(({ title, closeModal, onDel onConfirm={onDelete} title={getTitle()} > -
{i18n.DELETE_WARNING}
+
{warning}
); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.test.tsx index 18a6ffc06941c..cfbc7d255062f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.test.tsx @@ -7,8 +7,18 @@ import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; +import { useParams } from 'react-router-dom'; import { DeleteTimelineModalOverlay } from '.'; +import { TimelineType } from '../../../../../common/types/timeline'; + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { + ...actual, + useParams: jest.fn(), + }; +}); describe('DeleteTimelineModal', () => { const savedObjectId = 'abcd'; @@ -20,6 +30,10 @@ describe('DeleteTimelineModal', () => { title: 'Privilege Escalation', }; + beforeAll(() => { + (useParams as jest.Mock).mockReturnValue({ tabName: TimelineType.default }); + }); + describe('showModalState', () => { test('it does NOT render the modal when isModalOpen is false', () => { const testProps = { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx index 8e754b3d04654..0c611ca5106e8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx @@ -5,12 +5,10 @@ * 2.0. */ -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { cloneDeep } from 'lodash/fp'; import moment from 'moment'; import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; -import { ThemeProvider } from 'styled-components'; import '../../../../common/mock/formatted_relative'; import { mockTimelineResults } from '../../../../common/mock/timeline_results'; @@ -18,7 +16,6 @@ import { OpenTimelineResult, TimelineResultNote } from '../types'; import { NotePreviews } from '.'; describe('NotePreviews', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); let mockResults: OpenTimelineResult[]; let note1updated: number; let note2updated: number; @@ -34,11 +31,7 @@ describe('NotePreviews', () => { test('it renders a note preview for each note when isModal is false', () => { const hasNotes: OpenTimelineResult[] = [{ ...mockResults[0] }]; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(); hasNotes[0].notes!.forEach(({ savedObjectId }) => { expect(wrapper.find(`[data-test-subj="note-preview-${savedObjectId}"]`).exists()).toBe(true); @@ -48,11 +41,7 @@ describe('NotePreviews', () => { test('it renders a note preview for each note when isModal is true', () => { const hasNotes: OpenTimelineResult[] = [{ ...mockResults[0] }]; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(); hasNotes[0].notes!.forEach(({ savedObjectId }) => { expect(wrapper.find(`[data-test-subj="note-preview-${savedObjectId}"]`).exists()).toBe(true); @@ -99,11 +88,7 @@ describe('NotePreviews', () => { }, ]; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(); expect(wrapper.find('.euiCommentEvent__headerUsername').at(1).text()).toEqual('bob'); }); @@ -130,11 +115,7 @@ describe('NotePreviews', () => { }, ]; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(); expect(wrapper.find(`.euiCommentEvent__headerUsername`).at(2).text()).toEqual('bob'); }); @@ -160,11 +141,7 @@ describe('NotePreviews', () => { }, ]; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(); expect(wrapper.find(`.euiCommentEvent__headerUsername`).at(2).text()).toEqual('bob'); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx index 1aac1f21f2d50..0cf7f2891dfbf 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { cloneDeep } from 'lodash/fp'; import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; @@ -32,8 +31,19 @@ jest.mock('react-router-dom', () => { }; }); +const mockTheme = { + eui: { + euiSizeL: '10px', + paddingSizes: { + s: '10px', + }, + euiBreakpoints: { + l: '1200px', + }, + }, +}; + describe('OpenTimeline', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); const title = 'All Timelines / Open Timelines'; let mockResults: OpenTimelineResult[]; @@ -73,7 +83,7 @@ describe('OpenTimeline', () => { test('it renders the search row', () => { const defaultProps = getDefaultTestProps(mockResults); const wrapper = mountWithIntl( - + ); @@ -84,7 +94,7 @@ describe('OpenTimeline', () => { test('it renders the timelines table', () => { const defaultProps = getDefaultTestProps(mockResults); const wrapper = mountWithIntl( - + ); @@ -95,7 +105,7 @@ describe('OpenTimeline', () => { test('it shows the delete action columns when onDeleteSelected and deleteTimelines are specified', () => { const defaultProps = getDefaultTestProps(mockResults); const wrapper = mountWithIntl( - + ); @@ -115,7 +125,7 @@ describe('OpenTimeline', () => { deleteTimelines: undefined, }; const wrapper = mountWithIntl( - + ); @@ -135,7 +145,7 @@ describe('OpenTimeline', () => { deleteTimelines: undefined, }; const wrapper = mountWithIntl( - + ); @@ -155,7 +165,7 @@ describe('OpenTimeline', () => { deleteTimelines: undefined, }; const wrapper = mountWithIntl( - + ); @@ -174,7 +184,7 @@ describe('OpenTimeline', () => { query: '', }; const wrapper = mountWithIntl( - + ); @@ -188,7 +198,7 @@ describe('OpenTimeline', () => { query: ' ', }; const wrapper = mountWithIntl( - + ); @@ -202,7 +212,7 @@ describe('OpenTimeline', () => { query: 'Would you like to go to Denver?', }; const wrapper = mountWithIntl( - + ); @@ -218,7 +228,7 @@ describe('OpenTimeline', () => { query: ' Is it starting to feel cramped in here? ', }; const wrapper = mountWithIntl( - + ); @@ -234,7 +244,7 @@ describe('OpenTimeline', () => { query: '', }; const wrapper = mountWithIntl( - + ); @@ -250,7 +260,7 @@ describe('OpenTimeline', () => { query: ' ', }; const wrapper = mountWithIntl( - + ); @@ -266,7 +276,7 @@ describe('OpenTimeline', () => { query: 'How was your day?', }; const wrapper = mountWithIntl( - + ); @@ -282,7 +292,7 @@ describe('OpenTimeline', () => { timelineStatus: TimelineStatus.active, }; const wrapper = mountWithIntl( - + ); @@ -297,7 +307,7 @@ describe('OpenTimeline', () => { selectedItems: [], }; const wrapper = mountWithIntl( - + ); @@ -317,7 +327,7 @@ describe('OpenTimeline', () => { selectedItems: [], }; const wrapper = mountWithIntl( - + ); @@ -337,7 +347,7 @@ describe('OpenTimeline', () => { selectedItems: [{}], }; const wrapper = mountWithIntl( - + ); @@ -357,7 +367,7 @@ describe('OpenTimeline', () => { selectedItems: [{}], }; const wrapper = mountWithIntl( - + ); @@ -376,7 +386,7 @@ describe('OpenTimeline', () => { timelineStatus: TimelineStatus.active, }; const wrapper = mountWithIntl( - + ); @@ -392,7 +402,7 @@ describe('OpenTimeline', () => { timelineStatus: TimelineStatus.active, }; const wrapper = mountWithIntl( - + ); @@ -406,7 +416,7 @@ describe('OpenTimeline', () => { timelineStatus: TimelineStatus.immutable, }; const wrapper = mountWithIntl( - + ); @@ -420,7 +430,7 @@ describe('OpenTimeline', () => { timelineStatus: TimelineStatus.immutable, }; const wrapper = mountWithIntl( - + ); @@ -436,7 +446,7 @@ describe('OpenTimeline', () => { timelineStatus: TimelineStatus.immutable, }; const wrapper = mountWithIntl( - + ); @@ -450,7 +460,7 @@ describe('OpenTimeline', () => { timelineStatus: null, }; const wrapper = mountWithIntl( - + ); @@ -464,7 +474,7 @@ describe('OpenTimeline', () => { timelineStatus: null, }; const wrapper = mountWithIntl( - + ); @@ -480,7 +490,7 @@ describe('OpenTimeline', () => { timelineStatus: null, }; const wrapper = mountWithIntl( - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx index 24b0702770d3c..5a3da748bea1d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx @@ -122,9 +122,9 @@ export const OpenTimeline = React.memo( const onRefreshBtnClick = useCallback(() => { if (refetch != null) { - refetch(searchResults, totalSearchResultsCount); + refetch(); } - }, [refetch, searchResults, totalSearchResultsCount]); + }, [refetch]); const handleCloseModal = useCallback(() => { if (setImportDataModalToggle != null) { @@ -137,9 +137,9 @@ export const OpenTimeline = React.memo( setImportDataModalToggle(false); } if (refetch != null) { - refetch(searchResults, totalSearchResultsCount); + refetch(); } - }, [setImportDataModalToggle, refetch, searchResults, totalSearchResultsCount]); + }, [setImportDataModalToggle, refetch]); const actionTimelineToShow = useMemo(() => { const timelineActions: ActionTimelineToShow[] = ['createFrom', 'duplicate']; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx index 5babecb3acb69..38186d35d2d2d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { cloneDeep } from 'lodash/fp'; import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; @@ -23,7 +22,14 @@ import { TimelineType, TimelineStatus } from '../../../../../common/types/timeli jest.mock('../../../../common/lib/kibana'); describe('OpenTimelineModal', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); + const mockTheme = { + eui: { + euiColorMediumShade: '#ece', + euiBreakpoints: { + s: '500px', + }, + }, + }; const title = 'All Timelines / Open Timelines'; let mockResults: OpenTimelineResult[]; @@ -62,7 +68,7 @@ describe('OpenTimelineModal', () => { test('it renders the title row', () => { const defaultProps = getDefaultTestProps(mockResults); const wrapper = mountWithIntl( - + ); @@ -73,7 +79,7 @@ describe('OpenTimelineModal', () => { test('it renders the search row', () => { const defaultProps = getDefaultTestProps(mockResults); const wrapper = mountWithIntl( - + ); @@ -84,7 +90,7 @@ describe('OpenTimelineModal', () => { test('it renders the timelines table', () => { const defaultProps = getDefaultTestProps(mockResults); const wrapper = mountWithIntl( - + ); @@ -99,7 +105,7 @@ describe('OpenTimelineModal', () => { deleteTimelines: jest.fn(), }; const wrapper = mountWithIntl( - + ); @@ -119,7 +125,7 @@ describe('OpenTimelineModal', () => { deleteTimelines: undefined, }; const wrapper = mountWithIntl( - + ); @@ -139,7 +145,7 @@ describe('OpenTimelineModal', () => { deleteTimelines: undefined, }; const wrapper = mountWithIntl( - + ); @@ -159,7 +165,7 @@ describe('OpenTimelineModal', () => { deleteTimelines: undefined, }; const wrapper = mountWithIntl( - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_button.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_button.test.tsx index 837dcbe1d6bfd..62cdda6070b32 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_button.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_button.test.tsx @@ -5,11 +5,9 @@ * 2.0. */ -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { mount } from 'enzyme'; import React from 'react'; import { MockedProvider } from 'react-apollo/test-utils'; -import { ThemeProvider } from 'styled-components'; import { waitFor } from '@testing-library/react'; import { TestProviders } from '../../../../common/mock/test_providers'; @@ -19,8 +17,6 @@ import * as i18n from '../translations'; import { OpenTimelineModalButton } from './open_timeline_modal_button'; describe('OpenTimelineModalButton', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); - test('it renders the expected button text', async () => { const wrapper = mount( @@ -43,13 +39,11 @@ describe('OpenTimelineModalButton', () => { test('it invokes onClick function provided as a prop when the button is clicked', async () => { const onClick = jest.fn(); const wrapper = mount( - - - - - - - + + + + + ); await waitFor(() => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/search_row/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/search_row/index.test.tsx index e0b252e112fc6..d75823b771681 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/search_row/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/search_row/index.test.tsx @@ -6,7 +6,6 @@ */ import { EuiFilterButtonProps } from '@elastic/eui'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; import { ThemeProvider } from 'styled-components'; @@ -17,12 +16,16 @@ import { SearchRow } from '.'; import * as i18n from '../translations'; -describe('SearchRow', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); +const mockTheme = { + eui: { + euiSizeL: '10px', + }, +}; +describe('SearchRow', () => { test('it renders a search input with the expected placeholder when the query is empty', () => { const wrapper = mountWithIntl( - + { describe('Only Favorites Button', () => { test('it renders the expected button text', () => { const wrapper = mountWithIntl( - + { const onToggleOnlyFavorites = jest.fn(); const wrapper = mountWithIntl( - + { test('it sets the button to the toggled state when onlyFavorites is true', () => { const wrapper = mountWithIntl( - + { test('it sets the button to the NON-toggled state when onlyFavorites is false', () => { const wrapper = mountWithIntl( - + { test('it invokes onQueryChange when the user enters a query', () => { const wrapper = mountWithIntl( - + { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); let mockResults: OpenTimelineResult[]; beforeEach(() => { @@ -37,7 +37,7 @@ describe('#getActionsColumns', () => { actionTimelineToShow: ['delete'], }; const wrapper = mountWithIntl( - + ); @@ -51,7 +51,7 @@ describe('#getActionsColumns', () => { actionTimelineToShow: [], }; const wrapper = mountWithIntl( - + ); @@ -65,7 +65,7 @@ describe('#getActionsColumns', () => { actionTimelineToShow: ['duplicate'], }; const wrapper = mountWithIntl( - + ); @@ -79,7 +79,7 @@ describe('#getActionsColumns', () => { actionTimelineToShow: ['duplicate'], }; const wrapper = mountWithIntl( - + ); @@ -93,7 +93,7 @@ describe('#getActionsColumns', () => { actionTimelineToShow: [], }; const wrapper = mountWithIntl( - + ); @@ -107,7 +107,7 @@ describe('#getActionsColumns', () => { actionTimelineToShow: ['delete'], }; const wrapper = mountWithIntl( - + ); @@ -124,7 +124,7 @@ describe('#getActionsColumns', () => { ...getMockTimelinesTableProps(missingSavedObjectId), }; const wrapper = mountWithIntl( - + ); @@ -141,7 +141,7 @@ describe('#getActionsColumns', () => { ...getMockTimelinesTableProps(mockResults), }; const wrapper = mountWithIntl( - + ); @@ -161,7 +161,7 @@ describe('#getActionsColumns', () => { onOpenTimeline, }; const wrapper = mountWithIntl( - + ); @@ -177,7 +177,7 @@ describe('#getActionsColumns', () => { actionTimelineToShow: ['export'], }; const wrapper = mountWithIntl( - + ); @@ -189,7 +189,7 @@ describe('#getActionsColumns', () => { ...getMockTimelinesTableProps(mockResults), }; const wrapper = mountWithIntl( - + ); @@ -206,7 +206,7 @@ describe('#getActionsColumns', () => { actionTimelineToShow: ['export'], }; const wrapper = mountWithIntl( - + ); @@ -226,7 +226,7 @@ describe('#getActionsColumns', () => { enableExportTimelineDownloader, }; const wrapper = mountWithIntl( - + ); @@ -242,7 +242,7 @@ describe('#getActionsColumns', () => { actionTimelineToShow: ['createFrom', 'duplicate'], }; const wrapper = mountWithIntl( - + ); @@ -256,7 +256,7 @@ describe('#getActionsColumns', () => { actionTimelineToShow: ['createFrom', 'duplicate'], }; const wrapper = mountWithIntl( - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.test.tsx index 3108dd09ea687..3c70cc70a66de 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.test.tsx @@ -6,7 +6,6 @@ */ import { EuiButtonIconProps } from '@elastic/eui'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { cloneDeep, omit } from 'lodash/fp'; import React from 'react'; import { ThemeProvider } from 'styled-components'; @@ -23,10 +22,11 @@ import { TimelinesTable, TimelinesTableProps } from '.'; import * as i18n from '../translations'; import { getMockTimelinesTableProps } from './mocks'; +const mockTheme = { eui: { euiColorMediumShade: '#ece' } }; + jest.mock('../../../../common/lib/kibana'); describe('#getCommonColumns', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); let mockResults: OpenTimelineResult[]; beforeEach(() => { @@ -40,11 +40,7 @@ describe('#getCommonColumns', () => { const testProps: TimelinesTableProps = { ...getMockTimelinesTableProps(hasNotes), }; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(true); }); @@ -55,11 +51,7 @@ describe('#getCommonColumns', () => { const testProps: TimelinesTableProps = { ...getMockTimelinesTableProps(missingNotes), }; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); }); @@ -70,11 +62,7 @@ describe('#getCommonColumns', () => { const testProps: TimelinesTableProps = { ...getMockTimelinesTableProps(nullNotes), }; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); }); @@ -85,11 +73,7 @@ describe('#getCommonColumns', () => { const testProps: TimelinesTableProps = { ...getMockTimelinesTableProps(emptylNotes), }; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); }); @@ -101,11 +85,7 @@ describe('#getCommonColumns', () => { const testProps: TimelinesTableProps = { ...getMockTimelinesTableProps(missingSavedObjectId), }; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); }); @@ -116,11 +96,7 @@ describe('#getCommonColumns', () => { const testProps: TimelinesTableProps = { ...getMockTimelinesTableProps(nullSavedObjectId), }; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); }); @@ -131,11 +107,7 @@ describe('#getCommonColumns', () => { const testProps: TimelinesTableProps = { ...getMockTimelinesTableProps(hasNotes), }; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(); const props = wrapper .find('[data-test-subj="expand-notes"]') @@ -156,11 +128,7 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(hasNotes), itemIdToExpandedNotesRowMap, }; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(); const props = wrapper .find('[data-test-subj="expand-notes"]') @@ -184,11 +152,7 @@ describe('#getCommonColumns', () => { itemIdToExpandedNotesRowMap, onToggleShowNotes, }; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(); wrapper.find('[data-test-subj="expand-notes"]').first().simulate('click'); @@ -214,7 +178,7 @@ describe('#getCommonColumns', () => { onToggleShowNotes, }; const wrapper = mountWithIntl( - + ); @@ -233,7 +197,7 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(mockResults), }; const wrapper = mountWithIntl( - + ); @@ -246,7 +210,7 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(mockResults), }; const wrapper = mountWithIntl( - + ); @@ -265,7 +229,7 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(missingSavedObjectId), }; const wrapper = mountWithIntl( - + ); @@ -285,7 +249,7 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(missingTitle), }; const wrapper = mountWithIntl( - + ); @@ -304,7 +268,7 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(withMissingSavedObjectIdAndTitle), }; const wrapper = mountWithIntl( - + ); @@ -323,7 +287,7 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(withJustWhitespaceTitle), }; const wrapper = mountWithIntl( - + ); @@ -345,7 +309,7 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(withMissingSavedObjectId), }; const wrapper = mountWithIntl( - + ); @@ -360,7 +324,7 @@ describe('#getCommonColumns', () => { test('it renders a hyperlink when the timeline has a saved object id', () => { const wrapper = mountWithIntl( - + ); @@ -379,7 +343,7 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(missingSavedObjectId), }; const wrapper = mountWithIntl( - + ); @@ -397,7 +361,7 @@ describe('#getCommonColumns', () => { onOpenTimeline, }; const wrapper = mountWithIntl( - + ); @@ -417,7 +381,7 @@ describe('#getCommonColumns', () => { describe('Description column', () => { test('it renders the expected column name', () => { const wrapper = mountWithIntl( - + ); @@ -427,7 +391,7 @@ describe('#getCommonColumns', () => { test('it renders the description when the timeline has a description', () => { const wrapper = mountWithIntl( - + ); @@ -441,7 +405,7 @@ describe('#getCommonColumns', () => { const missingDescription: OpenTimelineResult[] = [omit('description', { ...mockResults[0] })]; const wrapper = mountWithIntl( - + ); @@ -459,7 +423,7 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(justWhitespaceDescription), }; const wrapper = mountWithIntl( - + ); @@ -472,7 +436,7 @@ describe('#getCommonColumns', () => { describe('Last Modified column', () => { test('it renders the expected column name', () => { const wrapper = mountWithIntl( - + ); @@ -482,7 +446,7 @@ describe('#getCommonColumns', () => { test('it renders the last modified (updated) date when the timeline has an updated property', () => { const wrapper = mountWithIntl( - + ); @@ -497,7 +461,7 @@ describe('#getCommonColumns', () => { const missingUpdated: OpenTimelineResult[] = [omit('updated', { ...mockResults[0] })]; const wrapper = mountWithIntl( - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/extended_columns.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/extended_columns.test.tsx index 66556296c42ac..83e21267bce28 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/extended_columns.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/extended_columns.test.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { cloneDeep, omit } from 'lodash/fp'; import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; @@ -21,10 +20,11 @@ import { TimelinesTable, TimelinesTableProps } from '.'; import * as i18n from '../translations'; import { getMockTimelinesTableProps } from './mocks'; +const mockTheme = { eui: { euiColorMediumShade: '#ece' } }; + jest.mock('../../../../common/lib/kibana'); describe('#getExtendedColumns', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); let mockResults: OpenTimelineResult[]; beforeEach(() => { @@ -37,7 +37,7 @@ describe('#getExtendedColumns', () => { ...getMockTimelinesTableProps(mockResults), }; const wrapper = mountWithIntl( - + ); @@ -50,7 +50,7 @@ describe('#getExtendedColumns', () => { ...getMockTimelinesTableProps(mockResults), }; const wrapper = mountWithIntl( - + ); @@ -66,7 +66,7 @@ describe('#getExtendedColumns', () => { ...getMockTimelinesTableProps(missingUpdatedBy), }; const wrapper = mountWithIntl( - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.test.tsx index c3681753c7732..a8ed5f02fa3ef 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.test.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { cloneDeep, omit } from 'lodash/fp'; import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; @@ -16,10 +15,12 @@ import { mockTimelineResults } from '../../../../common/mock/timeline_results'; import { TimelinesTable, TimelinesTableProps } from '.'; import { OpenTimelineResult } from '../types'; import { getMockTimelinesTableProps } from './mocks'; + +const mockTheme = { eui: { euiColorMediumShade: '#ece' } }; + jest.mock('../../../../common/lib/kibana'); describe('#getActionsColumns', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); let mockResults: OpenTimelineResult[]; beforeEach(() => { @@ -28,7 +29,7 @@ describe('#getActionsColumns', () => { test('it renders the pinned events header icon', () => { const wrapper = mountWithIntl( - + ); @@ -42,7 +43,7 @@ describe('#getActionsColumns', () => { ...getMockTimelinesTableProps(with6Events), }; const wrapper = mountWithIntl( - + ); @@ -52,7 +53,7 @@ describe('#getActionsColumns', () => { test('it renders the notes count header icon', () => { const wrapper = mountWithIntl( - + ); @@ -66,7 +67,7 @@ describe('#getActionsColumns', () => { ...getMockTimelinesTableProps(with4Notes), }; const wrapper = mountWithIntl( - + ); @@ -76,7 +77,7 @@ describe('#getActionsColumns', () => { test('it renders the favorites header icon', () => { const wrapper = mountWithIntl( - + ); @@ -90,7 +91,7 @@ describe('#getActionsColumns', () => { ...getMockTimelinesTableProps(undefinedFavorite), }; const wrapper = mountWithIntl( - + ); @@ -104,7 +105,7 @@ describe('#getActionsColumns', () => { ...getMockTimelinesTableProps(nullFavorite), }; const wrapper = mountWithIntl( - + ); @@ -118,7 +119,7 @@ describe('#getActionsColumns', () => { ...getMockTimelinesTableProps(emptyFavorite), }; const wrapper = mountWithIntl( - + ); @@ -143,7 +144,7 @@ describe('#getActionsColumns', () => { ...getMockTimelinesTableProps(favorite), }; const wrapper = mountWithIntl( - + ); @@ -172,7 +173,7 @@ describe('#getActionsColumns', () => { ...getMockTimelinesTableProps(favorite), }; const wrapper = mountWithIntl( - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.test.tsx index 2d5949ae41125..01a855524ac0d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.test.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { cloneDeep } from 'lodash/fp'; import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; @@ -19,10 +18,11 @@ import { getMockTimelinesTableProps } from './mocks'; import * as i18n from '../translations'; +const mockTheme = { eui: { euiColorMediumShade: '#ece' } }; + jest.mock('../../../../common/lib/kibana'); describe('TimelinesTable', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); let mockResults: OpenTimelineResult[]; beforeEach(() => { @@ -31,7 +31,7 @@ describe('TimelinesTable', () => { test('it renders the select all timelines header checkbox when actionTimelineToShow has the action selectable', () => { const wrapper = mountWithIntl( - + ); @@ -45,7 +45,7 @@ describe('TimelinesTable', () => { actionTimelineToShow: ['delete', 'duplicate'], }; const wrapper = mountWithIntl( - + ); @@ -59,7 +59,7 @@ describe('TimelinesTable', () => { showExtendedColumns: true, }; const wrapper = mountWithIntl( - + ); @@ -73,7 +73,7 @@ describe('TimelinesTable', () => { showExtendedColumns: false, }; const wrapper = mountWithIntl( - + ); @@ -90,7 +90,7 @@ describe('TimelinesTable', () => { test('it renders the delete timeline (trash icon) when actionTimelineToShow has the delete action', () => { const wrapper = mountWithIntl( - + ); @@ -104,7 +104,7 @@ describe('TimelinesTable', () => { actionTimelineToShow: ['duplicate', 'selectable'], }; const wrapper = mountWithIntl( - + ); @@ -114,7 +114,7 @@ describe('TimelinesTable', () => { test('it renders the rows per page selector when showExtendedColumns is true', () => { const wrapper = mountWithIntl( - + ); @@ -128,7 +128,7 @@ describe('TimelinesTable', () => { showExtendedColumns: false, }; const wrapper = mountWithIntl( - + ); @@ -144,7 +144,7 @@ describe('TimelinesTable', () => { pageSize: defaultPageSize, }; const wrapper = mountWithIntl( - + ); @@ -156,7 +156,7 @@ describe('TimelinesTable', () => { test('it sorts the Last Modified column in descending order when showExtendedColumns is true ', () => { const wrapper = mountWithIntl( - + ); @@ -170,7 +170,7 @@ describe('TimelinesTable', () => { showExtendedColumns: false, }; const wrapper = mountWithIntl( - + ); @@ -184,7 +184,7 @@ describe('TimelinesTable', () => { searchResults: [], }; const wrapper = mountWithIntl( - + ); @@ -199,7 +199,7 @@ describe('TimelinesTable', () => { onTableChange, }; const wrapper = mountWithIntl( - + ); @@ -221,7 +221,7 @@ describe('TimelinesTable', () => { onSelectionChange, }; const wrapper = mountWithIntl( - + ); @@ -242,7 +242,7 @@ describe('TimelinesTable', () => { loading: true, }; const wrapper = mountWithIntl( - + ); @@ -257,7 +257,7 @@ describe('TimelinesTable', () => { test('it disables the table loading animation when isLoading is false', () => { const wrapper = mountWithIntl( - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx index 8c553bb95e9bd..c1b30f3e68cf4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx @@ -25,7 +25,11 @@ import { getActionsColumns } from './actions_columns'; import { getCommonColumns } from './common_columns'; import { getExtendedColumns } from './extended_columns'; import { getIconHeaderColumns } from './icon_header_columns'; -import { TimelineTypeLiteralWithNull, TimelineStatus } from '../../../../../common/types/timeline'; +import { + TimelineTypeLiteralWithNull, + TimelineStatus, + TimelineType, +} from '../../../../../common/types/timeline'; // there are a number of type mismatches across this file const EuiBasicTable: any = _EuiBasicTable; // eslint-disable-line @typescript-eslint/no-explicit-any @@ -103,7 +107,7 @@ export interface TimelinesTableProps { onToggleShowNotes: OnToggleShowNotes; pageIndex: number; pageSize: number; - searchResults: OpenTimelineResult[]; + searchResults: OpenTimelineResult[] | null; showExtendedColumns: boolean; sortDirection: 'asc' | 'desc'; sortField: string; @@ -196,6 +200,13 @@ export const TimelinesTable = React.memo( ] ); + const noItemsMessage = + isLoading || searchResults == null + ? i18n.LOADING + : timelineType === TimelineType.template + ? i18n.ZERO_TIMELINE_TEMPLATES_MATCH + : i18n.ZERO_TIMELINES_MATCH; + return ( ( isSelectable={actionTimelineToShow.includes('selectable')} itemId="savedObjectId" itemIdToExpandedRowMap={itemIdToExpandedNotesRowMap} - items={searchResults} + items={searchResults ?? []} loading={isLoading} - noItemsMessage={i18n.ZERO_TIMELINES_MATCH} + noItemsMessage={noItemsMessage} onChange={onTableChange} pagination={pagination} selection={actionTimelineToShow.includes('selectable') ? selection : undefined} diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/title_row/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/title_row/index.test.tsx index 5621a2287f3a2..4661f72901eb6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/title_row/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/title_row/index.test.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { EuiButtonProps } from '@elastic/eui'; import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; @@ -13,13 +12,16 @@ import { ThemeProvider } from 'styled-components'; import { TitleRow } from '.'; +const mockTheme = { + eui: { euiSizeS: '10px', euiLineHeight: '20px', euiBreakpoints: { s: '10px' }, euiSize: '10px' }, +}; + describe('TitleRow', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); const title = 'All Timelines / Open Timelines'; test('it renders the title', () => { const wrapper = mountWithIntl( - + ); @@ -30,7 +32,7 @@ describe('TitleRow', () => { describe('Favorite Selected button', () => { test('it renders the Favorite Selected button when onAddTimelinesToFavorites is provided', () => { const wrapper = mountWithIntl( - + { test('it does NOT render the Favorite Selected button when onAddTimelinesToFavorites is NOT provided', () => { const wrapper = mountWithIntl( - + ); @@ -54,7 +56,7 @@ describe('TitleRow', () => { test('it disables the Favorite Selected button when the selectedTimelinesCount is 0', () => { const wrapper = mountWithIntl( - + { test('it enables the Favorite Selected button when the selectedTimelinesCount is greater than 0', () => { const wrapper = mountWithIntl( - + { const onAddTimelinesToFavorites = jest.fn(); const wrapper = mountWithIntl( - + export const IMPORT_FAILED = i18n.translate( 'xpack.securitySolution.timelines.components.importTimelineModal.importFailedTitle', { - defaultMessage: 'Failed to import timelines', + defaultMessage: 'Failed to import', } ); export const IMPORT_TIMELINE = i18n.translate( 'xpack.securitySolution.timelines.components.importTimelineModal.importTitle', { - defaultMessage: 'Import timeline…', + defaultMessage: 'Import…', } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts index ad62bda4c9783..47e1da2d240ea 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts @@ -167,9 +167,9 @@ export interface OpenTimelineProps { /** The currently applied search criteria */ query: string; /** Refetch table */ - refetch?: (existingTimeline?: OpenTimelineResult[], existingCount?: number) => void; - /** The results of executing a search */ - searchResults: OpenTimelineResult[]; + refetch?: () => void; + /** The results of executing a search, null is the status before data fatched */ + searchResults: OpenTimelineResult[] | null; /** the currently-selected timelines in the table */ selectedItems: OpenTimelineResult[]; /** Toggle export timelines modal*/ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/plain_row_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/plain_row_renderer.test.tsx index 88521b779925a..666fb254aaa2c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/plain_row_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/plain_row_renderer.test.tsx @@ -5,11 +5,9 @@ * 2.0. */ -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { mount, shallow } from 'enzyme'; import { cloneDeep } from 'lodash'; import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { mockBrowserFields } from '../../../../../common/containers/source/mock'; import { Ecs } from '../../../../../../common/ecs'; @@ -42,11 +40,7 @@ describe('plain_row_renderer', () => { data: mockDatum, timelineId: 'test', }); - const wrapper = mount( - ({ eui: euiDarkVars, darkMode: true })}> - {children} - - ); + const wrapper = mount({children}); expect(wrapper.text()).toEqual(''); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/unknown_column_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/unknown_column_renderer.test.tsx index d98a724ebf9cb..6d7a9e5aecfd9 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/unknown_column_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/unknown_column_renderer.test.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { mount, shallow } from 'enzyme'; import { cloneDeep } from 'lodash'; import React from 'react'; @@ -17,8 +16,13 @@ import { getEmptyValue } from '../../../../../common/components/empty_value'; import { unknownColumnRenderer } from './unknown_column_renderer'; import { getValues } from './helpers'; +const mockTheme = { + eui: { + euiColorMediumShade: '#ece', + }, +}; + describe('unknown_column_renderer', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); let mockDatum: TimelineNonEcsData[]; const _id = mockTimelineData[0]._id; beforeEach(() => { @@ -50,7 +54,7 @@ describe('unknown_column_renderer', () => { timelineId: 'test', }); const wrapper = mount( - + {emptyColumn} ); @@ -66,7 +70,7 @@ describe('unknown_column_renderer', () => { timelineId: 'test', }); const wrapper = mount( - + {emptyColumn} ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx index d4de77e04e9f7..7ccce80bbe9a4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx @@ -222,7 +222,7 @@ const SelectableTimelineComponent: React.FC = ({ windowProps: { onScroll: ({ scrollOffset }) => handleOnScroll( - timelines.filter((t) => !hideUntitled || t.title !== '').length, + (timelines ?? []).filter((t) => !hideUntitled || t.title !== '').length, timelineCount, scrollOffset ), @@ -254,7 +254,7 @@ const SelectableTimelineComponent: React.FC = ({ = ({ searchProps={searchProps} singleSelection={true} options={getSelectableOptions({ - timelines, + timelines: timelines ?? [], onlyFavorites, searchTimelineValue, timelineType, diff --git a/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx index b14ccbd319399..82b41a95bd537 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx @@ -38,7 +38,7 @@ export interface AllTimelinesArgs { status, timelineType, }: AllTimelinesVariables) => void; - timelines: OpenTimelineResult[]; + timelines: OpenTimelineResult[] | null; loading: boolean; totalCount: number; customTemplateTimelineCount: number; @@ -105,7 +105,7 @@ export const useGetAllTimeline = (): AllTimelinesArgs => { const [allTimelines, setAllTimelines] = useState>({ loading: false, totalCount: 0, - timelines: [], + timelines: null, // use null as initial state to distinguish between empty result and haven't started loading. customTemplateTimelineCount: 0, defaultTimelineCount: 0, elasticTemplateTimelineCount: 0, @@ -128,7 +128,10 @@ export const useGetAllTimeline = (): AllTimelinesArgs => { const fetchData = async () => { try { if (apolloClient != null) { - setAllTimelines((prevState) => ({ ...prevState, loading: true })); + setAllTimelines((prevState) => ({ + ...prevState, + loading: true, + })); const variables: GetAllTimeline.Variables = { onlyUserFavorite, diff --git a/x-pack/plugins/security_solution/public/timelines/pages/index.tsx b/x-pack/plugins/security_solution/public/timelines/pages/index.tsx index 53ea28832f47f..806ac57df1f65 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/pages/index.tsx @@ -12,7 +12,6 @@ import { Switch, Route, useHistory } from 'react-router-dom'; import { ChromeBreadcrumb } from '../../../../../../src/core/public'; import { TimelineType } from '../../../common/types/timeline'; -import { TAB_TIMELINES, TAB_TEMPLATES } from '../components/open_timeline/translations'; import { TimelineRouteSpyState } from '../../common/utils/route/types'; import { TimelinesPage } from './timelines_page'; @@ -25,37 +24,18 @@ import { SecurityPageName } from '../../app/types'; const timelinesPagePath = `/:tabName(${TimelineType.default}|${TimelineType.template})`; const timelinesDefaultPath = `/${TimelineType.default}`; -const TabNameMappedToI18nKey: Record = { - [TimelineType.default]: TAB_TIMELINES, - [TimelineType.template]: TAB_TEMPLATES, -}; - export const getBreadcrumbs = ( params: TimelineRouteSpyState, search: string[], getUrlForApp: GetUrlForApp -): ChromeBreadcrumb[] => { - let breadcrumb = [ - { - text: PAGE_TITLE, - href: getUrlForApp(`${APP_ID}:${SecurityPageName.timelines}`, { - path: !isEmpty(search[0]) ? search[0] : '', - }), - }, - ]; - - const tabName = params?.tabName; - if (!tabName) return breadcrumb; - - breadcrumb = [ - ...breadcrumb, - { - text: TabNameMappedToI18nKey[tabName], - href: '', - }, - ]; - return breadcrumb; -}; +): ChromeBreadcrumb[] => [ + { + text: PAGE_TITLE, + href: getUrlForApp(`${APP_ID}:${SecurityPageName.timelines}`, { + path: !isEmpty(search[0]) ? search[0] : '', + }), + }, +]; export const Timelines = React.memo(() => { const history = useHistory(); diff --git a/x-pack/plugins/security_solution/public/timelines/pages/translations.ts b/x-pack/plugins/security_solution/public/timelines/pages/translations.ts index f3bff98785619..199fc27c2663a 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/pages/translations.ts @@ -21,7 +21,7 @@ export const ALL_TIMELINES_PANEL_TITLE = i18n.translate( export const ALL_TIMELINES_IMPORT_TIMELINE_TITLE = i18n.translate( 'xpack.securitySolution.timelines.allTimelines.importTimelineTitle', { - defaultMessage: 'Import Timeline', + defaultMessage: 'Import', } ); 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 55d128225c555..50823ebd85d05 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 @@ -5,7 +5,6 @@ * 2.0. */ -import moment from 'moment'; import { sampleRuleAlertParams, sampleEmptyDocSearchResults, @@ -23,9 +22,10 @@ import { alertsMock, AlertServicesMock } from '../../../../../alerts/server/mock import uuid from 'uuid'; import { listMock } from '../../../../../lists/server/mocks'; import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; -import { BulkResponse } from './types'; +import { BulkResponse, RuleRangeTuple } from './types'; import { SearchListItemArraySchema } from '../../../../../lists/common/schemas'; import { getSearchListItemResponseMock } from '../../../../../lists/common/schemas/response/search_list_item_schema.mock'; +import { getRuleRangeTuples } from './utils'; const buildRuleMessage = buildRuleMessageFactory({ id: 'fake id', @@ -39,16 +39,26 @@ describe('searchAfterAndBulkCreate', () => { let inputIndexPattern: string[] = []; let listClient = listMock.getListClient(); const someGuids = Array.from({ length: 13 }).map(() => uuid.v4()); + const sampleParams = sampleRuleAlertParams(30); + let tuples: RuleRangeTuple[]; beforeEach(() => { jest.clearAllMocks(); listClient = listMock.getListClient(); listClient.searchListItemByValues = jest.fn().mockResolvedValue([]); inputIndexPattern = ['auditbeat-*']; mockService = alertsMock.createAlertServices(); + ({ tuples } = getRuleRangeTuples({ + logger: mockLogger, + previousStartedAt: new Date(), + from: sampleParams.from, + to: sampleParams.to, + interval: '5m', + maxSignals: sampleParams.maxSignals, + buildRuleMessage, + })); }); test('should return success with number of searches less than max signals', async () => { - const sampleParams = sampleRuleAlertParams(30); mockService.callCluster .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3))) .mockResolvedValueOnce({ @@ -112,11 +122,9 @@ describe('searchAfterAndBulkCreate', () => { }, }, ]; - const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, - gap: null, - previousStartedAt: new Date(), + tuples, listClient, exceptionsList: [exceptionItem], services: mockService, @@ -147,7 +155,6 @@ describe('searchAfterAndBulkCreate', () => { }); test('should return success with number of searches less than max signals with gap', async () => { - const sampleParams = sampleRuleAlertParams(30); mockService.callCluster .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3))) .mockResolvedValueOnce({ @@ -201,8 +208,7 @@ describe('searchAfterAndBulkCreate', () => { ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, - gap: moment.duration(2, 'm'), - previousStartedAt: moment().subtract(10, 'm').toDate(), + tuples, listClient, exceptionsList: [exceptionItem], services: mockService, @@ -233,7 +239,6 @@ describe('searchAfterAndBulkCreate', () => { }); test('should return success when no search results are in the allowlist', async () => { - const sampleParams = sampleRuleAlertParams(30); mockService.callCluster .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3))) .mockResolvedValueOnce({ @@ -278,8 +283,7 @@ describe('searchAfterAndBulkCreate', () => { ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, - gap: null, - previousStartedAt: new Date(), + tuples, listClient, exceptionsList: [exceptionItem], services: mockService, @@ -305,7 +309,7 @@ describe('searchAfterAndBulkCreate', () => { }); expect(success).toEqual(true); expect(mockService.callCluster).toHaveBeenCalledTimes(3); - expect(createdSignalsCount).toEqual(4); // should not create any signals because all events were in the allowlist + expect(createdSignalsCount).toEqual(4); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); @@ -316,7 +320,6 @@ describe('searchAfterAndBulkCreate', () => { { ...getSearchListItemResponseMock(), value: ['3.3.3.3'] }, ]; listClient.searchListItemByValues = jest.fn().mockResolvedValue(searchListItems); - const sampleParams = sampleRuleAlertParams(30); mockService.callCluster .mockResolvedValueOnce( repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3), [ @@ -342,8 +345,7 @@ describe('searchAfterAndBulkCreate', () => { ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, - gap: null, - previousStartedAt: new Date(), + tuples, listClient, exceptionsList: [exceptionItem], services: mockService, @@ -382,7 +384,6 @@ describe('searchAfterAndBulkCreate', () => { ]; listClient.searchListItemByValues = jest.fn().mockResolvedValue(searchListItems); - const sampleParams = sampleRuleAlertParams(30); mockService.callCluster.mockResolvedValueOnce( repeatedSearchResultsWithNoSortId(4, 4, someGuids.slice(0, 3), [ '1.1.1.1', @@ -406,8 +407,7 @@ describe('searchAfterAndBulkCreate', () => { ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, - gap: null, - previousStartedAt: new Date(), + tuples, listClient, exceptionsList: [exceptionItem], services: mockService, @@ -437,13 +437,12 @@ describe('searchAfterAndBulkCreate', () => { expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); // I don't like testing log statements since logs change but this is the best // way I can think of to ensure this section is getting hit with this test case. - expect(((mockLogger.debug as unknown) as jest.Mock).mock.calls[8][0]).toContain( + expect(((mockLogger.debug as unknown) as jest.Mock).mock.calls[7][0]).toContain( 'ran out of sort ids to sort on name: "fake name" id: "fake id" rule id: "fake rule id" signals index: "fakeindex"' ); }); test('should return success when no sortId present but search results are in the allowlist', async () => { - const sampleParams = sampleRuleAlertParams(30); mockService.callCluster .mockResolvedValueOnce(repeatedSearchResultsWithNoSortId(4, 4, someGuids.slice(0, 3))) .mockResolvedValueOnce({ @@ -487,8 +486,7 @@ describe('searchAfterAndBulkCreate', () => { ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, - gap: null, - previousStartedAt: new Date(), + tuples, listClient, exceptionsList: [exceptionItem], services: mockService, @@ -514,17 +512,16 @@ describe('searchAfterAndBulkCreate', () => { }); expect(success).toEqual(true); expect(mockService.callCluster).toHaveBeenCalledTimes(2); - expect(createdSignalsCount).toEqual(4); // should not create any signals because all events were in the allowlist + expect(createdSignalsCount).toEqual(4); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); // I don't like testing log statements since logs change but this is the best // way I can think of to ensure this section is getting hit with this test case. - expect(((mockLogger.debug as unknown) as jest.Mock).mock.calls[15][0]).toContain( + expect(((mockLogger.debug as unknown) as jest.Mock).mock.calls[14][0]).toContain( 'ran out of sort ids to sort on name: "fake name" id: "fake id" rule id: "fake rule id" signals index: "fakeindex"' ); }); test('should return success when no exceptions list provided', async () => { - const sampleParams = sampleRuleAlertParams(30); mockService.callCluster .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3))) .mockResolvedValueOnce({ @@ -565,8 +562,7 @@ describe('searchAfterAndBulkCreate', () => { ); const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, - gap: null, - previousStartedAt: new Date(), + tuples, listClient, exceptionsList: [], services: mockService, @@ -592,7 +588,7 @@ describe('searchAfterAndBulkCreate', () => { }); expect(success).toEqual(true); expect(mockService.callCluster).toHaveBeenCalledTimes(3); - expect(createdSignalsCount).toEqual(4); // should not create any signals because all events were in the allowlist + expect(createdSignalsCount).toEqual(4); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); @@ -609,15 +605,13 @@ describe('searchAfterAndBulkCreate', () => { }, }, ]; - const sampleParams = sampleRuleAlertParams(10); mockService.callCluster .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3))) .mockRejectedValue(new Error('bulk failed')); // Added this recently const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ listClient, exceptionsList: [exceptionItem], - gap: null, - previousStartedAt: new Date(), + tuples, ruleParams: sampleParams, services: mockService, logger: mockLogger, @@ -659,7 +653,6 @@ describe('searchAfterAndBulkCreate', () => { }, }, ]; - const sampleParams = sampleRuleAlertParams(30); mockService.callCluster.mockResolvedValueOnce(sampleEmptyDocSearchResults()); listClient.searchListItemByValues = jest.fn(({ value }) => Promise.resolve( @@ -672,8 +665,7 @@ describe('searchAfterAndBulkCreate', () => { const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ listClient, exceptionsList: [exceptionItem], - gap: null, - previousStartedAt: new Date(), + tuples, ruleParams: sampleParams, services: mockService, logger: mockLogger, @@ -702,7 +694,6 @@ describe('searchAfterAndBulkCreate', () => { }); test('if returns false when singleSearchAfter throws an exception', async () => { - const sampleParams = sampleRuleAlertParams(10); mockService.callCluster .mockResolvedValueOnce({ took: 100, @@ -741,8 +732,7 @@ describe('searchAfterAndBulkCreate', () => { const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ listClient, exceptionsList: [exceptionItem], - gap: null, - previousStartedAt: new Date(), + tuples, ruleParams: sampleParams, services: mockService, logger: mockLogger, @@ -771,7 +761,6 @@ describe('searchAfterAndBulkCreate', () => { }); test('it returns error array when singleSearchAfter returns errors', async () => { - const sampleParams = sampleRuleAlertParams(30); const bulkItem: BulkResponse = { took: 100, errors: true, @@ -832,7 +821,6 @@ describe('searchAfterAndBulkCreate', () => { ], }) .mockResolvedValueOnce(sampleDocSearchResultsNoSortIdNoHits()); - const { success, createdSignalsCount, @@ -840,8 +828,7 @@ describe('searchAfterAndBulkCreate', () => { errors, } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, - gap: null, - previousStartedAt: new Date(), + tuples, listClient, exceptionsList: [], services: mockService, @@ -873,7 +860,6 @@ describe('searchAfterAndBulkCreate', () => { }); it('invokes the enrichment callback with signal search results', async () => { - const sampleParams = sampleRuleAlertParams(30); mockService.callCluster .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3))) .mockResolvedValueOnce({ @@ -917,8 +903,7 @@ describe('searchAfterAndBulkCreate', () => { const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ enrichment: mockEnrichment, ruleParams: sampleParams, - gap: moment.duration(2, 'm'), - previousStartedAt: moment().subtract(10, 'm').toDate(), + tuples, listClient, exceptionsList: [], services: mockService, 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 061aa4bba5a41..1dd3a2d2173a8 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 @@ -17,7 +17,6 @@ import { createSearchResultReturnType, createSearchAfterReturnTypeFromResponse, createTotalHitsFromSearchResult, - getSignalTimeTuples, mergeReturns, mergeSearchResults, } from './utils'; @@ -25,8 +24,7 @@ import { SearchAfterAndBulkCreateParams, SearchAfterAndBulkCreateReturnType } fr // search_after through documents and re-index using bulk endpoint. export const searchAfterAndBulkCreate = async ({ - gap, - previousStartedAt, + tuples: totalToFromTuples, ruleParams, exceptionsList, services, @@ -64,16 +62,6 @@ export const searchAfterAndBulkCreate = async ({ // to ensure we don't exceed maxSignals let signalsCreatedCount = 0; - const totalToFromTuples = getSignalTimeTuples({ - logger, - ruleParamsFrom: ruleParams.from, - ruleParamsTo: ruleParams.to, - ruleParamsMaxSignals: ruleParams.maxSignals, - gap, - previousStartedAt, - interval, - buildRuleMessage, - }); const tuplesToBeLogged = [...totalToFromTuples]; logger.debug(buildRuleMessage(`totalToFromTuples: ${totalToFromTuples.length}`)); 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 a79961eb716fd..cadc6d0c5b7c0 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 @@ -11,14 +11,7 @@ import { getResult, getMlResult } from '../routes/__mocks__/request_responses'; import { signalRulesAlertType } from './signal_rule_alert_type'; import { alertsMock, AlertServicesMock } from '../../../../../alerts/server/mocks'; import { ruleStatusServiceFactory } from './rule_status_service'; -import { - getGapBetweenRuns, - getGapMaxCatchupRatio, - getListsClient, - getExceptions, - sortExceptionItems, - checkPrivileges, -} from './utils'; +import { getListsClient, getExceptions, sortExceptionItems, checkPrivileges } from './utils'; import { parseScheduleDates } from '../../../../common/detection_engine/parse_schedule_dates'; import { RuleExecutorOptions, SearchAfterAndBulkCreateReturnType } from './types'; import { searchAfterAndBulkCreate } from './search_after_bulk_create'; @@ -40,8 +33,6 @@ jest.mock('./utils', () => { const original = jest.requireActual('./utils'); return { ...original, - getGapBetweenRuns: jest.fn(), - getGapMaxCatchupRatio: jest.fn(), getListsClient: jest.fn(), getExceptions: jest.fn(), sortExceptionItems: jest.fn(), @@ -113,7 +104,6 @@ describe('rules_notification_alert_type', () => { warning: jest.fn(), }; (ruleStatusServiceFactory as jest.Mock).mockReturnValue(ruleStatusService); - (getGapBetweenRuns as jest.Mock).mockReturnValue(moment.duration(0)); (getListsClient as jest.Mock).mockReturnValue({ listClient: getListClientMock(), exceptionsClient: getExceptionListClientMock(), @@ -124,7 +114,6 @@ describe('rules_notification_alert_type', () => { exceptionsWithValueLists: [], }); (searchAfterAndBulkCreate as jest.Mock).mockClear(); - (getGapMaxCatchupRatio as jest.Mock).mockClear(); (searchAfterAndBulkCreate as jest.Mock).mockResolvedValue({ success: true, searchAfterTimes: [], @@ -187,23 +176,12 @@ describe('rules_notification_alert_type', () => { describe('executor', () => { it('should warn about the gap between runs if gap is very large', async () => { - (getGapBetweenRuns as jest.Mock).mockReturnValue(moment.duration(100, 'm')); - (getGapMaxCatchupRatio as jest.Mock).mockReturnValue({ - maxCatchup: 4, - ratio: 20, - gapDiffInUnits: 95, - }); + payload.previousStartedAt = moment().subtract(100, 'm').toDate(); await alert.executor(payload); expect(logger.warn).toHaveBeenCalled(); - expect(logger.warn.mock.calls[0][0]).toContain( - '2 hours (6000000ms) has passed since last rule execution, and signals may have been missed.' - ); expect(ruleStatusService.error).toHaveBeenCalled(); - expect(ruleStatusService.error.mock.calls[0][0]).toContain( - '2 hours (6000000ms) has passed since last rule execution, and signals may have been missed.' - ); expect(ruleStatusService.error.mock.calls[0][1]).toEqual({ - gap: '2 hours', + gap: 'an hour', }); }); @@ -257,12 +235,7 @@ describe('rules_notification_alert_type', () => { }); it('should NOT warn about the gap between runs if gap small', async () => { - (getGapBetweenRuns as jest.Mock).mockReturnValue(moment.duration(1, 'm')); - (getGapMaxCatchupRatio as jest.Mock).mockReturnValue({ - maxCatchup: 1, - ratio: 1, - gapDiffInUnits: 1, - }); + payload.previousStartedAt = moment().subtract(10, 'm').toDate(); await alert.executor(payload); expect(logger.warn).toHaveBeenCalledTimes(0); expect(ruleStatusService.error).toHaveBeenCalledTimes(0); @@ -450,6 +423,7 @@ describe('rules_notification_alert_type', () => { const ruleAlert = getMlResult(); ruleAlert.params.anomalyThreshold = undefined; payload = getPayload(ruleAlert, alertServices) as jest.Mocked; + payload.previousStartedAt = null; await alert.executor(payload); expect(logger.error).toHaveBeenCalled(); expect(logger.error.mock.calls[0][0]).toContain( @@ -460,6 +434,7 @@ describe('rules_notification_alert_type', () => { it('should throw an error if Machine learning job summary was null', async () => { const ruleAlert = getMlResult(); payload = getPayload(ruleAlert, alertServices) as jest.Mocked; + payload.previousStartedAt = null; jobsSummaryMock.mockResolvedValue([]); await alert.executor(payload); expect(logger.warn).toHaveBeenCalled(); @@ -473,6 +448,7 @@ describe('rules_notification_alert_type', () => { it('should log an error if Machine learning job was not started', async () => { const ruleAlert = getMlResult(); payload = getPayload(ruleAlert, alertServices) as jest.Mocked; + payload.previousStartedAt = null; jobsSummaryMock.mockResolvedValue([ { id: 'some_job_id', @@ -518,6 +494,7 @@ describe('rules_notification_alert_type', () => { it('should call ruleStatusService.success if signals were created', async () => { const ruleAlert = getMlResult(); payload = getPayload(ruleAlert, alertServices) as jest.Mocked; + payload.previousStartedAt = null; jobsSummaryMock.mockResolvedValue([ { id: 'some_job_id', @@ -544,6 +521,7 @@ describe('rules_notification_alert_type', () => { it('should not call checkPrivileges if ML rule', async () => { const ruleAlert = getMlResult(); payload = getPayload(ruleAlert, alertServices) as jest.Mocked; + payload.previousStartedAt = null; jobsSummaryMock.mockResolvedValue([ { id: 'some_job_id', 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 98c9dd41d179c..2025ba512cb65 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 @@ -37,11 +37,8 @@ import { WrappedSignalHit, } from './types'; import { - getGapBetweenRuns, getListsClient, getExceptions, - getGapMaxCatchupRatio, - MAX_RULE_GAP_RATIO, wrapSignal, createErrorsFromShard, createSearchAfterReturnType, @@ -50,6 +47,7 @@ import { checkPrivileges, hasTimestampFields, hasReadIndexPrivileges, + getRuleRangeTuples, } from './utils'; import { signalParamsSchema } from './signal_params_schema'; import { siemRuleActionGroups } from './siem_rule_action_groups'; @@ -230,29 +228,24 @@ export const signalRulesAlertType = ({ } catch (exc) { logger.error(buildRuleMessage(`Check privileges failed to execute ${exc}`)); } - - const gap = getGapBetweenRuns({ previousStartedAt, interval, from, to }); - if (gap != null && gap.asMilliseconds() > 0) { - const fromUnit = from[from.length - 1]; - const { ratio } = getGapMaxCatchupRatio({ - logger, - buildRuleMessage, - previousStartedAt, - ruleParamsFrom: from, - interval, - unit: fromUnit, - }); - if (ratio && ratio >= MAX_RULE_GAP_RATIO) { - const gapString = gap.humanize(); - const gapMessage = buildRuleMessage( - `${gapString} (${gap.asMilliseconds()}ms) has passed since last rule execution, and signals may have been missed.`, - 'Consider increasing your look behind time or adding more Kibana instances.' - ); - logger.warn(gapMessage); - - hasError = true; - await ruleStatusService.error(gapMessage, { gap: gapString }); - } + const { tuples, remainingGap } = getRuleRangeTuples({ + logger, + previousStartedAt, + from, + to, + interval, + maxSignals, + buildRuleMessage, + }); + if (remainingGap.asMilliseconds() > 0) { + const gapString = remainingGap.humanize(); + const gapMessage = buildRuleMessage( + `${gapString} (${remainingGap.asMilliseconds()}ms) were not queried between this rule execution and the last execution, so signals may have been missed.`, + 'Consider increasing your look behind time or adding more Kibana instances.' + ); + logger.warn(gapMessage); + hasError = true; + await ruleStatusService.error(gapMessage, { gap: gapString }); } try { const { listClient, exceptionsClient } = getListsClient({ @@ -479,6 +472,7 @@ export const signalRulesAlertType = ({ } const inputIndex = await getInputIndex(services, version, index); result = await createThreatSignals({ + tuples, threatMapping, query, inputIndex, @@ -489,8 +483,6 @@ export const signalRulesAlertType = ({ savedId, services, exceptionItems: exceptionItems ?? [], - gap, - previousStartedAt, listClient, logger, eventsTelemetry, @@ -531,8 +523,7 @@ export const signalRulesAlertType = ({ }); result = await searchAfterAndBulkCreate({ - gap, - previousStartedAt, + tuples, listClient, exceptionsList: exceptionItems ?? [], ruleParams: params, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_enrichment.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_enrichment.ts index cdbafe692c630..7d6cd655e336d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_enrichment.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_enrichment.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { DEFAULT_INDICATOR_PATH } from '../../../../../common/constants'; +import { DEFAULT_INDICATOR_SOURCE_PATH } from '../../../../../common/constants'; import { SignalSearchResponse, SignalsEnrichment } from '../types'; import { enrichSignalThreatMatches } from './enrich_signal_threat_matches'; import { getThreatList } from './get_threat_list'; @@ -52,7 +52,9 @@ export const buildThreatEnrichment = ({ return threatResponse.hits.hits; }; - const defaultedIndicatorPath = threatIndicatorPath ? threatIndicatorPath : DEFAULT_INDICATOR_PATH; + const defaultedIndicatorPath = threatIndicatorPath + ? threatIndicatorPath + : DEFAULT_INDICATOR_SOURCE_PATH; return (signals: SignalSearchResponse): Promise => enrichSignalThreatMatches(signals, getMatchedThreats, defaultedIndicatorPath); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts index ba428bc077125..d9c72f7f95679 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts @@ -13,6 +13,7 @@ import { CreateThreatSignalOptions } from './types'; import { SearchAfterAndBulkCreateReturnType } from '../types'; export const createThreatSignal = async ({ + tuples, threatMapping, threatEnrichment, query, @@ -23,8 +24,6 @@ export const createThreatSignal = async ({ savedId, services, exceptionItems, - gap, - previousStartedAt, listClient, logger, eventsTelemetry, @@ -80,8 +79,7 @@ export const createThreatSignal = async ({ ); const result = await searchAfterAndBulkCreate({ - gap, - previousStartedAt, + tuples, listClient, exceptionsList: exceptionItems, ruleParams: params, 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 e45aea29c423f..854c2b8f3fdc1 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 @@ -15,6 +15,7 @@ import { combineConcurrentResults } from './utils'; import { buildThreatEnrichment } from './build_threat_enrichment'; export const createThreatSignals = async ({ + tuples, threatMapping, query, inputIndex, @@ -24,8 +25,6 @@ export const createThreatSignals = async ({ savedId, services, exceptionItems, - gap, - previousStartedAt, listClient, logger, eventsTelemetry, @@ -111,6 +110,7 @@ export const createThreatSignals = async ({ const concurrentSearchesPerformed = chunks.map>( (slicedChunk) => createThreatSignal({ + tuples, threatEnrichment, threatMapping, query, @@ -121,8 +121,6 @@ export const createThreatSignals = async ({ savedId, services, exceptionItems, - gap, - previousStartedAt, listClient, logger, eventsTelemetry, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts index f98f0c88a2dfa..b77e8228e72d8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts @@ -6,7 +6,7 @@ */ import { get } from 'lodash'; -import { DEFAULT_INDICATOR_PATH } from '../../../../../common/constants'; +import { INDICATOR_DESTINATION_PATH } from '../../../../../common/constants'; import { getThreatListItemMock } from './build_threat_mapping_filter.mock'; import { @@ -75,8 +75,10 @@ describe('groupAndMergeSignalMatches', () => { describe('buildMatchedIndicator', () => { let threats: ThreatListItem[]; let queries: ThreatMatchNamedQuery[]; + let indicatorPath: string; beforeEach(() => { + indicatorPath = 'threat.indicator'; threats = [ getThreatListItemMock({ _id: '123', @@ -94,7 +96,7 @@ describe('buildMatchedIndicator', () => { const indicators = buildMatchedIndicator({ queries: [], threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }); expect(indicators).toEqual([]); @@ -104,7 +106,7 @@ describe('buildMatchedIndicator', () => { const [indicator] = buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }); expect(get(indicator, 'matched.atomic')).toEqual('domain_1'); @@ -114,7 +116,7 @@ describe('buildMatchedIndicator', () => { const [indicator] = buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }); expect(get(indicator, 'matched.field')).toEqual('event.field'); @@ -124,7 +126,7 @@ describe('buildMatchedIndicator', () => { const [indicator] = buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }); expect(get(indicator, 'matched.type')).toEqual('type_1'); @@ -153,7 +155,7 @@ describe('buildMatchedIndicator', () => { const indicators = buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }); expect(indicators).toHaveLength(queries.length); @@ -163,7 +165,7 @@ describe('buildMatchedIndicator', () => { const indicators = buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }); expect(indicators).toEqual([ @@ -228,7 +230,7 @@ describe('buildMatchedIndicator', () => { const indicators = buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }); expect(indicators).toEqual([ @@ -253,7 +255,7 @@ describe('buildMatchedIndicator', () => { const indicators = buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }); expect(indicators).toEqual([ @@ -285,7 +287,7 @@ describe('buildMatchedIndicator', () => { const indicators = buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }); expect(indicators).toEqual([ @@ -317,7 +319,7 @@ describe('buildMatchedIndicator', () => { buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }) ).toThrowError('Expected indicator field to be an object, but found: not an object'); }); @@ -338,7 +340,7 @@ describe('buildMatchedIndicator', () => { buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }) ).toThrowError('Expected indicator field to be an object, but found: not an object'); }); @@ -347,8 +349,10 @@ describe('buildMatchedIndicator', () => { describe('enrichSignalThreatMatches', () => { let getMatchedThreats: GetMatchedThreats; let matchedQuery: string; + let indicatorPath: string; beforeEach(() => { + indicatorPath = 'threat.indicator'; getMatchedThreats = async () => [ getThreatListItemMock({ _id: '123', @@ -367,7 +371,7 @@ describe('enrichSignalThreatMatches', () => { const enrichedSignals = await enrichSignalThreatMatches( signals, getMatchedThreats, - DEFAULT_INDICATOR_PATH + indicatorPath ); expect(enrichedSignals.hits.hits).toEqual([]); @@ -382,10 +386,10 @@ describe('enrichSignalThreatMatches', () => { const enrichedSignals = await enrichSignalThreatMatches( signals, getMatchedThreats, - DEFAULT_INDICATOR_PATH + indicatorPath ); const [enrichedHit] = enrichedSignals.hits.hits; - const indicators = get(enrichedHit._source, DEFAULT_INDICATOR_PATH); + const indicators = get(enrichedHit._source, INDICATOR_DESTINATION_PATH); expect(indicators).toEqual([ { existing: 'indicator' }, @@ -407,10 +411,10 @@ describe('enrichSignalThreatMatches', () => { const enrichedSignals = await enrichSignalThreatMatches( signals, getMatchedThreats, - DEFAULT_INDICATOR_PATH + indicatorPath ); const [enrichedHit] = enrichedSignals.hits.hits; - const indicators = get(enrichedHit._source, DEFAULT_INDICATOR_PATH); + const indicators = get(enrichedHit._source, INDICATOR_DESTINATION_PATH); expect(indicators).toEqual([ { @@ -428,10 +432,10 @@ describe('enrichSignalThreatMatches', () => { const enrichedSignals = await enrichSignalThreatMatches( signals, getMatchedThreats, - DEFAULT_INDICATOR_PATH + indicatorPath ); const [enrichedHit] = enrichedSignals.hits.hits; - const indicators = get(enrichedHit._source, DEFAULT_INDICATOR_PATH); + const indicators = get(enrichedHit._source, INDICATOR_DESTINATION_PATH); expect(indicators).toEqual([ { existing: 'indicator' }, @@ -451,7 +455,7 @@ describe('enrichSignalThreatMatches', () => { }); const signals = getSignalsResponseMock([signalHit]); await expect(() => - enrichSignalThreatMatches(signals, getMatchedThreats, DEFAULT_INDICATOR_PATH) + enrichSignalThreatMatches(signals, getMatchedThreats, indicatorPath) ).rejects.toThrowError('Expected threat field to be an object, but found: whoops'); }); @@ -487,7 +491,7 @@ describe('enrichSignalThreatMatches', () => { 'custom_threat.custom_indicator' ); const [enrichedHit] = enrichedSignals.hits.hits; - const indicators = get(enrichedHit._source, DEFAULT_INDICATOR_PATH); + const indicators = get(enrichedHit._source, INDICATOR_DESTINATION_PATH); expect(indicators).toEqual([ { @@ -530,13 +534,13 @@ describe('enrichSignalThreatMatches', () => { const enrichedSignals = await enrichSignalThreatMatches( signals, getMatchedThreats, - DEFAULT_INDICATOR_PATH + indicatorPath ); expect(enrichedSignals.hits.total).toEqual(expect.objectContaining({ value: 1 })); expect(enrichedSignals.hits.hits).toHaveLength(1); const [enrichedHit] = enrichedSignals.hits.hits; - const indicators = get(enrichedHit._source, DEFAULT_INDICATOR_PATH); + const indicators = get(enrichedHit._source, INDICATOR_DESTINATION_PATH); expect(indicators).toEqual([ { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts index 761a58224fac5..3c8b80886cabe 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts @@ -6,7 +6,6 @@ */ import { get, isObject } from 'lodash'; -import { DEFAULT_INDICATOR_PATH } from '../../../../../common/constants'; import type { SignalSearchResponse, SignalSourceHit } from '../types'; import type { @@ -92,7 +91,11 @@ export const enrichSignalThreatMatches = async ( if (!isObject(threat)) { throw new Error(`Expected threat field to be an object, but found: ${threat}`); } - const existingIndicatorValue = get(signalHit._source, DEFAULT_INDICATOR_PATH) ?? []; + // We are not using INDICATOR_DESTINATION_PATH here because the code above + // and below make assumptions about its current value, 'threat.indicator', + // and making this code dynamic on an arbitrary path would introduce several + // new issues. + const existingIndicatorValue = get(signalHit._source, 'threat.indicator') ?? []; const existingIndicators = [existingIndicatorValue].flat(); // ensure indicators is an array return { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts index a022cbbdd4042..1c35a5af09b38 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts @@ -6,7 +6,6 @@ */ import { SearchResponse } from 'elasticsearch'; -import { Duration } from 'moment'; import { ListClient } from '../../../../../../lists/server'; import { @@ -34,11 +33,12 @@ import { ILegacyScopedClusterClient, Logger } from '../../../../../../../../src/ import { RuleAlertAction } from '../../../../../common/detection_engine/types'; import { TelemetryEventsSender } from '../../../telemetry/sender'; import { BuildRuleMessage } from '../rule_messages'; -import { SearchAfterAndBulkCreateReturnType, SignalsEnrichment } from '../types'; +import { RuleRangeTuple, SearchAfterAndBulkCreateReturnType, SignalsEnrichment } from '../types'; export type SortOrderOrUndefined = 'asc' | 'desc' | undefined; export interface CreateThreatSignalsOptions { + tuples: RuleRangeTuple[]; threatMapping: ThreatMapping; query: string; inputIndex: string[]; @@ -48,8 +48,6 @@ export interface CreateThreatSignalsOptions { savedId: string | undefined; services: AlertServices; exceptionItems: ExceptionListItemSchema[]; - gap: Duration | null; - previousStartedAt: Date | null; listClient: ListClient; logger: Logger; eventsTelemetry: TelemetryEventsSender | undefined; @@ -79,6 +77,7 @@ export interface CreateThreatSignalsOptions { } export interface CreateThreatSignalOptions { + tuples: RuleRangeTuple[]; threatMapping: ThreatMapping; threatEnrichment: SignalsEnrichment; query: string; @@ -89,8 +88,6 @@ export interface CreateThreatSignalOptions { savedId: string | undefined; services: AlertServices; exceptionItems: ExceptionListItemSchema[]; - gap: Duration | null; - previousStartedAt: Date | null; listClient: ListClient; logger: Logger; eventsTelemetry: TelemetryEventsSender | undefined; 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 e5ca1f6a60456..f759da31566e2 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 @@ -73,6 +73,12 @@ export interface ThresholdSignalHistory { [hash: string]: ThresholdSignalHistoryRecord; } +export interface RuleRangeTuple { + to: moment.Moment; + from: moment.Moment; + maxSignals: number; +} + export interface SignalSource { [key: string]: SearchTypes; // TODO: SignalSource is being used as the type for documents matching detection engine queries, but they may not @@ -251,8 +257,11 @@ export interface QueryFilter { export type SignalsEnrichment = (signals: SignalSearchResponse) => Promise; export interface SearchAfterAndBulkCreateParams { - gap: moment.Duration | null; - previousStartedAt: Date | null | undefined; + tuples: Array<{ + to: moment.Moment; + from: moment.Moment; + maxSignals: number; + }>; ruleParams: RuleTypeParams; services: AlertServices; listClient: ListClient; 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 7888bb6deaab7..0a581816ee82f 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 @@ -25,10 +25,10 @@ import { parseInterval, getDriftTolerance, getGapBetweenRuns, - getGapMaxCatchupRatio, + getNumCatchupIntervals, errorAggregator, getListsClient, - getSignalTimeTuples, + getRuleRangeTuples, getExceptions, hasTimestampFields, wrapBuildingBlocks, @@ -109,7 +109,7 @@ describe('utils', () => { expect(duration?.asMilliseconds()).toEqual(moment.duration(5, 'minutes').asMilliseconds()); }); - test('it returns null given an invalid duration', () => { + test('it throws given an invalid duration', () => { const duration = parseInterval('junk'); expect(duration).toBeNull(); }); @@ -148,7 +148,7 @@ describe('utils', () => { const drift = getDriftTolerance({ from: 'now-6m', to: 'now', - interval: moment.duration(5, 'minutes'), + intervalDuration: moment.duration(5, 'minutes'), }); expect(drift).not.toBeNull(); expect(drift?.asMilliseconds()).toEqual(moment.duration(1, 'minute').asMilliseconds()); @@ -158,7 +158,7 @@ describe('utils', () => { const drift = getDriftTolerance({ from: 'now-5m', to: 'now', - interval: moment.duration(5, 'minutes'), + intervalDuration: moment.duration(5, 'minutes'), }); expect(drift?.asMilliseconds()).toEqual(0); }); @@ -167,7 +167,7 @@ describe('utils', () => { const drift = getDriftTolerance({ from: 'now-10m', to: 'now', - interval: moment.duration(5, 'minutes'), + intervalDuration: moment.duration(5, 'minutes'), }); expect(drift).not.toBeNull(); expect(drift?.asMilliseconds()).toEqual(moment.duration(5, 'minutes').asMilliseconds()); @@ -177,7 +177,7 @@ describe('utils', () => { const drift = getDriftTolerance({ from: 'now-10m', to: 'now', - interval: moment.duration(0, 'milliseconds'), + intervalDuration: moment.duration(0, 'milliseconds'), }); expect(drift).not.toBeNull(); expect(drift?.asMilliseconds()).toEqual(moment.duration(10, 'minutes').asMilliseconds()); @@ -187,7 +187,7 @@ describe('utils', () => { const drift = getDriftTolerance({ from: 'invalid', to: 'now', - interval: moment.duration(5, 'minutes'), + intervalDuration: moment.duration(5, 'minutes'), }); expect(drift).not.toBeNull(); expect(drift?.asMilliseconds()).toEqual(moment.duration(1, 'minute').asMilliseconds()); @@ -197,7 +197,7 @@ describe('utils', () => { const drift = getDriftTolerance({ from: '10m', to: 'now', - interval: moment.duration(5, 'minutes'), + intervalDuration: moment.duration(5, 'minutes'), }); expect(drift).not.toBeNull(); expect(drift?.asMilliseconds()).toEqual(moment.duration(1, 'minute').asMilliseconds()); @@ -207,7 +207,7 @@ describe('utils', () => { const drift = getDriftTolerance({ from: 'now-10m', to: 'now-1m', - interval: moment.duration(5, 'minutes'), + intervalDuration: moment.duration(5, 'minutes'), }); expect(drift).not.toBeNull(); expect(drift?.asMilliseconds()).toEqual(moment.duration(4, 'minutes').asMilliseconds()); @@ -217,7 +217,7 @@ describe('utils', () => { const drift = getDriftTolerance({ from: moment().subtract(10, 'minutes').toISOString(), to: 'now', - interval: moment.duration(5, 'minutes'), + intervalDuration: moment.duration(5, 'minutes'), }); expect(drift).not.toBeNull(); expect(drift?.asMilliseconds()).toEqual(moment.duration(5, 'minutes').asMilliseconds()); @@ -227,7 +227,7 @@ describe('utils', () => { const drift = getDriftTolerance({ from: 'now-6m', to: moment().toISOString(), - interval: moment.duration(5, 'minutes'), + intervalDuration: moment.duration(5, 'minutes'), }); expect(drift).not.toBeNull(); expect(drift?.asMilliseconds()).toEqual(moment.duration(1, 'minute').asMilliseconds()); @@ -238,7 +238,7 @@ describe('utils', () => { test('it returns a gap of 0 when "from" and interval match each other and the previous started was from the previous interval time', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(5, 'minutes').toDate(), - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'now-5m', to: 'now', now: nowDate.clone(), @@ -250,7 +250,7 @@ describe('utils', () => { test('it returns a negative gap of 1 minute when "from" overlaps to by 1 minute and the previousStartedAt was 5 minutes ago', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(5, 'minutes').toDate(), - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'now-6m', to: 'now', now: nowDate.clone(), @@ -262,7 +262,7 @@ describe('utils', () => { test('it returns a negative gap of 5 minutes when "from" overlaps to by 1 minute and the previousStartedAt was 5 minutes ago', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(5, 'minutes').toDate(), - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'now-10m', to: 'now', now: nowDate.clone(), @@ -274,7 +274,7 @@ describe('utils', () => { test('it returns a negative gap of 1 minute when "from" overlaps to by 1 minute and the previousStartedAt was 10 minutes ago and so was the interval', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(10, 'minutes').toDate(), - interval: '10m', + intervalDuration: moment.duration(10, 'minutes'), from: 'now-11m', to: 'now', now: nowDate.clone(), @@ -286,7 +286,7 @@ describe('utils', () => { test('it returns a gap of only -30 seconds when the from overlaps with now by 1 minute, the interval is 5 minutes but the previous started is 30 seconds more', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(5, 'minutes').subtract(30, 'seconds').toDate(), - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'now-6m', to: 'now', now: nowDate.clone(), @@ -298,7 +298,7 @@ describe('utils', () => { test('it returns an exact 0 gap when the from overlaps with now by 1 minute, the interval is 5 minutes but the previous started is one minute late', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(6, 'minutes').toDate(), - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'now-6m', to: 'now', now: nowDate.clone(), @@ -310,7 +310,7 @@ describe('utils', () => { test('it returns a gap of 30 seconds when the from overlaps with now by 1 minute, the interval is 5 minutes but the previous started is one minute and 30 seconds late', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(6, 'minutes').subtract(30, 'seconds').toDate(), - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'now-6m', to: 'now', now: nowDate.clone(), @@ -322,7 +322,7 @@ describe('utils', () => { test('it returns a gap of 1 minute when the from overlaps with now by 1 minute, the interval is 5 minutes but the previous started is two minutes late', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(7, 'minutes').toDate(), - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'now-6m', to: 'now', now: nowDate.clone(), @@ -331,32 +331,21 @@ describe('utils', () => { expect(gap?.asMilliseconds()).toEqual(moment.duration(1, 'minute').asMilliseconds()); }); - test('it returns null if given a previousStartedAt of null', () => { + test('it returns 0 if given a previousStartedAt of null', () => { const gap = getGapBetweenRuns({ previousStartedAt: null, - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'now-5m', to: 'now', now: nowDate.clone(), }); - expect(gap).toBeNull(); - }); - - test('it returns null if the interval is an invalid string such as "invalid"', () => { - const gap = getGapBetweenRuns({ - previousStartedAt: nowDate.clone().toDate(), - interval: 'invalid', // if not set to "x" where x is an interval such as 6m - from: 'now-5m', - to: 'now', - now: nowDate.clone(), - }); - expect(gap).toBeNull(); + expect(gap.asMilliseconds()).toEqual(0); }); test('it returns the expected result when "from" is an invalid string such as "invalid"', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(7, 'minutes').toDate(), - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'invalid', to: 'now', now: nowDate.clone(), @@ -368,7 +357,7 @@ describe('utils', () => { test('it returns the expected result when "to" is an invalid string such as "invalid"', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(7, 'minutes').toDate(), - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'now-6m', to: 'invalid', now: nowDate.clone(), @@ -609,134 +598,116 @@ describe('utils', () => { }); }); - describe('getSignalTimeTuples', () => { + describe('getRuleRangeTuples', () => { test('should return a single tuple if no gap', () => { - const someTuples = getSignalTimeTuples({ + const { tuples, remainingGap } = getRuleRangeTuples({ logger: mockLogger, - gap: null, previousStartedAt: moment().subtract(30, 's').toDate(), interval: '30s', - ruleParamsFrom: 'now-30s', - ruleParamsTo: 'now', - ruleParamsMaxSignals: 20, + from: 'now-30s', + to: 'now', + maxSignals: 20, buildRuleMessage, }); - const someTuple = someTuples[0]; + const someTuple = tuples[0]; expect(moment(someTuple.to).diff(moment(someTuple.from), 's')).toEqual(30); + expect(tuples.length).toEqual(1); + expect(remainingGap.asMilliseconds()).toEqual(0); + }); + + test('should return a single tuple if malformed interval prevents gap calculation', () => { + const { tuples, remainingGap } = getRuleRangeTuples({ + logger: mockLogger, + previousStartedAt: moment().subtract(30, 's').toDate(), + interval: 'invalid', + from: 'now-30s', + to: 'now', + maxSignals: 20, + buildRuleMessage, + }); + const someTuple = tuples[0]; + expect(moment(someTuple.to).diff(moment(someTuple.from), 's')).toEqual(30); + expect(tuples.length).toEqual(1); + expect(remainingGap.asMilliseconds()).toEqual(0); }); test('should return two tuples if gap and previouslyStartedAt', () => { - const someTuples = getSignalTimeTuples({ + const { tuples, remainingGap } = getRuleRangeTuples({ logger: mockLogger, - gap: moment.duration(10, 's'), previousStartedAt: moment().subtract(65, 's').toDate(), interval: '50s', - ruleParamsFrom: 'now-55s', - ruleParamsTo: 'now', - ruleParamsMaxSignals: 20, + from: 'now-55s', + to: 'now', + maxSignals: 20, buildRuleMessage, }); - const someTuple = someTuples[1]; - expect(moment(someTuple.to).diff(moment(someTuple.from), 's')).toEqual(10); + const someTuple = tuples[1]; + expect(moment(someTuple.to).diff(moment(someTuple.from), 's')).toEqual(55); + expect(remainingGap.asMilliseconds()).toEqual(0); }); test('should return five tuples when give long gap', () => { - const someTuples = getSignalTimeTuples({ + const { tuples, remainingGap } = getRuleRangeTuples({ logger: mockLogger, - gap: moment.duration(65, 's'), // 64 is 5 times the interval + lookback, which will trigger max lookback - previousStartedAt: moment().subtract(65, 's').toDate(), + previousStartedAt: moment().subtract(65, 's').toDate(), // 64 is 5 times the interval + lookback, which will trigger max lookback interval: '10s', - ruleParamsFrom: 'now-13s', - ruleParamsTo: 'now', - ruleParamsMaxSignals: 20, + from: 'now-13s', + to: 'now', + maxSignals: 20, buildRuleMessage, }); - expect(someTuples.length).toEqual(5); - someTuples.forEach((item, index) => { + expect(tuples.length).toEqual(5); + tuples.forEach((item, index) => { if (index === 0) { return; } - expect(moment(item.to).diff(moment(item.from), 's')).toEqual(10); + expect(moment(item.to).diff(moment(item.from), 's')).toEqual(13); + expect(item.to.diff(tuples[index - 1].to, 's')).toEqual(-10); + expect(item.from.diff(tuples[index - 1].from, 's')).toEqual(-10); }); + expect(remainingGap.asMilliseconds()).toEqual(12000); }); - // this tests if calculatedFrom in utils.ts:320 parses an int and not a float - // if we don't parse as an int, then dateMath.parse will fail - // as it doesn't support parsing `now-67.549`, it only supports ints like `now-67`. - test('should return five tuples when given a gap with a decimal to ensure no parsing errors', () => { - const someTuples = getSignalTimeTuples({ + test('should return a single tuple when give a negative gap (rule ran sooner than expected)', () => { + const { tuples, remainingGap } = getRuleRangeTuples({ logger: mockLogger, - gap: moment.duration(67549, 'ms'), // 64 is 5 times the interval + lookback, which will trigger max lookback - previousStartedAt: moment().subtract(67549, 'ms').toDate(), - interval: '10s', - ruleParamsFrom: 'now-13s', - ruleParamsTo: 'now', - ruleParamsMaxSignals: 20, - buildRuleMessage, - }); - expect(someTuples.length).toEqual(5); - }); - - test('should return single tuples when give a negative gap (rule ran sooner than expected)', () => { - const someTuples = getSignalTimeTuples({ - logger: mockLogger, - gap: moment.duration(-15, 's'), // 64 is 5 times the interval + lookback, which will trigger max lookback previousStartedAt: moment().subtract(-15, 's').toDate(), interval: '10s', - ruleParamsFrom: 'now-13s', - ruleParamsTo: 'now', - ruleParamsMaxSignals: 20, + from: 'now-13s', + to: 'now', + maxSignals: 20, buildRuleMessage, }); - expect(someTuples.length).toEqual(1); - const someTuple = someTuples[0]; + expect(tuples.length).toEqual(1); + const someTuple = tuples[0]; expect(moment(someTuple.to).diff(moment(someTuple.from), 's')).toEqual(13); + expect(remainingGap.asMilliseconds()).toEqual(0); }); }); describe('getMaxCatchupRatio', () => { - test('should return null if rule has never run before', () => { - const { maxCatchup, ratio, gapDiffInUnits } = getGapMaxCatchupRatio({ - logger: mockLogger, - previousStartedAt: null, - interval: '30s', - ruleParamsFrom: 'now-30s', - buildRuleMessage, - unit: 's', + test('should return 0 if gap is 0', () => { + const catchup = getNumCatchupIntervals({ + gap: moment.duration(0), + intervalDuration: moment.duration(11000), }); - expect(maxCatchup).toBeNull(); - expect(ratio).toBeNull(); - expect(gapDiffInUnits).toBeNull(); + expect(catchup).toEqual(0); }); - test('should should have non-null values when gap is present', () => { - const { maxCatchup, ratio, gapDiffInUnits } = getGapMaxCatchupRatio({ - logger: mockLogger, - previousStartedAt: moment().subtract(65, 's').toDate(), - interval: '50s', - ruleParamsFrom: 'now-55s', - buildRuleMessage, - unit: 's', + test('should return 1 if gap is in (0, intervalDuration]', () => { + const catchup = getNumCatchupIntervals({ + gap: moment.duration(10000), + intervalDuration: moment.duration(10000), }); - expect(maxCatchup).toEqual(0.2); - expect(ratio).toEqual(0.2); - expect(gapDiffInUnits).toEqual(10); + expect(catchup).toEqual(1); }); - // when a rule runs sooner than expected we don't - // consider that a gap as that is a very rare circumstance - test('should return null when given a negative gap (rule ran sooner than expected)', () => { - const { maxCatchup, ratio, gapDiffInUnits } = getGapMaxCatchupRatio({ - logger: mockLogger, - previousStartedAt: moment().subtract(-15, 's').toDate(), - interval: '10s', - ruleParamsFrom: 'now-13s', - buildRuleMessage, - unit: 's', + test('should round up return value', () => { + const catchup = getNumCatchupIntervals({ + gap: moment.duration(15000), + intervalDuration: moment.duration(11000), }); - expect(maxCatchup).toBeNull(); - expect(ratio).toBeNull(); - expect(gapDiffInUnits).toBeNull(); + expect(catchup).toEqual(2); }); }); 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 58bf22be97bf8..2b306cd2a8d9d 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 @@ -29,12 +29,12 @@ import { ListArray } from '../../../../common/detection_engine/schemas/types/lis import { BulkResponse, BulkResponseErrorAggregation, - isValidUnit, SignalHit, SearchAfterAndBulkCreateReturnType, SignalSearchResponse, Signal, WrappedSignalHit, + RuleRangeTuple, } from './types'; import { BuildRuleMessage } from './rule_messages'; import { parseScheduleDates } from '../../../../common/detection_engine/parse_schedule_dates'; @@ -163,82 +163,21 @@ export const checkPrivileges = async ( }, }); -export const getGapMaxCatchupRatio = ({ - logger, - previousStartedAt, - unit, - buildRuleMessage, - ruleParamsFrom, - interval, +export const getNumCatchupIntervals = ({ + gap, + intervalDuration, }: { - logger: Logger; - ruleParamsFrom: string; - previousStartedAt: Date | null | undefined; - interval: string; - buildRuleMessage: BuildRuleMessage; - unit: string; -}): { - maxCatchup: number | null; - ratio: number | null; - gapDiffInUnits: number | null; -} => { - if (previousStartedAt == null) { - return { - maxCatchup: null, - ratio: null, - gapDiffInUnits: null, - }; - } - if (!isValidUnit(unit)) { - logger.error(buildRuleMessage(`unit: ${unit} failed isValidUnit check`)); - return { - maxCatchup: null, - ratio: null, - gapDiffInUnits: null, - }; - } - /* - we need the total duration from now until the last time the rule ran. - the next few lines can be summed up as calculating - "how many second | minutes | hours have passed since the last time this ran?" - */ - const nowToGapDiff = moment.duration(moment().diff(previousStartedAt)); - // rule ran early, no gap - if (shorthandMap[unit].asFn(nowToGapDiff) < 0) { - // rule ran early, no gap - return { - maxCatchup: null, - ratio: null, - gapDiffInUnits: null, - }; - } - const calculatedFrom = `now-${ - parseInt(shorthandMap[unit].asFn(nowToGapDiff).toString(), 10) + unit - }`; - logger.debug(buildRuleMessage(`calculatedFrom: ${calculatedFrom}`)); - - const intervalMoment = moment.duration(parseInt(interval, 10), unit); - logger.debug(buildRuleMessage(`intervalMoment: ${shorthandMap[unit].asFn(intervalMoment)}`)); - const calculatedFromAsMoment = dateMath.parse(calculatedFrom); - const dateMathRuleParamsFrom = dateMath.parse(ruleParamsFrom); - if (dateMathRuleParamsFrom != null && intervalMoment != null) { - const momentUnit = shorthandMap[unit].momentString as moment.DurationInputArg2; - const gapDiffInUnits = dateMathRuleParamsFrom.diff(calculatedFromAsMoment, momentUnit); - - const ratio = gapDiffInUnits / shorthandMap[unit].asFn(intervalMoment); - - // maxCatchup is to ensure we are not trying to catch up too far back. - // This allows for a maximum of 4 consecutive rule execution misses - // to be included in the number of signals generated. - const maxCatchup = ratio < MAX_RULE_GAP_RATIO ? ratio : MAX_RULE_GAP_RATIO; - return { maxCatchup, ratio, gapDiffInUnits }; + gap: moment.Duration; + intervalDuration: moment.Duration; +}): number => { + if (gap.asMilliseconds() <= 0 || intervalDuration.asMilliseconds() <= 0) { + return 0; } - logger.error(buildRuleMessage('failed to parse calculatedFrom and intervalMoment')); - return { - maxCatchup: null, - ratio: null, - gapDiffInUnits: null, - }; + const ratio = Math.ceil(gap.asMilliseconds() / intervalDuration.asMilliseconds()); + // maxCatchup is to ensure we are not trying to catch up too far back. + // This allows for a maximum of 4 consecutive rule execution misses + // to be included in the number of signals generated. + return ratio < MAX_RULE_GAP_RATIO ? ratio : MAX_RULE_GAP_RATIO; }; export const getListsClient = ({ @@ -396,50 +335,40 @@ export const parseInterval = (intervalString: string): moment.Duration | null => export const getDriftTolerance = ({ from, to, - interval, + intervalDuration, now = moment(), }: { from: string; to: string; - interval: moment.Duration; + intervalDuration: moment.Duration; now?: moment.Moment; -}): moment.Duration | null => { +}): moment.Duration => { const toDate = parseScheduleDates(to) ?? now; const fromDate = parseScheduleDates(from) ?? dateMath.parse('now-6m'); const timeSegment = toDate.diff(fromDate); const duration = moment.duration(timeSegment); - if (duration !== null) { - return duration.subtract(interval); - } else { - return null; - } + return duration.subtract(intervalDuration); }; export const getGapBetweenRuns = ({ previousStartedAt, - interval, + intervalDuration, from, to, now = moment(), }: { previousStartedAt: Date | undefined | null; - interval: string; + intervalDuration: moment.Duration; from: string; to: string; now?: moment.Moment; -}): moment.Duration | null => { +}): moment.Duration => { if (previousStartedAt == null) { - return null; - } - const intervalDuration = parseInterval(interval); - if (intervalDuration == null) { - return null; - } - const driftTolerance = getDriftTolerance({ from, to, interval: intervalDuration }); - if (driftTolerance == null) { - return null; + return moment.duration(0); } + const driftTolerance = getDriftTolerance({ from, to, intervalDuration }); + const diff = moment.duration(now.diff(previousStartedAt)); const drift = diff.subtract(intervalDuration); return drift.subtract(driftTolerance); @@ -489,135 +418,103 @@ export const errorAggregator = ( }, Object.create(null)); }; -/** - * Determines the number of time intervals to search if gap is present - * along with new maxSignals per time interval. - * @param logger Logger - * @param ruleParamsFrom string representing the rules 'from' property - * @param ruleParamsTo string representing the rules 'to' property - * @param ruleParamsMaxSignals int representing the maxSignals property on the rule (usually unmodified at 100) - * @param gap moment.Duration representing a gap in since the last time the rule ran - * @param previousStartedAt Date at which the rule last ran - * @param interval string the interval which the rule runs - * @param buildRuleMessage function provides meta information for logged event - */ -export const getSignalTimeTuples = ({ +export const getRuleRangeTuples = ({ logger, - ruleParamsFrom, - ruleParamsTo, - ruleParamsMaxSignals, - gap, previousStartedAt, + from, + to, interval, + maxSignals, buildRuleMessage, }: { logger: Logger; - ruleParamsFrom: string; - ruleParamsTo: string; - ruleParamsMaxSignals: number; - gap: moment.Duration | null; previousStartedAt: Date | null | undefined; + from: string; + to: string; interval: string; - buildRuleMessage: BuildRuleMessage; -}): Array<{ - to: moment.Moment | undefined; - from: moment.Moment | undefined; maxSignals: number; -}> => { - let totalToFromTuples: Array<{ - to: moment.Moment | undefined; - from: moment.Moment | undefined; - maxSignals: number; - }> = []; - if (gap != null && gap.valueOf() > 0 && previousStartedAt != null) { - const fromUnit = ruleParamsFrom[ruleParamsFrom.length - 1]; - if (isValidUnit(fromUnit)) { - const unit = fromUnit; // only seconds (s), minutes (m) or hours (h) - - /* - we need the total duration from now until the last time the rule ran. - the next few lines can be summed up as calculating - "how many second | minutes | hours have passed since the last time this ran?" - */ - const nowToGapDiff = moment.duration(moment().diff(previousStartedAt)); - const calculatedFrom = `now-${ - parseInt(shorthandMap[unit].asFn(nowToGapDiff).toString(), 10) + unit - }`; - logger.debug(buildRuleMessage(`calculatedFrom: ${calculatedFrom}`)); - - const intervalMoment = moment.duration(parseInt(interval, 10), unit); - logger.debug(buildRuleMessage(`intervalMoment: ${shorthandMap[unit].asFn(intervalMoment)}`)); - const momentUnit = shorthandMap[unit].momentString as moment.DurationInputArg2; - // maxCatchup is to ensure we are not trying to catch up too far back. - // This allows for a maximum of 4 consecutive rule execution misses - // to be included in the number of signals generated. - const { maxCatchup, ratio, gapDiffInUnits } = getGapMaxCatchupRatio({ - logger, - buildRuleMessage, - previousStartedAt, - unit, - ruleParamsFrom, - interval, - }); - logger.debug(buildRuleMessage(`maxCatchup: ${maxCatchup}, ratio: ${ratio}`)); - if (maxCatchup == null || ratio == null || gapDiffInUnits == null) { - throw new Error( - buildRuleMessage('failed to calculate maxCatchup, ratio, or gapDiffInUnits') - ); - } - let tempTo = dateMath.parse(ruleParamsFrom); - if (tempTo == null) { - // return an error - throw new Error(buildRuleMessage('dateMath parse failed')); - } - - let beforeMutatedFrom: moment.Moment | undefined; - while (totalToFromTuples.length < maxCatchup) { - // if maxCatchup is less than 1, we calculate the 'from' differently - // and maxSignals becomes some less amount of maxSignals - // in order to maintain maxSignals per full rule interval. - if (maxCatchup > 0 && maxCatchup < 1) { - totalToFromTuples.push({ - to: tempTo.clone(), - from: tempTo.clone().subtract(gapDiffInUnits, momentUnit), - maxSignals: ruleParamsMaxSignals * maxCatchup, - }); - break; - } - const beforeMutatedTo = tempTo.clone(); - - // moment.subtract mutates the moment so we need to clone again.. - beforeMutatedFrom = tempTo.clone().subtract(intervalMoment, momentUnit); - const tuple = { - to: beforeMutatedTo, - from: beforeMutatedFrom, - maxSignals: ruleParamsMaxSignals, - }; - totalToFromTuples = [...totalToFromTuples, tuple]; - tempTo = beforeMutatedFrom; - } - totalToFromTuples = [ - { - to: dateMath.parse(ruleParamsTo), - from: dateMath.parse(ruleParamsFrom), - maxSignals: ruleParamsMaxSignals, - }, - ...totalToFromTuples, - ]; - } - } else { - totalToFromTuples = [ - { - to: dateMath.parse(ruleParamsTo), - from: dateMath.parse(ruleParamsFrom), - maxSignals: ruleParamsMaxSignals, - }, - ]; + buildRuleMessage: BuildRuleMessage; +}) => { + const originalTo = dateMath.parse(to); + const originalFrom = dateMath.parse(from); + if (originalTo == null || originalFrom == null) { + throw new Error(buildRuleMessage('dateMath parse failed')); + } + const tuples = [ + { + to: originalTo, + from: originalFrom, + maxSignals, + }, + ]; + const intervalDuration = parseInterval(interval); + if (intervalDuration == null) { + logger.error(`Failed to compute gap between rule runs: could not parse rule interval`); + return { tuples, remainingGap: moment.duration(0) }; } - logger.debug( - buildRuleMessage(`totalToFromTuples: ${JSON.stringify(totalToFromTuples, null, 4)}`) + const gap = getGapBetweenRuns({ previousStartedAt, intervalDuration, from, to }); + const catchup = getNumCatchupIntervals({ + gap, + intervalDuration, + }); + const catchupTuples = getCatchupTuples({ + to: originalTo, + from: originalFrom, + ruleParamsMaxSignals: maxSignals, + catchup, + intervalDuration, + }); + tuples.push(...catchupTuples); + // Each extra tuple adds one extra intervalDuration to the time range this rule will cover. + const remainingGapMilliseconds = Math.max( + gap.asMilliseconds() - catchup * intervalDuration.asMilliseconds(), + 0 ); - return totalToFromTuples; + return { tuples, remainingGap: moment.duration(remainingGapMilliseconds) }; +}; + +/** + * Creates rule range tuples needed to cover gaps since the last rule run. + * @param to moment.Moment representing the rules 'to' property + * @param from moment.Moment representing the rules 'from' property + * @param ruleParamsMaxSignals int representing the maxSignals property on the rule (usually unmodified at 100) + * @param catchup number the number of additional rule run intervals to add + * @param intervalDuration moment.Duration the interval which the rule runs + */ +export const getCatchupTuples = ({ + to, + from, + ruleParamsMaxSignals, + catchup, + intervalDuration, +}: { + to: moment.Moment; + from: moment.Moment; + ruleParamsMaxSignals: number; + catchup: number; + intervalDuration: moment.Duration; +}): RuleRangeTuple[] => { + const catchupTuples: RuleRangeTuple[] = []; + const intervalInMilliseconds = intervalDuration.asMilliseconds(); + let currentTo = to; + let currentFrom = from; + // This loop will create tuples with overlapping time ranges, the same way rule runs have overlapping time + // ranges due to the additional lookback. We could choose to create tuples that don't overlap here by using the + // "from" value from one tuple as "to" in the next one, however, the overlap matters for rule types like EQL and + // threshold rules that look for sets of documents within the query. Thus we keep the overlap so that these + // extra tuples behave as similarly to the regular rule runs as possible. + while (catchupTuples.length < catchup) { + const nextTo = currentTo.clone().subtract(intervalInMilliseconds); + const nextFrom = currentFrom.clone().subtract(intervalInMilliseconds); + catchupTuples.push({ + to: nextTo, + from: nextFrom, + maxSignals: ruleParamsMaxSignals, + }); + currentTo = nextTo; + currentFrom = nextFrom; + } + return catchupTuples; }; /** diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 15333f71861b8..5e553e3cfe7a1 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5944,19 +5944,10 @@ "xpack.canvas.functions.ltHelpText": "{CONTEXT} が引数よりも小さいかを戻します。", "xpack.canvas.functions.mapCenter.args.latHelpText": "マップの中央の緯度", "xpack.canvas.functions.mapCenterHelpText": "マップの中央座標とズームレベルのオブジェクトに戻ります。", - "xpack.canvas.functions.mapColumn.args.expressionHelpText": "単一行 {DATATABLE} として各行に渡される {CANVAS} 表現です。", - "xpack.canvas.functions.mapColumn.args.nameHelpText": "結果の列の名前です。", - "xpack.canvas.functions.mapColumnHelpText": "他の列の結果として計算された列を追加します。引数が指定された場合のみ変更が加えられます。{alterColumnFn}と{staticColumnFn}もご参照ください。", "xpack.canvas.functions.markdown.args.contentHelpText": "{MARKDOWN} を含むテキストの文字列です。連結させるには、{stringFn} 関数を複数回渡します。", "xpack.canvas.functions.markdown.args.fontHelpText": "コンテンツの {CSS} フォントプロパティです。たとえば、{fontFamily} または {fontWeight} です。", "xpack.canvas.functions.markdown.args.openLinkHelpText": "新しいタブでリンクを開くためのtrue/false値。デフォルト値は「false」です。「true」に設定するとすべてのリンクが新しいタブで開くようになります。", "xpack.canvas.functions.markdownHelpText": "{MARKDOWN} テキストをレンダリングするエレメントを追加します。ヒント:単一の数字、メトリック、テキストの段落には {markdownFn} 関数を使います。", - "xpack.canvas.functions.math.args.expressionHelpText": "評価された {TINYMATH} 表現です。{TINYMATH_URL} をご覧ください。", - "xpack.canvas.functions.math.emptyDatatableErrorMessage": "空のデータベース", - "xpack.canvas.functions.math.emptyExpressionErrorMessage": "空の表現", - "xpack.canvas.functions.math.executionFailedErrorMessage": "数式の実行に失敗しました。列名を確認してください", - "xpack.canvas.functions.math.tooManyResultsErrorMessage": "式は 1 つの数字を返す必要があります。表現を {mean} または {sum} で囲んでみてください", - "xpack.canvas.functions.mathHelpText": "{TYPE_NUMBER}または{DATATABLE}を{CONTEXT}として使用して、{TINYMATH}数式を解釈します。{DATATABLE}列は列名で表示されます。{CONTEXT}が数字の場合は、{value}と表示されます。", "xpack.canvas.functions.metric.args.labelFontHelpText": "ラベルの {CSS} フォントプロパティです。例: {FONT_FAMILY} または {FONT_WEIGHT}。", "xpack.canvas.functions.metric.args.labelHelpText": "メトリックを説明するテキストです。", "xpack.canvas.functions.metric.args.metricFontHelpText": "メトリックの {CSS} フォントプロパティです。例: {FONT_FAMILY} または {FONT_WEIGHT}。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 8051b24bf9c03..e5c57dc0e2ec6 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5955,19 +5955,10 @@ "xpack.canvas.functions.ltHelpText": "返回 {CONTEXT} 是否小于参数。", "xpack.canvas.functions.mapCenter.args.latHelpText": "地图中心的纬度", "xpack.canvas.functions.mapCenterHelpText": "返回包含地图中心坐标和缩放级别的对象。", - "xpack.canvas.functions.mapColumn.args.expressionHelpText": "作为单行 {DATATABLE} 传递到每一行的 {CANVAS} 表达式。", - "xpack.canvas.functions.mapColumn.args.nameHelpText": "结果列的名称。", - "xpack.canvas.functions.mapColumnHelpText": "添加计算为其他列的结果的列。只有提供参数时,才会执行更改。另请参见 {alterColumnFn} 和 {staticColumnFn}。", "xpack.canvas.functions.markdown.args.contentHelpText": "包含 {MARKDOWN} 的文本字符串。要进行串联,请多次传递 {stringFn} 函数。", "xpack.canvas.functions.markdown.args.fontHelpText": "内容的 {CSS} 字体属性。例如 {fontFamily} 或 {fontWeight}。", "xpack.canvas.functions.markdown.args.openLinkHelpText": "用于在新标签页中打开链接的 true 或 false 值。默认值为 `false`。设置为 `true` 时将在新标签页中打开所有链接。", "xpack.canvas.functions.markdownHelpText": "添加呈现 {MARKDOWN} 文本的元素。提示:将 {markdownFn} 函数用于单个数字、指标和文本段落。", - "xpack.canvas.functions.math.args.expressionHelpText": "已计算的 {TINYMATH} 表达式。请参阅 {TINYMATH_URL}。", - "xpack.canvas.functions.math.emptyDatatableErrorMessage": "空数据表", - "xpack.canvas.functions.math.emptyExpressionErrorMessage": "空表达式", - "xpack.canvas.functions.math.executionFailedErrorMessage": "无法执行数学表达式。检查您的列名称", - "xpack.canvas.functions.math.tooManyResultsErrorMessage": "表达式必须返回单个数字。尝试将您的表达式包装在 {mean} 或 {sum} 中", - "xpack.canvas.functions.mathHelpText": "使用 {TYPE_NUMBER} 或 {DATATABLE} 作为 {CONTEXT} 来解释 {TINYMATH} 数学表达式。{DATATABLE} 列按列名使用。如果 {CONTEXT} 是数字,则作为 {value} 使用。", "xpack.canvas.functions.metric.args.labelFontHelpText": "标签的 {CSS} 字体属性。例如 {FONT_FAMILY} 或 {FONT_WEIGHT}。", "xpack.canvas.functions.metric.args.labelHelpText": "描述指标的文本。", "xpack.canvas.functions.metric.args.metricFontHelpText": "指标的 {CSS} 字体属性。例如 {FONT_FAMILY} 或 {FONT_WEIGHT}。", diff --git a/x-pack/test/accessibility/apps/index_lifecycle_management.ts b/x-pack/test/accessibility/apps/index_lifecycle_management.ts index 43fd805a42d37..d6ba222e50eb4 100644 --- a/x-pack/test/accessibility/apps/index_lifecycle_management.ts +++ b/x-pack/test/accessibility/apps/index_lifecycle_management.ts @@ -28,7 +28,10 @@ const TEST_POLICY_ALL_PHASES = { }; export default function ({ getService, getPageObjects }: FtrProviderContext) { - const { common } = getPageObjects(['common']); + const { common, indexLifecycleManagement } = getPageObjects([ + 'common', + 'indexLifecycleManagement', + ]); const retry = getService('retry'); const testSubjects = getService('testSubjects'); const esClient = getService('es'); @@ -55,6 +58,32 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await esClient.ilm.deleteLifecycle({ policy: TEST_POLICY_NAME }); }); + it('Create Policy Form', async () => { + await retry.waitFor('Index Lifecycle Policy create/edit view to be present', async () => { + return testSubjects.isDisplayed('createPolicyButton'); + }); + + // Navigate to create policy page and take snapshot + await testSubjects.click('createPolicyButton'); + await retry.waitFor('Index Lifecycle Policy create/edit view to be present', async () => { + return (await testSubjects.getVisibleText('policyTitle')) === 'Create policy'; + }); + + // Fill out form after enabling all phases and take snapshot. + await indexLifecycleManagement.fillNewPolicyForm('testPolicy', true, true, false); + await a11y.testAppSnapshot(); + }); + + it('Send Request Flyout on New Policy Page', async () => { + // Take snapshot of the show request panel + await testSubjects.click('requestButton'); + await a11y.testAppSnapshot(); + + // Close panel and save policy + await testSubjects.click('euiFlyoutCloseButton'); + await indexLifecycleManagement.saveNewPolicy(); + }); + it('List policies view', async () => { await retry.waitFor('Index Lifecycle Policy create/edit view to be present', async () => { await common.navigateToApp('indexLifecycleManagement'); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts index 9e1c290d16059..20d2b107dc2cc 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts @@ -273,6 +273,7 @@ export default ({ getService }: FtrProviderContext) => { rule_id: 'rule-1', from: '1900-01-01T00:00:00.000Z', query: '*:*', + threat_indicator_path: 'threat.indicator', threat_query: 'threat.indicator.domain: *', // narrow things down to indicators with a domain threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module threat_mapping: [ @@ -353,6 +354,7 @@ export default ({ getService }: FtrProviderContext) => { rule_id: 'rule-1', from: '1900-01-01T00:00:00.000Z', query: 'source.port: 57324', // narrow our query to a single record that matches two indicators + threat_indicator_path: 'threat.indicator', threat_query: 'threat.indicator.ip: *', threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module threat_mapping: [ @@ -422,6 +424,7 @@ export default ({ getService }: FtrProviderContext) => { rule_id: 'rule-1', from: '1900-01-01T00:00:00.000Z', query: 'source.port: 57324', // narrow our query to a single record that matches two indicators + threat_indicator_path: 'threat.indicator', threat_query: 'threat.indicator.ip: *', threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module threat_mapping: [ @@ -519,6 +522,7 @@ export default ({ getService }: FtrProviderContext) => { rule_id: 'rule-1', from: '1900-01-01T00:00:00.000Z', query: '*:*', // narrow our query to a single record that matches two indicators + threat_indicator_path: 'threat.indicator', threat_query: '', threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module threat_mapping: [ diff --git a/x-pack/test/functional/page_objects/index_lifecycle_management_page.ts b/x-pack/test/functional/page_objects/index_lifecycle_management_page.ts index 04db9e4544c9a..ddf46926f122a 100644 --- a/x-pack/test/functional/page_objects/index_lifecycle_management_page.ts +++ b/x-pack/test/functional/page_objects/index_lifecycle_management_page.ts @@ -9,6 +9,7 @@ import { FtrProviderContext } from '../ftr_provider_context'; export function IndexLifecycleManagementPageProvider({ getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); + const retry = getService('retry'); return { async sectionHeadingText() { @@ -17,5 +18,40 @@ export function IndexLifecycleManagementPageProvider({ getService }: FtrProvider async createPolicyButton() { return await testSubjects.find('createPolicyButton'); }, + async fillNewPolicyForm( + policyName: string, + warmEnabled: boolean = false, + coldEnabled: boolean = false, + deletePhaseEnabled: boolean = false + ) { + await testSubjects.setValue('policyNameField', policyName); + if (warmEnabled) { + await retry.try(async () => { + await testSubjects.click('enablePhaseSwitch-warm'); + }); + } + if (coldEnabled) { + await retry.try(async () => { + await testSubjects.click('enablePhaseSwitch-cold'); + }); + } + if (deletePhaseEnabled) { + await retry.try(async () => { + await testSubjects.click('enableDeletePhaseButton'); + }); + } + }, + async saveNewPolicy() { + await testSubjects.click('savePolicyButton'); + }, + async createNewPolicyAndSave( + policyName: string, + warmEnabled: boolean = false, + coldEnabled: boolean = false, + deletePhaseEnabled: boolean = false + ) { + await this.fillNewPolicyForm(policyName, warmEnabled, coldEnabled, deletePhaseEnabled); + await this.saveNewPolicy(); + }, }; } diff --git a/x-pack/test/security_solution_cypress/es_archives/exceptions/data.json b/x-pack/test/security_solution_cypress/es_archives/exceptions/data.json new file mode 100644 index 0000000000000..b7de2dba02d19 --- /dev/null +++ b/x-pack/test/security_solution_cypress/es_archives/exceptions/data.json @@ -0,0 +1,23 @@ +{ + "type": "doc", + "value": { + "id": "_aZE5nwBOpWiDweSth_D", + "index": "exceptions-0001", + "source": { + "@timestamp": "2019-09-01T00:41:06.527Z", + "agent": { + "name": "bond" + }, + "user" : [ + { + "name" : "john", + "id" : "c5baec68-e774-46dc-b728-417e71d68444" + }, + { + "name" : "alice", + "id" : "6e831997-deab-4e56-9218-a90ef045556e" + } + ] + } + } +} diff --git a/x-pack/test/security_solution_cypress/es_archives/exceptions/mappings.json b/x-pack/test/security_solution_cypress/es_archives/exceptions/mappings.json new file mode 100644 index 0000000000000..e63b86392756f --- /dev/null +++ b/x-pack/test/security_solution_cypress/es_archives/exceptions/mappings.json @@ -0,0 +1,42 @@ +{ + "type": "index", + "value": { + "aliases": { + "exceptions": { + "is_write_index": false + } + }, + "settings": { + "index": { + "refresh_interval": "5s" + } + }, + "index": "exceptions-0001", + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + }, + "agent": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user": { + "type": "nested", + "properties": { + "first": { + "type": "keyword" + }, + "last": { + "type": "keyword" + } + } + } + } + } + } +}