From e27066d48980033fc98f256c497df785504c7f21 Mon Sep 17 00:00:00 2001 From: Maxim Palenov Date: Thu, 2 May 2024 15:13:02 +0200 Subject: [PATCH] [Security Solution] Allow users to edit related_integrations field for custom rules (#178295) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Resolves: https://github.com/elastic/kibana/issues/173595** ## Summary This PR adds an ability to add and edit custom rule's related integrations. Functionality is necessary to start working on [Prebuilt Rule Customization Epic Milestone 3](https://github.com/elastic/kibana/issues/174168). ## Details Rule's related integrations represent optional dependencies on [Elastic integrations](https://docs.elastic.co/en/integrations) to ingest data. Currently prebuilt rule's related integrations are shown on rule details page. This information contains integration's name, installation status and a version mismatch warning when related integration's version dependency doesn't match with an installed integration's version. A subset of [Semver](https://semver.org/) is used to specify version dependency. Elastic prebuilt rules use only caret syntax like `^1.2.3`. To make it possible to add and edit related integrations for custom rules the following has been done - New internal endpoint `/internal/detection_engine/fleet/integrations/all` has been added. It returns the full list of available integrations containing title, latest available version and installed version if available. This is necessary to display an options list where users can pick a desired integration. Since some Elastic Prebuilt rules depend not only on integrations from `security` category this endpoint returns all available integrations (not only related to Security Solution). - Rule create form has been adjusted by adding `Related Integrations` form controls - Rule edit form has been adjusted by adding `Related Integrations` form controls - Related integrations installation status has been adjusted to conform with the design - Functional Jest tests have been added - Functional tests have been added to make sure it's possible to (bulk) `create`/`patch`/`update`/`export`/`import` with related integrations - A limited number of Cypress tests have been added ### Integration installation status Integration installation status has been adjusted. There are following statuses shown - `Enabled` for installed and enabled integrations. Enabled integrations are detected by checking Elastic Agent policies for presence of such an integration. It's not guaranteed the policy is picked by agents and data is being ingested. - `Disabled` for installed and disabled integrations. An agent policy containing such an integration isn't found. - `Not installed` for not installed integrations. - Nothing is shown for unknown integrations. If there is no such integration found in `/internal/detection_engine/fleet/integrations/all` result it's considered as unknown. ### Version dependency [Semver](https://semver.org/) allows a wide range of version range declaration. Such flexibility will complicate constructing of an integration link on rule details page. Since Elastic Prebuilt rules use only caret version dependency like `^1.2.3` related integration's version dependency is limited to a subset of semver semantic. The following is supported - A plain version dependency e.g. `1.2.3` - Tilde version dependency e.g. `~1.2.3` - Caret version dependency e.g. `^1.2.3` ### Misc - https://github.com/elastic/kibana/issues/152408 has been fixed by this PR. - `/internal/detection_engine/fleet/integrations/installed` endpoint hasn't been removed. We need to make sure it's not needed anymore. - E2e testing of the current functionality is complicated by dependency on EPR and difficulties to mock it. EPR periodically may respond with an error resulting in flaky Cypress tests. ### Flaky test runner results - 🟢 [Create rule](https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5632) (100 runs ESS and 100 runs in Serverless) - 🟢 [Rule Management related integrations](https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5632) (100 runs ESS and 100 runs in Serverless) ### Screenshots ![Screenshot 2024-04-16 at 10 01 25](https://github.com/elastic/kibana/assets/3775283/f41574cb-c806-4e49-97bf-9b27bf4c0f39) ![Screenshot 2024-04-16 at 10 02 03](https://github.com/elastic/kibana/assets/3775283/cf15580e-169f-4823-a579-257509c806a4) ![Screenshot 2024-04-16 at 10 02 16](https://github.com/elastic/kibana/assets/3775283/03a21eea-1014-484f-b1d2-3db81c46b8ef) ![Screenshot 2024-04-16 at 10 04 19](https://github.com/elastic/kibana/assets/3775283/06385ef4-458f-4562-bb8f-d98db9bb1fe3) ![Screenshot 2024-04-16 at 10 02 33](https://github.com/elastic/kibana/assets/3775283/edec85bf-d020-4afb-a999-4eb21255c3b6) ![Screenshot 2024-04-16 at 10 04 40](https://github.com/elastic/kibana/assets/3775283/a21c55a8-9947-44b0-ba1f-6624cd410d3e) ![Screenshot 2024-04-16 at 10 05 03](https://github.com/elastic/kibana/assets/3775283/05928a15-961b-4f67-9968-d2a32ceb86dc) --- packages/kbn-doc-links/src/get_doc_links.ts | 1 + packages/kbn-doc-links/src/types.ts | 1 + .../get_all_integrations_route.ts | 12 + .../fleet_integrations/index.ts | 3 + .../fleet_integrations/model/integrations.ts | 101 +++ .../fleet_integrations/urls.ts | 2 + .../model/rule_schema/rule_schemas.gen.ts | 4 +- .../rule_schema/rule_schemas.schema.yaml | 7 +- .../mock/create_react_query_wrapper.tsx | 25 + .../public/common/mock/index.ts | 1 + .../api/__mocks__/api_client.ts | 38 +- .../fleet_integrations/api/api_client.ts | 19 +- .../api/api_client_interface.ts | 19 +- .../default_related_integration.ts | 8 + .../components/related_integrations/index.ts | 8 + .../integration_status_badge.tsx | 33 + .../related_integration_field.tsx | 241 +++++ .../related_integration_field_row.tsx | 48 + .../related_integrations.test.tsx | 856 ++++++++++++++++++ .../related_integrations.tsx | 72 ++ .../related_integrations_help_info.tsx | 63 ++ .../related_integrations/translations.ts | 136 +++ .../validate_related_integration.test.ts | 101 +++ .../validate_related_integration.ts | 40 + .../step_define_rule/index.test.tsx | 404 ++++++--- .../components/step_define_rule/index.tsx | 4 + .../components/step_define_rule/schema.tsx | 13 +- .../pages/rule_creation/helpers.test.ts | 66 ++ .../pages/rule_creation/helpers.ts | 1 + .../pages/rule_editing/index.tsx | 1 + .../components/rules_table/__mocks__/mock.ts | 12 +- .../use_execution_events.test.tsx | 19 +- .../use_execution_results.test.tsx | 19 +- .../integration_details.test.ts | 24 +- .../integration_details.ts | 112 ++- .../integration_status_badge.tsx | 22 +- .../related_integrations/translations.ts | 8 +- .../use_installed_integrations.tsx | 52 -- ...ons.test.tsx => use_integrations.test.tsx} | 70 +- .../related_integrations/use_integrations.tsx | 35 + .../use_related_integrations.ts | 13 +- .../pages/detection_engine/rules/types.ts | 4 +- ...erts.test.tsx => use_fetch_alerts.test.ts} | 21 +- ...st.tsx => use_fetch_related_cases.test.ts} | 27 +- .../public/shared_imports.ts | 4 + .../note_previews/index.test.tsx | 172 ++-- .../timelines_table/common_columns.test.tsx | 280 +++--- .../hooks/use_managed_user.test.ts | 27 +- .../new_user_detail/hooks/use_managed_user.ts | 9 +- .../extract_integrations.test.ts | 715 +++++++++++++++ .../extract_integrations.ts | 93 ++ .../api/get_all_integrations/route.ts | 74 ++ .../sort_integrations_by_status.ts | 29 + .../sort_packages_by_security_category.ts | 25 + .../api/get_installed_integrations/route.ts | 6 +- .../fleet_integrations/api/register_routes.ts | 11 +- .../model/rule_assets/prebuilt_rule_asset.ts | 2 - .../logic/crud/update_rules.ts | 2 +- .../security_solution/server/routes/index.ts | 2 +- .../translations/translations/fr-FR.json | 3 - .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - .../perform_bulk_action.ts | 458 ++++++---- .../create_rules.ts | 35 +- .../create_rules_bulk.ts | 35 +- .../export_rules.ts | 23 + .../import_rules.ts | 29 + .../patch_rules.ts | 54 +- .../patch_rules_bulk.ts | 66 +- .../update_rules.ts | 49 +- .../update_rules_bulk.ts | 59 +- .../rule_creation/common_flows.cy.ts | 2 + .../related_integrations.cy.ts | 168 ++-- .../e2e/entity_analytics/entity_flyout.cy.ts | 16 +- .../cypress/screens/common.ts | 2 + .../cypress/screens/create_new_rule.ts | 3 + .../cypress/tasks/create_new_rule.ts | 14 +- .../cypress/tasks/fleet_integrations.ts | 12 +- 78 files changed, 4253 insertions(+), 998 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/api/detection_engine/fleet_integrations/get_all_integrations/get_all_integrations_route.ts create mode 100644 x-pack/plugins/security_solution/common/api/detection_engine/fleet_integrations/model/integrations.ts create mode 100644 x-pack/plugins/security_solution/public/common/mock/create_react_query_wrapper.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/default_related_integration.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/index.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/integration_status_badge.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integration_field.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integration_field_row.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integrations.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integrations.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integrations_help_info.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/translations.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/validate_related_integration.test.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/validate_related_integration.ts delete mode 100644 x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_installed_integrations.tsx rename x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/{use_installed_integrations.test.tsx => use_integrations.test.tsx} (65%) create mode 100644 x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_integrations.tsx rename x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/{use_fetch_alerts.test.tsx => use_fetch_alerts.test.ts} (79%) rename x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/{use_fetch_related_cases.test.tsx => use_fetch_related_cases.test.ts} (66%) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_all_integrations/extract_integrations.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_all_integrations/extract_integrations.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_all_integrations/route.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_all_integrations/sort_integrations_by_status.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_all_integrations/sort_packages_by_security_category.ts diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index 017d5c6b23305..689f8a5e9859e 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -480,6 +480,7 @@ export const getDocLinks = ({ kibanaBranch, buildFlavor }: GetDocLinkOptions): D privileges: `${SECURITY_SOLUTION_DOCS}endpoint-management-req.html`, manageDetectionRules: `${SECURITY_SOLUTION_DOCS}rules-ui-management.html`, createEsqlRuleType: `${SECURITY_SOLUTION_DOCS}rules-ui-create.html#create-esql-rule`, + ruleUiAdvancedParams: `${SECURITY_SOLUTION_DOCS}rules-ui-create.html#rule-ui-advanced-params`, entityAnalytics: { riskScorePrerequisites: `${SECURITY_SOLUTION_DOCS}ers-requirements.html`, hostRiskScore: `${SECURITY_SOLUTION_DOCS}host-risk-score.html`, diff --git a/packages/kbn-doc-links/src/types.ts b/packages/kbn-doc-links/src/types.ts index bd8f353c1c591..261165dcd7ec9 100644 --- a/packages/kbn-doc-links/src/types.ts +++ b/packages/kbn-doc-links/src/types.ts @@ -355,6 +355,7 @@ export interface DocLinks { readonly privileges: string; readonly manageDetectionRules: string; readonly createEsqlRuleType: string; + readonly ruleUiAdvancedParams: string; readonly entityAnalytics: { readonly riskScorePrerequisites: string; readonly hostRiskScore: string; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/fleet_integrations/get_all_integrations/get_all_integrations_route.ts b/x-pack/plugins/security_solution/common/api/detection_engine/fleet_integrations/get_all_integrations/get_all_integrations_route.ts new file mode 100644 index 0000000000000..0798808365e12 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/fleet_integrations/get_all_integrations/get_all_integrations_route.ts @@ -0,0 +1,12 @@ +/* + * 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 type { Integration } from '../model/integrations'; + +export interface GetAllIntegrationsResponse { + integrations: Integration[]; +} diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/fleet_integrations/index.ts b/x-pack/plugins/security_solution/common/api/detection_engine/fleet_integrations/index.ts index 63a824a430c6e..1c50373366955 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/fleet_integrations/index.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/fleet_integrations/index.ts @@ -5,7 +5,10 @@ * 2.0. */ +export * from './get_all_integrations/get_all_integrations_route'; + export * from './get_installed_integrations/get_installed_integrations_route'; export * from './urls'; +export * from './model/integrations'; export * from './model/installed_integrations'; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/fleet_integrations/model/integrations.ts b/x-pack/plugins/security_solution/common/api/detection_engine/fleet_integrations/model/integrations.ts new file mode 100644 index 0000000000000..d1ddeb0ffa057 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/fleet_integrations/model/integrations.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// ------------------------------------------------------------------------------------------------- +// Fleet Package Integration + +/** + * Information about a Fleet integration including info about its package. + * + * @example + * { + * package_name: 'aws', + * package_title: 'AWS', + * integration_name: 'cloudtrail', + * integration_title: 'AWS CloudTrail', + * latest_package_version: '1.2.3', + * is_installed: false + * is_enabled: false + * } + * + * @example + * { + * package_name: 'aws', + * package_title: 'AWS', + * integration_name: 'cloudtrail', + * integration_title: 'AWS CloudTrail', + * latest_package_version: '1.16.1', + * installed_package_version: '1.16.1', + * is_installed: true + * is_enabled: false + * } + * + * @example + * { + * package_name: 'system', + * package_title: 'System', + * latest_package_version: '2.0.1', + * installed_package_version: '1.13.0', + * is_installed: true + * is_enabled: true + * } + * + */ +export interface Integration { + /** + * Name is a unique package id within a given cluster. + * There can't be 2 or more different packages with the same name. + * @example 'aws' + */ + package_name: string; + + /** + * Title is a user-friendly name of the package that we show in the UI. + * @example 'AWS' + */ + package_title: string; + + /** + * Whether the package is installed + */ + is_installed: boolean; + + /** + * Whether this integration is enabled + */ + is_enabled: boolean; + + /** + * Version of the latest available package. Semver-compatible. + * @example '1.2.3' + */ + latest_package_version: string; + + /** + * Version of the installed package. Semver-compatible. + * @example '1.2.3' + */ + installed_package_version?: string; + + /** + * Name identifies an integration within its package. + * Undefined when package name === integration name. This indicates that it's the only integration + * within this package. + * @example 'cloudtrail' + * @example undefined + */ + integration_name?: string; + + /** + * Title is a user-friendly name of the integration that we show in the UI. + * Undefined when package name === integration name. This indicates that it's the only integration + * within this package. + * @example 'AWS CloudTrail' + * @example undefined + */ + integration_title?: string; +} diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/fleet_integrations/urls.ts b/x-pack/plugins/security_solution/common/api/detection_engine/fleet_integrations/urls.ts index b1216d855284e..04cf30bc509fe 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/fleet_integrations/urls.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/fleet_integrations/urls.ts @@ -7,5 +7,7 @@ import { INTERNAL_DETECTION_ENGINE_URL as INTERNAL_URL } from '../../../constants'; +export const GET_ALL_INTEGRATIONS_URL = `${INTERNAL_URL}/fleet/integrations/all` as const; + export const GET_INSTALLED_INTEGRATIONS_URL = `${INTERNAL_URL}/fleet/integrations/installed` as const; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.gen.ts b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.gen.ts index fc29093779c75..d05a272337534 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.gen.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.gen.ts @@ -53,11 +53,11 @@ import { MaxSignals, ThreatArray, SetupGuide, + RelatedIntegrationArray, RuleObjectId, RuleSignatureId, IsRuleImmutable, RuleSource, - RelatedIntegrationArray, RequiredFieldArray, RuleQuery, IndexPatternArray, @@ -136,6 +136,7 @@ export const BaseDefaultableFields = z.object({ max_signals: MaxSignals.optional(), threat: ThreatArray.optional(), setup: SetupGuide.optional(), + related_integrations: RelatedIntegrationArray.optional(), }); export type BaseCreateProps = z.infer; @@ -163,7 +164,6 @@ export const ResponseFields = z.object({ created_at: z.string().datetime(), created_by: z.string(), revision: z.number().int().min(0), - related_integrations: RelatedIntegrationArray, required_fields: RequiredFieldArray, execution_summary: RuleExecutionSummary.optional(), }); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.schema.yaml b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.schema.yaml index 4a2279f7a7c8d..dfb3bfb738a5c 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.schema.yaml +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.schema.yaml @@ -131,6 +131,9 @@ components: setup: $ref: './common_attributes.schema.yaml#/components/schemas/SetupGuide' + related_integrations: + $ref: './common_attributes.schema.yaml#/components/schemas/RelatedIntegrationArray' + BaseCreateProps: x-inline: true allOf: @@ -178,13 +181,11 @@ components: revision: type: integer minimum: 0 - # NOTE: For now, Related Integrations and Required Fields are + # NOTE: For now, Required Fields are # supported for prebuilt rules only. We don't want to allow users to edit these 3 # fields via the API. If we added them to baseParams.defaultable, they would # become a part of the request schema as optional fields. This is why we add them # here, in order to add them only to the response schema. - related_integrations: - $ref: './common_attributes.schema.yaml#/components/schemas/RelatedIntegrationArray' required_fields: $ref: './common_attributes.schema.yaml#/components/schemas/RequiredFieldArray' execution_summary: diff --git a/x-pack/plugins/security_solution/public/common/mock/create_react_query_wrapper.tsx b/x-pack/plugins/security_solution/public/common/mock/create_react_query_wrapper.tsx new file mode 100644 index 0000000000000..42377ecca87c8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/mock/create_react_query_wrapper.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +export function createReactQueryWrapper(): React.FC { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // Turn retries off, otherwise we won't be able to test errors + retry: false, + }, + }, + }); + + // eslint-disable-next-line react/display-name + return ({ children }) => ( + {children} + ); +} diff --git a/x-pack/plugins/security_solution/public/common/mock/index.ts b/x-pack/plugins/security_solution/public/common/mock/index.ts index d4cc1185846bb..3be928b9dcc1f 100644 --- a/x-pack/plugins/security_solution/public/common/mock/index.ts +++ b/x-pack/plugins/security_solution/public/common/mock/index.ts @@ -19,3 +19,4 @@ export * from './test_providers'; export * from './timeline_results'; export * from './utils'; export * from './create_store'; +export * from './create_react_query_wrapper'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/fleet_integrations/api/__mocks__/api_client.ts b/x-pack/plugins/security_solution/public/detection_engine/fleet_integrations/api/__mocks__/api_client.ts index f0dbf5dd6899e..eadc0a1cb2d41 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/fleet_integrations/api/__mocks__/api_client.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/fleet_integrations/api/__mocks__/api_client.ts @@ -5,13 +5,49 @@ * 2.0. */ -import type { GetInstalledIntegrationsResponse } from '../../../../../common/api/detection_engine/fleet_integrations'; import type { + GetAllIntegrationsResponse, + GetInstalledIntegrationsResponse, +} from '../../../../../common/api/detection_engine/fleet_integrations'; +import type { + FetchAllIntegrationsArgs, FetchInstalledIntegrationsArgs, IFleetIntegrationsApiClient, } from '../api_client_interface'; export const fleetIntegrationsApi: jest.Mocked = { + fetchAllIntegrations: jest + .fn, [FetchAllIntegrationsArgs]>() + .mockResolvedValue({ + integrations: [ + { + package_name: 'o365', + package_title: 'Microsoft 365', + latest_package_version: '1.2.0', + installed_package_version: '1.0.0', + is_installed: false, + is_enabled: false, + }, + { + package_name: 'atlassian_bitbucket', + package_title: 'Atlassian Bitbucket', + latest_package_version: '1.0.1', + installed_package_version: '1.0.1', + integration_name: 'audit', + integration_title: 'Audit Logs', + is_installed: true, + is_enabled: true, + }, + { + package_name: 'system', + package_title: 'System', + latest_package_version: '1.6.4', + installed_package_version: '1.6.4', + is_installed: true, + is_enabled: true, + }, + ], + }), fetchInstalledIntegrations: jest .fn, [FetchInstalledIntegrationsArgs]>() .mockResolvedValue({ diff --git a/x-pack/plugins/security_solution/public/detection_engine/fleet_integrations/api/api_client.ts b/x-pack/plugins/security_solution/public/detection_engine/fleet_integrations/api/api_client.ts index 192683f84fc46..812daf5cf2104 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/fleet_integrations/api/api_client.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/fleet_integrations/api/api_client.ts @@ -5,16 +5,31 @@ * 2.0. */ -import type { GetInstalledIntegrationsResponse } from '../../../../common/api/detection_engine/fleet_integrations'; -import { GET_INSTALLED_INTEGRATIONS_URL } from '../../../../common/api/detection_engine/fleet_integrations'; +import type { + GetAllIntegrationsResponse, + GetInstalledIntegrationsResponse, +} from '../../../../common/api/detection_engine/fleet_integrations'; +import { + GET_ALL_INTEGRATIONS_URL, + GET_INSTALLED_INTEGRATIONS_URL, +} from '../../../../common/api/detection_engine/fleet_integrations'; import { KibanaServices } from '../../../common/lib/kibana'; import type { + FetchAllIntegrationsArgs, FetchInstalledIntegrationsArgs, IFleetIntegrationsApiClient, } from './api_client_interface'; export const fleetIntegrationsApi: IFleetIntegrationsApiClient = { + fetchAllIntegrations: (args: FetchAllIntegrationsArgs): Promise => { + return http().fetch(GET_ALL_INTEGRATIONS_URL, { + method: 'GET', + version: '1', + signal: args.signal, + }); + }, + fetchInstalledIntegrations: ( args: FetchInstalledIntegrationsArgs ): Promise => { diff --git a/x-pack/plugins/security_solution/public/detection_engine/fleet_integrations/api/api_client_interface.ts b/x-pack/plugins/security_solution/public/detection_engine/fleet_integrations/api/api_client_interface.ts index ba847ae39b97b..8b2610a098efc 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/fleet_integrations/api/api_client_interface.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/fleet_integrations/api/api_client_interface.ts @@ -5,9 +5,19 @@ * 2.0. */ -import type { GetInstalledIntegrationsResponse } from '../../../../common/api/detection_engine/fleet_integrations'; +import type { + GetInstalledIntegrationsResponse, + GetAllIntegrationsResponse, +} from '../../../../common/api/detection_engine/fleet_integrations'; export interface IFleetIntegrationsApiClient { + /** + * Fetch all integrations with installed and enabled statuses + * + * @throws An error if response is not OK + */ + fetchAllIntegrations(args: FetchAllIntegrationsArgs): Promise; + /** * Fetch all installed integrations. * @throws An error if response is not OK @@ -17,6 +27,13 @@ export interface IFleetIntegrationsApiClient { ): Promise; } +export interface FetchAllIntegrationsArgs { + /** + * Optional signal for cancelling the request. + */ + signal?: AbortSignal; +} + export interface FetchInstalledIntegrationsArgs { /** * Array of Fleet packages to filter for. diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/default_related_integration.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/default_related_integration.ts new file mode 100644 index 0000000000000..bc8063c3db9ca --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/default_related_integration.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const DEFAULT_RELATED_INTEGRATION = { package: '', version: '' }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/index.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/index.ts new file mode 100644 index 0000000000000..0b169487aa490 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { RelatedIntegrations } from './related_integrations'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/integration_status_badge.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/integration_status_badge.tsx new file mode 100644 index 0000000000000..0aa77fa9d39fd --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/integration_status_badge.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiBadge } from '@elastic/eui'; +import * as i18n from './translations'; + +interface IntegrationStatusBadgeProps { + isInstalled: boolean; + isEnabled: boolean; +} + +export function IntegrationStatusBadge({ + isInstalled, + isEnabled, +}: IntegrationStatusBadgeProps): JSX.Element { + const color = isEnabled ? 'success' : isInstalled ? 'primary' : undefined; + const statusText = isEnabled + ? i18n.INTEGRATION_INSTALLED_AND_ENABLED + : isInstalled + ? i18n.INTEGRATION_INSTALLED_AND_DISABLED + : i18n.INTEGRATION_NOT_INSTALLED; + + return ( + + {statusText} + + ); +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integration_field.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integration_field.tsx new file mode 100644 index 0000000000000..c24220923441b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integration_field.tsx @@ -0,0 +1,241 @@ +/* + * 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 type { ChangeEvent } from 'react'; +import React, { useCallback, useMemo } from 'react'; +import { capitalize } from 'lodash'; +import semver from 'semver'; +import { css } from '@emotion/css'; +import type { EuiComboBoxOptionOption } from '@elastic/eui'; +import { + EuiTextTruncate, + EuiButtonIcon, + EuiComboBox, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, +} from '@elastic/eui'; +import type { FieldHook } from '../../../../shared_imports'; +import type { Integration, RelatedIntegration } from '../../../../../common/api/detection_engine'; +import { useIntegrations } from '../../../../detections/components/rules/related_integrations/use_integrations'; +import { IntegrationStatusBadge } from './integration_status_badge'; +import { DEFAULT_RELATED_INTEGRATION } from './default_related_integration'; +import * as i18n from './translations'; + +interface RelatedIntegrationItemFormProps { + field: FieldHook; + relatedIntegrations: RelatedIntegration[]; + onRemove: () => void; +} + +export function RelatedIntegrationField({ + field, + relatedIntegrations, + onRemove, +}: RelatedIntegrationItemFormProps): JSX.Element { + const { data: integrations, isInitialLoading } = useIntegrations(); + const [integrationOptions, selectedIntegrationOptions] = useMemo(() => { + const currentKey = getKey(field.value.package, field.value.integration); + const relatedIntegrationsButCurrent = relatedIntegrations.filter( + (ri) => getKey(ri.package, ri.integration) !== currentKey + ); + const unusedIntegrations = filterOutUsedIntegrations( + integrations ?? [], + relatedIntegrationsButCurrent + ); + + const options = unusedIntegrations.map(transformIntegrationToOption); + const fallbackSelectedOption = + field.value.package.length > 0 + ? { + key: currentKey, + label: `${capitalize(field.value.package)} ${field.value.integration ?? ''}`, + } + : undefined; + const selectedOption = + options.find((option) => option.key === currentKey) ?? fallbackSelectedOption; + + return [options, selectedOption ? [selectedOption] : []]; + }, [integrations, field.value, relatedIntegrations]); + + const [packageErrorMessage, versionErrorMessage] = useMemo(() => { + const packagePath = `${field.path}.package`; + const versionPath = `${field.path}.version`; + + return [ + field.errors.find((err) => 'path' in err && err.path === packagePath), + field.errors.find((err) => 'path' in err && err.path === versionPath), + ]; + }, [field.path, field.errors]); + + const handleIntegrationChange = useCallback( + ([changedSelectedOption]: Array>) => + field.setValue({ + package: changedSelectedOption?.value?.package_name ?? '', + integration: changedSelectedOption?.value?.integration_name, + version: changedSelectedOption?.value + ? calculateRelevantSemver(changedSelectedOption.value) + : '', + }), + [field] + ); + + const handleVersionChange = useCallback( + (e: ChangeEvent) => + field.setValue((oldValue) => ({ + ...oldValue, + version: e.target.value, + })), + [field] + ); + + const hasError = Boolean(packageErrorMessage) || Boolean(versionErrorMessage); + const isLastField = relatedIntegrations.length === 1; + const isLastEmptyField = isLastField && field.value.package === ''; + const handleRemove = useCallback(() => { + if (isLastField) { + field.setValue(DEFAULT_RELATED_INTEGRATION); + return; + } + + onRemove(); + }, [onRemove, field, isLastField]); + + return ( + + + + + options={integrationOptions} + renderOption={renderIntegrationOption} + selectedOptions={selectedIntegrationOptions} + singleSelection + isLoading={isInitialLoading} + isDisabled={!integrations} + onChange={handleIntegrationChange} + fullWidth + aria-label={i18n.RELATED_INTEGRATION_ARIA_LABEL} + isInvalid={Boolean(packageErrorMessage)} + data-test-subj="relatedIntegrationComboBox" + /> + + + + + + + + + + ); +} + +const ROW_OVERFLOW_FIX_STYLE = css` + overflow: hidden; +`; + +/** + * Minimum width has been determined empirically like that + * semver value like `^1.2.3` doesn't overflow + */ +const MIN_WIDTH_VERSION_CONSTRAIN_STYLE = css` + min-width: 150px; +`; + +function filterOutUsedIntegrations( + integrations: Integration[], + relatedIntegrations: RelatedIntegration[] +): Integration[] { + const usedIntegrationsSet = new Set( + relatedIntegrations.map((ri) => getKey(ri.package, ri.integration)) + ); + + return integrations?.filter( + (i) => !usedIntegrationsSet.has(getKey(i.package_name, i.integration_name)) + ); +} + +function transformIntegrationToOption( + integration: Integration +): EuiComboBoxOptionOption { + const integrationTitle = integration.integration_title ?? integration.package_title; + const label = integration.is_enabled + ? i18n.INTEGRATION_ENABLED(integrationTitle) + : integration.is_installed + ? i18n.INTEGRATION_DISABLED(integrationTitle) + : integrationTitle; + + return { + key: getKey(integration.package_name, integration.integration_name), + label, + value: integration, + color: integration.is_enabled ? 'success' : integration.is_installed ? 'primary' : undefined, + }; +} + +function getKey(packageName: string | undefined, integrationName: string | undefined): string { + return `${packageName ?? ''}${integrationName ?? ''}`; +} + +function renderIntegrationOption( + option: EuiComboBoxOptionOption +): JSX.Element | string { + const { label, value } = option; + + if (!value) { + return label; + } + + return ( + + + + + + + + + ); +} + +function calculateRelevantSemver(integration: Integration): string { + if (!integration.installed_package_version) { + return `^${integration.latest_package_version}`; + } + + // In some rare cases users may install a prerelease integration version. + // We need to build constraint on the latest stable version and + // it's supposed `latest_package_version` is the latest stable version. + if (semver.gt(integration.installed_package_version, integration.latest_package_version)) { + return `^${integration.latest_package_version}`; + } + + return `^${integration.installed_package_version}`; +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integration_field_row.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integration_field_row.tsx new file mode 100644 index 0000000000000..de549be3fae02 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integration_field_row.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback } from 'react'; +import type { RelatedIntegration } from '../../../../../common/api/detection_engine'; +import type { ArrayItem, FieldConfig } from '../../../../shared_imports'; +import { FIELD_TYPES, UseField } from '../../../../shared_imports'; +import { DEFAULT_RELATED_INTEGRATION } from './default_related_integration'; +import { RelatedIntegrationField } from './related_integration_field'; +import { validateRelatedIntegration } from './validate_related_integration'; + +interface RelatedIntegrationFieldRowProps { + item: ArrayItem; + relatedIntegrations: RelatedIntegration[]; + removeItem: (id: number) => void; +} + +export function RelatedIntegrationFieldRow({ + item, + relatedIntegrations, + removeItem, +}: RelatedIntegrationFieldRowProps): JSX.Element { + const handleRemove = useCallback(() => removeItem(item.id), [removeItem, item.id]); + + return ( + + ); +} + +const RELATED_INTEGRATION_FIELD_CONFIG: FieldConfig = { + type: FIELD_TYPES.JSON, + validations: [{ validator: validateRelatedIntegration }], + defaultValue: DEFAULT_RELATED_INTEGRATION, +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integrations.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integrations.test.tsx new file mode 100644 index 0000000000000..21fa15c358719 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integrations.test.tsx @@ -0,0 +1,856 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { + screen, + render, + act, + fireEvent, + waitFor, + waitForElementToBeRemoved, +} from '@testing-library/react'; +import type { RelatedIntegration } from '../../../../../common/api/detection_engine'; +import { FIELD_TYPES, Form, useForm } from '../../../../shared_imports'; +import { createReactQueryWrapper } from '../../../../common/mock'; +import { fleetIntegrationsApi } from '../../../fleet_integrations/api/__mocks__'; +import { RelatedIntegrations } from './related_integrations'; + +// must match to the import in rules/related_integrations/use_integrations.tsx +jest.mock('../../../fleet_integrations/api'); +jest.mock('../../../../common/lib/kibana', () => ({ + useKibana: jest.fn().mockReturnValue({ + services: { + docLinks: { + links: { + securitySolution: { + ruleUiAdvancedParams: 'http://link-to-docs', + }, + }, + }, + }, + }), +})); + +const RELATED_INTEGRATION_ROW = 'relatedIntegrationRow'; +const COMBO_BOX_TOGGLE_BUTTON_TEST_ID = 'comboBoxToggleListButton'; +const COMBO_BOX_SELECTION_TEST_ID = 'euiComboBoxPill'; +const COMBO_BOX_CLEAR_BUTTON_TEST_ID = 'comboBoxClearButton'; +const VERSION_INPUT_TEST_ID = 'relatedIntegrationVersionDependency'; +const REMOVE_INTEGRATION_ROW_BUTTON_TEST_ID = 'relatedIntegrationRemove'; + +describe('RelatedIntegrations form part', () => { + beforeEach(() => { + fleetIntegrationsApi.fetchAllIntegrations.mockResolvedValue({ + integrations: [ + { + package_name: 'package-a', + package_title: 'Package A', + latest_package_version: '1.0.0', + is_installed: false, + is_enabled: false, + }, + ], + }); + }); + + it('renders related integrations legend', () => { + render(); + + expect(screen.getByText('Related integrations')).toBeVisible(); + }); + + describe('visual representation', () => { + it('shows package title when integration title is not set', async () => { + fleetIntegrationsApi.fetchAllIntegrations.mockResolvedValue({ + integrations: [ + { + package_name: 'package-a', + package_title: 'Package A', + latest_package_version: '1.2.0', + is_installed: false, + is_enabled: false, + }, + ], + }); + + render(, { wrapper: createReactQueryWrapper() }); + + await addRelatedIntegrationRow(); + await selectFirstEuiComboBoxOption({ + comboBoxToggleButton: screen.getByTestId(COMBO_BOX_TOGGLE_BUTTON_TEST_ID), + }); + + expect(screen.getByTestId(COMBO_BOX_SELECTION_TEST_ID)).toHaveTextContent('Package A'); + }); + + it('shows integration title when package and integration titles are set', async () => { + fleetIntegrationsApi.fetchAllIntegrations.mockResolvedValue({ + integrations: [ + { + package_name: 'package-a', + package_title: 'Package A', + integration_name: 'integration-a', + integration_title: 'Integration A', + latest_package_version: '1.2.0', + is_installed: false, + is_enabled: false, + }, + ], + }); + + render(, { wrapper: createReactQueryWrapper() }); + + await addRelatedIntegrationRow(); + await selectFirstEuiComboBoxOption({ + comboBoxToggleButton: screen.getByTestId(COMBO_BOX_TOGGLE_BUTTON_TEST_ID), + }); + + expect(screen.getByTestId(COMBO_BOX_SELECTION_TEST_ID)).toHaveTextContent('Integration A'); + }); + + it.each([ + [ + 'Not installed', + { + package_name: 'package-a', + package_title: 'Package A', + latest_package_version: '1.2.0', + is_installed: false, + is_enabled: false, + }, + ], + [ + 'Installed: Disabled', + { + package_name: 'package-a', + package_title: 'Package A', + installed_package_version: '1.2.0', + latest_package_version: '1.2.0', + is_installed: true, + is_enabled: false, + }, + ], + [ + 'Installed: Enabled', + { + package_name: 'package-a', + package_title: 'Package A', + installed_package_version: '1.2.0', + latest_package_version: '1.2.0', + is_installed: true, + is_enabled: true, + }, + ], + ])('shows integration status "%s" in combo box popover', async (status, integrationData) => { + fleetIntegrationsApi.fetchAllIntegrations.mockResolvedValue({ + integrations: [integrationData], + }); + + render(, { wrapper: createReactQueryWrapper() }); + + await addRelatedIntegrationRow(); + await showEuiComboBoxOptions(screen.getByTestId(COMBO_BOX_TOGGLE_BUTTON_TEST_ID)); + + expect(screen.getByRole('option')).toHaveTextContent(status); + }); + + it.each([ + [ + 'Package A', + { + package_name: 'package-a', + package_title: 'Package A', + latest_package_version: '1.2.0', + is_installed: false, + is_enabled: false, + }, + ], + [ + 'Package A: Disabled', + { + package_name: 'package-a', + package_title: 'Package A', + installed_package_version: '1.2.0', + latest_package_version: '1.2.0', + is_installed: true, + is_enabled: false, + }, + ], + [ + 'Package A: Enabled', + { + package_name: 'package-a', + package_title: 'Package A', + installed_package_version: '1.2.0', + latest_package_version: '1.2.0', + is_installed: true, + is_enabled: true, + }, + ], + ])( + 'shows integration name with its status "%s" when selected in combo box', + async (nameWithStatus, integrationData) => { + fleetIntegrationsApi.fetchAllIntegrations.mockResolvedValue({ + integrations: [integrationData], + }); + + render(, { wrapper: createReactQueryWrapper() }); + + await addRelatedIntegrationRow(); + await selectFirstEuiComboBoxOption({ + comboBoxToggleButton: screen.getByTestId(COMBO_BOX_TOGGLE_BUTTON_TEST_ID), + }); + + expect(screen.getByTestId(COMBO_BOX_SELECTION_TEST_ID)).toHaveTextContent( + new RegExp(`^${nameWithStatus}$`) + ); + } + ); + + it('shows integration version constraint corresponding to the latest package version when integration is NOT installed', async () => { + fleetIntegrationsApi.fetchAllIntegrations.mockResolvedValue({ + integrations: [ + { + package_name: 'package-a', + package_title: 'Package A', + latest_package_version: '1.2.0', + is_installed: false, + is_enabled: false, + }, + ], + }); + + render(, { wrapper: createReactQueryWrapper() }); + + await addRelatedIntegrationRow(); + await selectFirstEuiComboBoxOption({ + comboBoxToggleButton: screen.getByTestId(COMBO_BOX_TOGGLE_BUTTON_TEST_ID), + }); + + expect(screen.getByTestId(VERSION_INPUT_TEST_ID)).toHaveValue('^1.2.0'); + }); + + it('shows integration version constraint corresponding to the installed package version when integration is installed', async () => { + fleetIntegrationsApi.fetchAllIntegrations.mockResolvedValue({ + integrations: [ + { + package_name: 'package-a', + package_title: 'Package A', + installed_package_version: '1.1.0', + latest_package_version: '1.2.0', + is_installed: false, + is_enabled: false, + }, + ], + }); + + render(, { wrapper: createReactQueryWrapper() }); + + await addRelatedIntegrationRow(); + await selectFirstEuiComboBoxOption({ + comboBoxToggleButton: screen.getByTestId(COMBO_BOX_TOGGLE_BUTTON_TEST_ID), + }); + + expect(screen.getByTestId(VERSION_INPUT_TEST_ID)).toHaveValue('^1.1.0'); + }); + + it('shows saved earlier related integrations', async () => { + fleetIntegrationsApi.fetchAllIntegrations.mockResolvedValue({ + integrations: [ + { + package_name: 'package-a', + package_title: 'Package A', + latest_package_version: '1.0.0', + is_installed: false, + is_enabled: false, + }, + { + package_name: 'package-b', + package_title: 'Package B', + integration_name: 'integration-a', + integration_title: 'Integration A', + latest_package_version: '1.0.0', + is_installed: false, + is_enabled: false, + }, + ], + }); + + const initialRelatedIntegrations: RelatedIntegration[] = [ + { package: 'package-a', version: '1.2.3' }, + { package: 'package-b', integration: 'integration-a', version: '3.2.1' }, + ]; + + render(, { + wrapper: createReactQueryWrapper(), + }); + + await waitForIntegrationsToBeLoaded(); + + const visibleIntegrations = screen.getAllByTestId(COMBO_BOX_SELECTION_TEST_ID); + const visibleVersionInputs = screen.getAllByTestId(VERSION_INPUT_TEST_ID); + + expect(visibleIntegrations[0]).toHaveTextContent('Package A'); + expect(visibleVersionInputs[0]).toHaveValue('1.2.3'); + + expect(visibleIntegrations[1]).toHaveTextContent('Integration A'); + expect(visibleVersionInputs[1]).toHaveValue('3.2.1'); + }); + + it('shows saved earlier related integrations when there is no matching package found', async () => { + fleetIntegrationsApi.fetchAllIntegrations.mockResolvedValue({ + integrations: [], // package-a and package-b don't exist + }); + + const initialRelatedIntegrations: RelatedIntegration[] = [ + { package: 'package-a', version: '1.2.3' }, + { package: 'package-b', integration: 'integration-a', version: '3.2.1' }, + ]; + + render(, { + wrapper: createReactQueryWrapper(), + }); + + await waitForIntegrationsToBeLoaded(); + + const visibleIntegrations = screen.getAllByTestId(COMBO_BOX_SELECTION_TEST_ID); + const visibleVersionInputs = screen.getAllByTestId(VERSION_INPUT_TEST_ID); + + expect(visibleIntegrations[0]).toHaveTextContent('Package-a'); + expect(visibleVersionInputs[0]).toHaveValue('1.2.3'); + + expect(visibleIntegrations[1]).toHaveTextContent('Package-b integration-a'); + expect(visibleVersionInputs[1]).toHaveValue('3.2.1'); + }); + + it('shows saved earlier related integrations when API failed', async () => { + // suppress expected API error messages + jest.spyOn(console, 'error').mockReturnValue(); + + fleetIntegrationsApi.fetchAllIntegrations.mockRejectedValue(new Error('some error')); + + const initialRelatedIntegrations: RelatedIntegration[] = [ + { package: 'package-a', version: '1.2.3' }, + { package: 'package-b', integration: 'integration-a', version: '3.2.1' }, + ]; + + render(, { + wrapper: createReactQueryWrapper(), + }); + + await waitForIntegrationsToBeLoaded(); + + const visibleIntegrations = screen.getAllByTestId(COMBO_BOX_SELECTION_TEST_ID); + const visibleVersionInputs = screen.getAllByTestId(VERSION_INPUT_TEST_ID); + + expect(visibleIntegrations[0]).toHaveTextContent('Package-a'); + expect(visibleVersionInputs[0]).toHaveValue('1.2.3'); + + expect(visibleIntegrations[1]).toHaveTextContent('Package-b integration-a'); + expect(visibleVersionInputs[1]).toHaveValue('3.2.1'); + }); + }); + + describe('valid form submitting', () => { + it('returns undefined when no integrations are selected', async () => { + const handleSubmit = jest.fn(); + + render(, { wrapper: createReactQueryWrapper() }); + + await submitForm(); + await waitFor(() => { + expect(handleSubmit).toHaveBeenCalled(); + }); + + expect(handleSubmit).toHaveBeenCalledWith({ + data: undefined, + isValid: true, + }); + }); + + it('returns empty integrations when submitting not filled form', async () => { + const handleSubmit = jest.fn(); + + render(, { wrapper: createReactQueryWrapper() }); + + await addRelatedIntegrationRow(); + await addRelatedIntegrationRow(); + await submitForm(); + await waitFor(() => { + expect(handleSubmit).toHaveBeenCalled(); + }); + + expect(handleSubmit).toHaveBeenCalledWith({ + data: [ + { package: '', version: '' }, + { package: '', version: '' }, + ], + isValid: true, + }); + }); + + it('returns a mix of filled and empty integrations', async () => { + const handleSubmit = jest.fn(); + + render(, { wrapper: createReactQueryWrapper() }); + + await addRelatedIntegrationRow(); + await addRelatedIntegrationRow(); + await addRelatedIntegrationRow(); + await selectFirstEuiComboBoxOption({ + comboBoxToggleButton: screen.getAllByTestId(COMBO_BOX_TOGGLE_BUTTON_TEST_ID)[1], + }); + await submitForm(); + await waitFor(() => { + expect(handleSubmit).toHaveBeenCalled(); + }); + + expect(handleSubmit).toHaveBeenCalledWith({ + data: [ + { package: '', version: '' }, + { package: 'package-a', version: '^1.0.0' }, + { package: '', version: '' }, + ], + isValid: true, + }); + }); + + it('returns an empty integration after clearing selected integration', async () => { + const handleSubmit = jest.fn(); + + render(, { wrapper: createReactQueryWrapper() }); + + await addRelatedIntegrationRow(); + await selectFirstEuiComboBoxOption({ + comboBoxToggleButton: screen.getByTestId(COMBO_BOX_TOGGLE_BUTTON_TEST_ID), + }); + await clearEuiComboBoxSelection({ + clearButton: screen.getByTestId(COMBO_BOX_CLEAR_BUTTON_TEST_ID), + }); + + await submitForm(); + await waitFor(() => { + expect(handleSubmit).toHaveBeenCalled(); + }); + + expect(handleSubmit).toHaveBeenCalledWith({ + data: [{ package: '', version: '' }], + isValid: true, + }); + }); + + it('returns a selected integration', async () => { + fleetIntegrationsApi.fetchAllIntegrations.mockResolvedValue({ + integrations: [ + { + package_name: 'package-a', + package_title: 'Package A', + latest_package_version: '1.2.0', + is_installed: false, + is_enabled: false, + }, + ], + }); + + const handleSubmit = jest.fn(); + + render(, { wrapper: createReactQueryWrapper() }); + + await addRelatedIntegrationRow(); + await selectEuiComboBoxOption({ + comboBoxToggleButton: screen.getByTestId(COMBO_BOX_TOGGLE_BUTTON_TEST_ID), + optionIndex: 0, + }); + await submitForm(); + await waitFor(() => { + expect(handleSubmit).toHaveBeenCalled(); + }); + + expect(handleSubmit).toHaveBeenCalledWith({ + data: [{ integration: undefined, package: 'package-a', version: '^1.2.0' }], + isValid: true, + }); + }); + + it('returns a selected integration with version constraint modified', async () => { + fleetIntegrationsApi.fetchAllIntegrations.mockResolvedValue({ + integrations: [ + { + package_name: 'package-a', + package_title: 'Package A', + latest_package_version: '1.2.0', + is_installed: false, + is_enabled: false, + }, + ], + }); + + const handleSubmit = jest.fn(); + + render(, { wrapper: createReactQueryWrapper() }); + + await addRelatedIntegrationRow(); + await selectEuiComboBoxOption({ + comboBoxToggleButton: screen.getByTestId(COMBO_BOX_TOGGLE_BUTTON_TEST_ID), + optionIndex: 0, + }); + await setVersion({ input: screen.getByTestId(VERSION_INPUT_TEST_ID), value: '1.0.0' }); + await submitForm(); + await waitFor(() => { + expect(handleSubmit).toHaveBeenCalled(); + }); + + expect(handleSubmit).toHaveBeenCalledWith({ + data: [{ integration: undefined, package: 'package-a', version: '1.0.0' }], + isValid: true, + }); + }); + + it('returns saved earlier integrations', async () => { + fleetIntegrationsApi.fetchAllIntegrations.mockResolvedValue({ + integrations: [ + { + package_name: 'package-a', + package_title: 'Package A', + latest_package_version: '1.0.0', + is_installed: false, + is_enabled: false, + }, + { + package_name: 'package-b', + package_title: 'Package B', + integration_name: 'integration-a', + integration_title: 'Integration A', + latest_package_version: '1.0.0', + is_installed: false, + is_enabled: false, + }, + ], + }); + + const initialRelatedIntegrations: RelatedIntegration[] = [ + { package: 'package-a', version: '1.2.3' }, + { package: 'package-b', integration: 'integration-a', version: '3.2.1' }, + ]; + const handleSubmit = jest.fn(); + + render(, { + wrapper: createReactQueryWrapper(), + }); + await submitForm(); + await waitFor(() => { + expect(handleSubmit).toHaveBeenCalled(); + }); + + expect(handleSubmit).toHaveBeenCalledWith({ + data: [ + { package: 'package-a', integration: undefined, version: '1.2.3' }, + { package: 'package-b', integration: 'integration-a', version: '3.2.1' }, + ], + isValid: true, + }); + }); + + it('returns a saved earlier integration when there is no matching package found', async () => { + fleetIntegrationsApi.fetchAllIntegrations.mockResolvedValue({ + integrations: [], + }); + + const initialRelatedIntegrations: RelatedIntegration[] = [ + { package: 'package-a', version: '1.2.3' }, + ]; + const handleSubmit = jest.fn(); + + render(, { + wrapper: createReactQueryWrapper(), + }); + await submitForm(); + await waitFor(() => { + expect(handleSubmit).toHaveBeenCalled(); + }); + + expect(handleSubmit).toHaveBeenCalledWith({ + data: [{ integration: undefined, package: 'package-a', version: '1.2.3' }], + isValid: true, + }); + }); + + it('returns a saved earlier integration when API failed', async () => { + // suppress expected API error messages + jest.spyOn(console, 'error').mockReturnValue(); + + fleetIntegrationsApi.fetchAllIntegrations.mockRejectedValue(new Error('some error')); + + const initialRelatedIntegrations: RelatedIntegration[] = [ + { package: 'package-a', version: '^1.2.3' }, + ]; + const handleSubmit = jest.fn(); + + render(, { + wrapper: createReactQueryWrapper(), + }); + await submitForm(); + await waitFor(() => { + expect(handleSubmit).toHaveBeenCalled(); + }); + + expect(handleSubmit).toHaveBeenCalledWith({ + data: [{ integration: undefined, package: 'package-a', version: '^1.2.3' }], + isValid: true, + }); + }); + }); + + describe('validation errors', () => { + it('shows an error when version constraint is invalid', async () => { + render(, { wrapper: createReactQueryWrapper() }); + + await addRelatedIntegrationRow(); + await selectFirstEuiComboBoxOption({ + comboBoxToggleButton: screen.getByTestId(COMBO_BOX_TOGGLE_BUTTON_TEST_ID), + }); + await setVersion({ input: screen.getByTestId(VERSION_INPUT_TEST_ID), value: '100' }); + + expect(screen.getByTestId(RELATED_INTEGRATION_ROW)).toHaveTextContent( + 'Version constraint is invalid' + ); + }); + }); + + describe('removing an item', () => { + describe('when there is more than one item', () => { + it('removes just added item', async () => { + render(, { wrapper: createReactQueryWrapper() }); + + await addRelatedIntegrationRow(); + await addRelatedIntegrationRow(); + await removeLastRelatedIntegrationRow(); + + expect(screen.getAllByTestId(RELATED_INTEGRATION_ROW)).toHaveLength(1); + }); + + it('removes just added item after integration has been selected', async () => { + render(, { wrapper: createReactQueryWrapper() }); + + await addRelatedIntegrationRow(); + await addRelatedIntegrationRow(); + await selectFirstEuiComboBoxOption({ + comboBoxToggleButton: screen.getAllByTestId(COMBO_BOX_TOGGLE_BUTTON_TEST_ID).at(-1)!, + }); + await removeLastRelatedIntegrationRow(); + + expect(screen.getAllByTestId(RELATED_INTEGRATION_ROW)).toHaveLength(1); + }); + + it('submits an empty integration when just added integrations removed', async () => { + const handleSubmit = jest.fn(); + + render(, { wrapper: createReactQueryWrapper() }); + + await addRelatedIntegrationRow(); + await addRelatedIntegrationRow(); + await selectFirstEuiComboBoxOption({ + comboBoxToggleButton: screen.getAllByTestId(COMBO_BOX_TOGGLE_BUTTON_TEST_ID).at(-1)!, + }); + await removeLastRelatedIntegrationRow(); + await submitForm(); + await waitFor(() => { + expect(handleSubmit).toHaveBeenCalled(); + }); + + expect(handleSubmit).toHaveBeenCalledWith({ + data: [{ package: '', version: '' }], + isValid: true, + }); + }); + }); + + describe('sticky last form row', () => { + it('does not remove the last item', async () => { + render(, { wrapper: createReactQueryWrapper() }); + + await addRelatedIntegrationRow(); + await removeLastRelatedIntegrationRow(); + + expect(screen.getAllByTestId(RELATED_INTEGRATION_ROW)).toHaveLength(1); + }); + + it('disables remove button after clicking remove button on the last item', async () => { + render(, { wrapper: createReactQueryWrapper() }); + + await addRelatedIntegrationRow(); + await removeLastRelatedIntegrationRow(); + + expect(screen.getByTestId(REMOVE_INTEGRATION_ROW_BUTTON_TEST_ID)).toBeDisabled(); + }); + + it('clears selected integration when clicking remove the last form row button', async () => { + render(, { wrapper: createReactQueryWrapper() }); + + await addRelatedIntegrationRow(); + await selectFirstEuiComboBoxOption({ + comboBoxToggleButton: getLastByTestId(COMBO_BOX_TOGGLE_BUTTON_TEST_ID), + }); + await removeLastRelatedIntegrationRow(); + + expect(screen.queryByTestId(COMBO_BOX_SELECTION_TEST_ID)).not.toBeInTheDocument(); + }); + + it('submits an empty integration after clicking remove the last form row button', async () => { + const handleSubmit = jest.fn(); + + render(, { wrapper: createReactQueryWrapper() }); + + await addRelatedIntegrationRow(); + await selectFirstEuiComboBoxOption({ + comboBoxToggleButton: getLastByTestId(COMBO_BOX_TOGGLE_BUTTON_TEST_ID), + }); + await removeLastRelatedIntegrationRow(); + await submitForm(); + await waitFor(() => { + expect(handleSubmit).toHaveBeenCalled(); + }); + + expect(handleSubmit).toHaveBeenCalledWith({ + data: [{ package: '', version: '' }], + isValid: true, + }); + }); + + it('submits an empty integration after previously saved integrations were removed', async () => { + const initialRelatedIntegrations: RelatedIntegration[] = [ + { package: 'package-a', version: '^1.2.3' }, + ]; + const handleSubmit = jest.fn(); + + render(, { + wrapper: createReactQueryWrapper(), + }); + + await waitForIntegrationsToBeLoaded(); + await removeLastRelatedIntegrationRow(); + await submitForm(); + await waitFor(() => { + expect(handleSubmit).toHaveBeenCalled(); + }); + + expect(handleSubmit).toHaveBeenCalledWith({ + data: [{ package: '', version: '' }], + isValid: true, + }); + }); + }); + }); +}); + +interface TestFormProps { + initialState?: RelatedIntegration[]; + onSubmit?: (args: { data: RelatedIntegration[]; isValid: boolean }) => void; +} + +function TestForm({ initialState, onSubmit }: TestFormProps): JSX.Element { + const { form } = useForm({ + options: { stripEmptyFields: false }, + schema: { + relatedIntegrationsField: { + type: FIELD_TYPES.JSON, + }, + }, + defaultValue: { + relatedIntegrationsField: initialState, + }, + onSubmit: async (formData, isValid) => + onSubmit?.({ data: formData.relatedIntegrationsField, isValid }), + }); + + return ( +
+ + + + ); +} + +function getLastByTestId(testId: string): HTMLElement { + // getAllByTestId throws an error when there are no `testId` elements found + return screen.getAllByTestId(testId).at(-1)!; +} + +function waitForIntegrationsToBeLoaded(): Promise { + return waitForElementToBeRemoved(screen.queryAllByRole('progressbar')); +} + +function addRelatedIntegrationRow(): Promise { + return act(async () => { + fireEvent.click(screen.getByText('Add integration')); + }); +} + +function removeLastRelatedIntegrationRow(): Promise { + return act(async () => { + const lastRemoveButton = screen.getAllByTestId(REMOVE_INTEGRATION_ROW_BUTTON_TEST_ID).at(-1); + + if (!lastRemoveButton) { + throw new Error(`There are no "${REMOVE_INTEGRATION_ROW_BUTTON_TEST_ID}" found`); + } + + fireEvent.click(lastRemoveButton); + }); +} + +function showEuiComboBoxOptions(comboBoxToggleButton: HTMLElement): Promise { + fireEvent.click(comboBoxToggleButton); + + return waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); +} + +function selectEuiComboBoxOption({ + comboBoxToggleButton, + optionIndex, +}: { + comboBoxToggleButton: HTMLElement; + optionIndex: number; +}): Promise { + return act(async () => { + await showEuiComboBoxOptions(comboBoxToggleButton); + + fireEvent.click(screen.getAllByRole('option')[optionIndex]); + }); +} + +function clearEuiComboBoxSelection({ clearButton }: { clearButton: HTMLElement }): Promise { + return act(async () => { + fireEvent.click(clearButton); + }); +} + +function selectFirstEuiComboBoxOption({ + comboBoxToggleButton, +}: { + comboBoxToggleButton: HTMLElement; +}): Promise { + return selectEuiComboBoxOption({ comboBoxToggleButton, optionIndex: 0 }); +} + +function setVersion({ input, value }: { input: HTMLInputElement; value: string }): Promise { + return act(async () => { + fireEvent.input(input, { + target: { value }, + }); + }); +} + +function submitForm(): Promise { + return act(async () => { + fireEvent.click(screen.getByText('Submit')); + }); +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integrations.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integrations.tsx new file mode 100644 index 0000000000000..a57ce5fe8cd7b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integrations.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { UseArray, useFormData } from '../../../../shared_imports'; +import { RelatedIntegrationsHelpInfo } from './related_integrations_help_info'; +import { RelatedIntegrationFieldRow } from './related_integration_field_row'; +import * as i18n from './translations'; + +interface RelatedIntegrationsProps { + path: string; + dataTestSubj?: string; +} + +export function RelatedIntegrations({ path, dataTestSubj }: RelatedIntegrationsProps): JSX.Element { + const label = ( + <> + {i18n.RELATED_INTEGRATIONS_LABEL} + + + ); + const [formData] = useFormData(); + + return ( + + {({ items, addItem, removeItem }) => ( + + {i18n.OPTIONAL} + + } + labelType="legend" + fullWidth + data-test-subj={dataTestSubj} + hasChildLabel={false} + > + <> + + {items.map((item) => ( + + + + ))} + + {items.length > 0 && } + + {i18n.ADD_INTEGRATION} + + + + )} + + ); +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integrations_help_info.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integrations_help_info.tsx new file mode 100644 index 0000000000000..b694d17a80435 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integrations_help_info.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { useToggle } from 'react-use'; +import { EuiLink, EuiPopover, EuiText, EuiButtonIcon } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useKibana } from '../../../../common/lib/kibana'; + +/** + * Theme doesn't expose width variables. Using provided size variables will require + * multiplying it by another magic constant. + * + * 320px width looks + * like a [commonly used width in EUI](https://github.com/search?q=repo%3Aelastic%2Feui%20320&type=code). + */ +const POPOVER_WIDTH = 320; + +export function RelatedIntegrationsHelpInfo(): JSX.Element { + const [isPopoverOpen, togglePopover] = useToggle(false); + const { docLinks } = useKibana().services; + + const button = ( + + ); + + return ( + + + + + + ), + semverLink: ( + + + + ), + }} + /> + + + ); +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/translations.ts new file mode 100644 index 0000000000000..2645298783f5c --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/translations.ts @@ -0,0 +1,136 @@ +/* + * 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'; + +export const RELATED_INTEGRATIONS_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.relatedIntegrations.fieldRelatedIntegrationsLabel', + { + defaultMessage: 'Related integrations', + } +); + +export const OPTIONAL = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.relatedIntegrations.optionalText', + { + defaultMessage: 'Optional', + } +); + +export const RELATED_INTEGRATION_FIELDS_HELP_TEXT = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.relatedIntegrations.helpText', + { + defaultMessage: 'Select an integration and correct a version constraint if necessary.', + } +); + +export const RELATED_INTEGRATION_ARIA_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.relatedIntegrations.relatedIntegrationAriaLabel', + { + defaultMessage: 'Integrations selector', + } +); + +export const RELATED_INTEGRATION_VERSION_DEPENDENCY_ARIA_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.relatedIntegrations.relatedIntegrationVersionDependencyAriaLabel', + { + defaultMessage: 'Related integration version constraint', + } +); + +export const RELATED_INTEGRATION_VERSION_DEPENDENCY_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.relatedIntegrations.relatedIntegrationVersionDependencyPlaceholder', + { + defaultMessage: 'Semver', + } +); + +export const REMOVE_RELATED_INTEGRATION_BUTTON_ARIA_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.relatedIntegrations.removeRelatedIntegrationButtonAriaLabel', + { + defaultMessage: 'Remove related integration', + } +); + +export const ADD_INTEGRATION = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.relatedIntegrations.addIntegration', + { + defaultMessage: 'Add integration', + } +); + +export const INTEGRATION_VERSION = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.relatedIntegrations.integrationVersion', + { + defaultMessage: 'Version', + } +); + +export const INTEGRATION_REQUIRED = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.relatedIntegrations.validation.integrationRequired', + { + defaultMessage: 'Integration must be selected', + } +); + +export const VERSION_DEPENDENCY_REQUIRED = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.relatedIntegrations.validation.versionRequired', + { + defaultMessage: 'Version constraint must be specified', + } +); + +export const VERSION_DEPENDENCY_INVALID = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.relatedIntegrations.validation.versionInvalid', + { + defaultMessage: + 'Version constraint is invalid. Only tilde, caret or plain version supported e.g. ~1.2.3, ^1.2.3 or 1.2.3.', + } +); + +export const INTEGRATION_NOT_INSTALLED = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.relatedIntegrations.notInstalledText', + { + defaultMessage: 'Not installed', + } +); + +export const INTEGRATION_INSTALLED_AND_DISABLED = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.relatedIntegrations.installedDisabledText', + { + defaultMessage: 'Installed: Disabled', + } +); + +export const INTEGRATION_INSTALLED_AND_ENABLED = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.relatedIntegrations.installedEnabledText', + { + defaultMessage: 'Installed: Enabled', + } +); + +export const INTEGRATION_DISABLED = (integrationTitle: string) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.relatedIntegrations.integrationDisabledText', + { + defaultMessage: '{integrationTitle}: Disabled', + values: { + integrationTitle, + }, + } + ); + +export const INTEGRATION_ENABLED = (integrationTitle: string) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.relatedIntegrations.integrationEnabledText', + { + defaultMessage: '{integrationTitle}: Enabled', + values: { + integrationTitle, + }, + } + ); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/validate_related_integration.test.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/validate_related_integration.test.ts new file mode 100644 index 0000000000000..d991101cfce4b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/validate_related_integration.test.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RelatedIntegration } from '../../../../../common/api/detection_engine'; +import type { ValidationFuncArg } from '../../../../shared_imports'; +import { validateRelatedIntegration } from './validate_related_integration'; + +describe('validateRelatedIntegration', () => { + describe('with successful outcome', () => { + it.each([ + ['simple package version dependency', { package: 'some-package', version: '1.2.3' }], + ['caret package version dependency', { package: 'some-package', version: '^1.2.3' }], + ['tilde package version dependency', { package: 'some-package', version: '~1.2.3' }], + ])(`validates %s`, (_, relatedIntegration) => { + const arg = { + value: relatedIntegration, + } as ValidationFuncArg; + + const result = validateRelatedIntegration(arg); + + expect(result).toBeUndefined(); + }); + + it('validates empty package as a valid related integration', () => { + const relatedIntegration = { package: '', version: '1.2.3' }; + const arg = { + value: relatedIntegration, + path: 'form.path.to.field', + } as ValidationFuncArg; + + const result = validateRelatedIntegration(arg); + + expect(result).toBeUndefined(); + }); + + it('ignores version when package is empty', () => { + const relatedIntegration = { package: '', version: 'invalid' }; + const arg = { + value: relatedIntegration, + path: 'form.path.to.field', + } as ValidationFuncArg; + + const result = validateRelatedIntegration(arg); + + expect(result).toBeUndefined(); + }); + }); + + describe('with unsuccessful outcome', () => { + it('validates empty version', () => { + const relatedIntegration = { package: 'some-package', version: '' }; + const arg = { + value: relatedIntegration, + path: 'form.path.to.field', + } as ValidationFuncArg; + + const result = validateRelatedIntegration(arg); + + expect(result).toMatchObject({ + code: 'ERR_FIELD_MISSING', + path: 'form.path.to.field.version', + }); + }); + + it('validates version with white spaces', () => { + const relatedIntegration = { package: 'some-package', version: ' ' }; + const arg = { + value: relatedIntegration, + path: 'form.path.to.field', + } as ValidationFuncArg; + + const result = validateRelatedIntegration(arg); + + expect(result).toMatchObject({ + code: 'ERR_FIELD_MISSING', + path: 'form.path.to.field.version', + }); + }); + + it.each([ + ['invalid format version', { package: 'some-package', version: '^1.2.' }], + ['unexpected version spaces', { package: 'some-package', version: ' ~ 1.2.3' }], + ])(`validates %s`, (_, relatedIntegration) => { + const arg = { + value: relatedIntegration, + path: 'form.path.to.field', + } as ValidationFuncArg; + + const result = validateRelatedIntegration(arg); + + expect(result).toMatchObject({ + code: 'ERR_FIELD_FORMAT', + path: 'form.path.to.field.version', + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/validate_related_integration.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/validate_related_integration.ts new file mode 100644 index 0000000000000..3cbf2eff33b3d --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/validate_related_integration.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RelatedIntegration } from '../../../../../common/api/detection_engine'; +import type { FormData, ERROR_CODE, ValidationFunc } from '../../../../shared_imports'; +import * as i18n from './translations'; + +export function validateRelatedIntegration( + ...args: Parameters> +): ReturnType> | undefined { + const [{ value, path }] = args; + + // It allows to submit empty fields for better UX + // When integration isn't selected version shouldn't be validated + if (value.package.trim().length === 0) { + return; + } + + if (value.version.trim().length === 0) { + return { + code: 'ERR_FIELD_MISSING', + path: `${path}.version`, + message: i18n.VERSION_DEPENDENCY_REQUIRED, + }; + } + + if (!SEMVER_PATTERN.test(value.version)) { + return { + code: 'ERR_FIELD_FORMAT', + path: `${path}.version`, + message: i18n.VERSION_DEPENDENCY_INVALID, + }; + } +} + +const SEMVER_PATTERN = /^(\~|\^)?\d+\.\d+\.\d+$/; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.test.tsx index 22e43dc31acb8..de34718ef050f 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.test.tsx @@ -5,26 +5,22 @@ * 2.0. */ -import React from 'react'; -import { mount } from 'enzyme'; - -import type { Type } from '@kbn/securitysolution-io-ts-alerting-types'; - +import React, { useEffect, useState } from 'react'; +import { screen, fireEvent, render, within, act, waitFor } from '@testing-library/react'; +import type { Type as RuleType } from '@kbn/securitysolution-io-ts-alerting-types'; import { StepDefineRule, aggregatableFields } from '.'; import { mockBrowserFields } from '../../../../common/containers/source/mock'; import { useRuleFromTimeline } from '../../../../detections/containers/detection_engine/rules/use_rule_from_timeline'; -import { fireEvent, render, within } from '@testing-library/react'; import { TestProviders } from '../../../../common/mock'; -import { useRuleForms } from '../../pages/form'; -import { stepActionsDefaultValue } from '../../../rule_creation/components/step_rule_actions'; -import { - defaultSchedule, - stepAboutDefaultValue, - stepDefineDefaultValue, -} from '../../../../detections/pages/detection_engine/rules/utils'; -import type { FormHook } from '../../../../shared_imports'; +import { schema as defineRuleSchema } from './schema'; +import { stepDefineDefaultValue } from '../../../../detections/pages/detection_engine/rules/utils'; +import type { FormSubmitHandler } from '../../../../shared_imports'; +import { useForm } from '../../../../shared_imports'; import type { DefineStepRule } from '../../../../detections/pages/detection_engine/rules/types'; +import { fleetIntegrationsApi } from '../../../fleet_integrations/api/__mocks__'; +// Mocks integrations +jest.mock('../../../fleet_integrations/api'); jest.mock('../../../../common/components/query_bar', () => { return { QueryBar: jest.fn(({ filterQuery }) => { @@ -279,37 +275,261 @@ test('aggregatableFields with aggregatable: true', function () { const mockUseRuleFromTimeline = useRuleFromTimeline as jest.Mock; const onOpenTimeline = jest.fn(); + +const COMBO_BOX_TOGGLE_BUTTON_TEST_ID = 'comboBoxToggleListButton'; +const VERSION_INPUT_TEST_ID = 'relatedIntegrationVersionDependency'; + describe('StepDefineRule', () => { - const TestComp = ({ - setFormRef, - ruleType = stepDefineDefaultValue.ruleType, - }: { - setFormRef: (form: FormHook) => void; - ruleType?: Type; - }) => { - const { defineStepForm, eqlOptionsSelected, setEqlOptionsSelected } = useRuleForms({ - defineStepDefault: { ...stepDefineDefaultValue, ruleType }, - aboutStepDefault: stepAboutDefaultValue, - scheduleStepDefault: defaultSchedule, - actionsStepDefault: stepActionsDefaultValue, + beforeEach(() => { + jest.clearAllMocks(); + mockUseRuleFromTimeline.mockReturnValue({ onOpenTimeline, loading: false }); + }); + + it('renders correctly', () => { + render(, { + wrapper: TestProviders, + }); + + expect(screen.getByTestId('stepDefineRule')).toBeDefined(); + }); + + describe('related integrations', () => { + beforeEach(() => { + fleetIntegrationsApi.fetchAllIntegrations.mockResolvedValue({ + integrations: [ + { + package_name: 'package-a', + package_title: 'Package A', + latest_package_version: '1.0.0', + is_installed: false, + is_enabled: false, + }, + ], + }); + }); + + it('submits form without selected related integrations', async () => { + const initialState = { + index: ['test-index'], + queryBar: { + query: { query: '*:*', language: 'kuery' }, + filters: [], + saved_id: null, + }, + }; + const handleSubmit = jest.fn(); + + render(, { + wrapper: TestProviders, + }); + + await submitForm(); + await waitFor(() => { + expect(handleSubmit).toHaveBeenCalled(); + }); + + expect(handleSubmit).toHaveBeenCalledWith( + expect.not.objectContaining({ + relatedIntegrations: expect.anything(), + }), + true + ); + }); + + it('submits saved early related integrations', async () => { + const initialState = { + index: ['test-index'], + queryBar: { + query: { query: '*:*', language: 'kuery' }, + filters: [], + saved_id: null, + }, + relatedIntegrations: [ + { package: 'package-a', version: '1.2.3' }, + { package: 'package-b', integration: 'integration-a', version: '3.2.1' }, + ], + }; + const handleSubmit = jest.fn(); + + render(, { + wrapper: TestProviders, + }); + + await submitForm(); + await waitFor(() => { + expect(handleSubmit).toHaveBeenCalled(); + }); + + expect(handleSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + relatedIntegrations: [ + { package: 'package-a', version: '1.2.3' }, + { package: 'package-b', integration: 'integration-a', version: '3.2.1' }, + ], + }), + true + ); + }); + + it('submits a selected related integration', async () => { + const initialState = { + index: ['test-index'], + queryBar: { + query: { query: '*:*', language: 'kuery' }, + filters: [], + saved_id: null, + }, + relatedIntegrations: undefined, + }; + const handleSubmit = jest.fn(); + + render(, { + wrapper: TestProviders, + }); + + await addRelatedIntegrationRow(); + await selectEuiComboBoxOption({ + comboBoxToggleButton: within(screen.getByTestId('relatedIntegrations')).getByTestId( + COMBO_BOX_TOGGLE_BUTTON_TEST_ID + ), + optionIndex: 0, + }); + await setVersion({ input: screen.getByTestId(VERSION_INPUT_TEST_ID), value: '1.2.3' }); + + await submitForm(); + await waitFor(() => { + expect(handleSubmit).toHaveBeenCalled(); + }); + + expect(handleSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + relatedIntegrations: [{ package: 'package-a', version: '1.2.3' }], + }), + true + ); + }); + }); + + describe('handleSetRuleFromTimeline', () => { + it('updates KQL query correctly', () => { + const kqlQuery = { + index: ['.alerts-security.alerts-default', 'logs-*', 'packetbeat-*'], + queryBar: { + filters: [], + query: { + query: 'host.name:*', + language: 'kuery', + }, + saved_id: null, + }, + }; + + mockUseRuleFromTimeline.mockImplementation((handleSetRuleFromTimeline) => { + useEffect(() => { + handleSetRuleFromTimeline(kqlQuery); + }, [handleSetRuleFromTimeline]); + + return { onOpenTimeline, loading: false }; + }); + + render(, { + wrapper: TestProviders, + }); + + expect(screen.getAllByTestId('query-bar')[0].textContent).toBe( + `${kqlQuery.queryBar.query.query} ${kqlQuery.queryBar.query.language}` + ); }); - setFormRef(defineStepForm); + it('updates EQL query correctly', async () => { + const eqlQuery = { + index: ['.alerts-security.alerts-default', 'logs-*', 'packetbeat-*'], + queryBar: { + filters: [], + query: { + query: 'process where true', + language: 'eql', + }, + saved_id: null, + }, + eqlOptions: { + eventCategoryField: 'cool.field', + tiebreakerField: 'another.field', + timestampField: 'cool.@timestamp', + query: 'process where true', + size: 77, + }, + }; + + mockUseRuleFromTimeline.mockImplementation((handleSetRuleFromTimeline) => { + useEffect(() => { + handleSetRuleFromTimeline(eqlQuery); + }, [handleSetRuleFromTimeline]); + + return { onOpenTimeline, loading: false }; + }); - return ( + render(, { + wrapper: TestProviders, + }); + + expect(screen.getByTestId(`eqlQueryBarTextInput`).textContent).toEqual( + eqlQuery.queryBar.query.query + ); + + await act(async () => { + fireEvent.click(screen.getByTestId('eql-settings-trigger')); + }); + + expect( + within(screen.getByTestId('eql-event-category-field')).queryByRole('combobox') + ).toHaveValue(eqlQuery.eqlOptions.eventCategoryField); + + expect( + within(screen.getByTestId('eql-tiebreaker-field')).queryByRole('combobox') + ).toHaveValue(eqlQuery.eqlOptions.tiebreakerField); + + expect(within(screen.getByTestId('eql-timestamp-field')).queryByRole('combobox')).toHaveValue( + eqlQuery.eqlOptions.timestampField + ); + }); + }); +}); + +interface TestFormProps { + ruleType?: RuleType; + initialState?: Partial; + onSubmit?: FormSubmitHandler; +} + +function TestForm({ + ruleType = stepDefineDefaultValue.ruleType, + initialState, + onSubmit, +}: TestFormProps): JSX.Element { + const [selectedEqlOptions, setSelectedEqlOptions] = useState(stepDefineDefaultValue.eqlOptions); + const { form } = useForm({ + options: { stripEmptyFields: false }, + schema: defineRuleSchema, + defaultValue: { ...stepDefineDefaultValue, ...initialState }, + onSubmit, + }); + + return ( + <> {}} - setIsThreatQueryBarValid={() => {}} + setIsQueryBarValid={jest.fn()} + setIsThreatQueryBarValid={jest.fn()} ruleType={ruleType} index={stepDefineDefaultValue.index} threatIndex={stepDefineDefaultValue.threatIndex} @@ -321,87 +541,51 @@ describe('StepDefineRule', () => { thresholdFields={[]} enableThresholdSuppression={false} /> - ); - }; - beforeEach(() => { - jest.clearAllMocks(); - mockUseRuleFromTimeline.mockReturnValue({ onOpenTimeline, loading: false }); + + + ); +} + +function submitForm(): Promise { + return act(async () => { + fireEvent.click(screen.getByText('Submit')); }); - it('renders correctly', () => { - const wrapper = mount( {}} />, { - wrappingComponent: TestProviders, - }); +} - expect(wrapper.find('Form[data-test-subj="stepDefineRule"]')).toHaveLength(1); +function addRelatedIntegrationRow(): Promise { + return act(async () => { + fireEvent.click(screen.getByText('Add integration')); }); +} - const kqlQuery = { - index: ['.alerts-security.alerts-default', 'logs-*', 'packetbeat-*'], - queryBar: { - filters: [], - query: { - query: 'host.name:*', - language: 'kuery', - }, - saved_id: null, - }, - }; +function showEuiComboBoxOptions(comboBoxToggleButton: HTMLElement): Promise { + fireEvent.click(comboBoxToggleButton); - const eqlQuery = { - index: ['.alerts-security.alerts-default', 'logs-*', 'packetbeat-*'], - queryBar: { - filters: [], - query: { - query: 'process where true', - language: 'eql', - }, - saved_id: null, - }, - eqlOptions: { - eventCategoryField: 'cool.field', - tiebreakerField: 'another.field', - timestampField: 'cool.@timestamp', - query: 'process where true', - size: 77, - }, - }; - it('handleSetRuleFromTimeline correctly updates the query', () => { - mockUseRuleFromTimeline.mockImplementation((handleSetRuleFromTimeline) => { - handleSetRuleFromTimeline(kqlQuery); - return { onOpenTimeline, loading: false }; - }); - const { getAllByTestId } = render( - - {}} /> - - ); - expect(getAllByTestId('query-bar')[0].textContent).toEqual( - `${kqlQuery.queryBar.query.query} ${kqlQuery.queryBar.query.language}` - ); + return waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); }); - it('handleSetRuleFromTimeline correctly updates eql query', async () => { - mockUseRuleFromTimeline - .mockImplementationOnce(() => ({ onOpenTimeline, loading: false })) - .mockImplementationOnce((handleSetRuleFromTimeline) => { - handleSetRuleFromTimeline(eqlQuery); - return { onOpenTimeline, loading: false }; - }); - const { getByTestId } = render( - - {}} ruleType="eql" /> - - ); - expect(getByTestId(`eqlQueryBarTextInput`).textContent).toEqual(eqlQuery.queryBar.query.query); - fireEvent.click(getByTestId(`eql-settings-trigger`)); - - expect(within(getByTestId(`eql-event-category-field`)).queryByRole('combobox')).toHaveValue( - eqlQuery.eqlOptions.eventCategoryField - ); - expect(within(getByTestId(`eql-tiebreaker-field`)).queryByRole('combobox')).toHaveValue( - eqlQuery.eqlOptions.tiebreakerField - ); - expect(within(getByTestId(`eql-timestamp-field`)).queryByRole('combobox')).toHaveValue( - eqlQuery.eqlOptions.timestampField - ); +} + +function selectEuiComboBoxOption({ + comboBoxToggleButton, + optionIndex, +}: { + comboBoxToggleButton: HTMLElement; + optionIndex: number; +}): Promise { + return act(async () => { + await showEuiComboBoxOptions(comboBoxToggleButton); + + fireEvent.click(within(screen.getByRole('listbox')).getAllByRole('option')[optionIndex]); }); -}); +} + +function setVersion({ input, value }: { input: HTMLInputElement; value: string }): Promise { + return act(async () => { + fireEvent.input(input, { + target: { value }, + }); + }); +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.tsx index 987274ee5488e..317deb9479738 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.tsx @@ -99,6 +99,7 @@ import { DurationInput } from '../duration_input'; import { MINIMUM_LICENSE_FOR_SUPPRESSION } from '../../../../../common/detection_engine/constants'; import { useUpsellingMessage } from '../../../../common/hooks/use_upselling'; import { useAlertSuppression } from '../../../rule_management/logic/use_alert_suppression'; +import { RelatedIntegrations } from '../../../rule_creation/components/related_integrations'; const CommonUseField = getUseField({ component: Field }); @@ -1114,6 +1115,9 @@ const StepDefineRuleComponent: FC = ({ + + + = { ], }, relatedIntegrations: { - label: i18n.translate( - 'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRelatedIntegrationsLabel', - { - defaultMessage: 'Related integrations', - } - ), - helpText: i18n.translate( - 'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRelatedIntegrationsHelpText', - { - defaultMessage: 'Integration related to this Rule.', - } - ), + type: FIELD_TYPES.JSON, }, requiredFields: { label: i18n.translate( diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.test.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.test.ts index 40da9e9a204a4..e00e18ed7f6ea 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.test.ts @@ -129,11 +129,55 @@ describe('helpers', () => { type: 'query', timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', timeline_title: 'Titled timeline', + related_integrations: [ + { + package: 'aws', + integration: 'route53', + version: '~1.2.3', + }, + { + package: 'system', + version: '^1.2.3', + }, + ], }; expect(result).toEqual(expected); }); + test('filters out empty related integrations', () => { + const result = formatDefineStepData({ + ...mockData, + relatedIntegrations: [ + { package: '', version: '' }, + { + package: 'aws', + integration: 'route53', + version: '~1.2.3', + }, + { package: '', version: '' }, + { + package: 'system', + version: '^1.2.3', + }, + ], + }); + + expect(result).toMatchObject({ + related_integrations: [ + { + package: 'aws', + integration: 'route53', + version: '~1.2.3', + }, + { + package: 'system', + version: '^1.2.3', + }, + ], + }); + }); + describe('saved_query and query rule types', () => { test('returns query rule if savedId provided but shouldLoadQueryDynamically != true', () => { const mockStepData: DefineStepRule = { @@ -308,6 +352,17 @@ describe('helpers', () => { machine_learning_job_id: ['some_jobert_id'], timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', timeline_title: 'Titled timeline', + related_integrations: [ + { + package: 'aws', + integration: 'route53', + version: '~1.2.3', + }, + { + package: 'system', + version: '^1.2.3', + }, + ], }; expect(result).toEqual(expected); @@ -501,6 +556,17 @@ describe('helpers', () => { threat_index: mockStepData.threatIndex, index: mockStepData.index, threat_filters: threatFilters, + related_integrations: [ + { + package: 'aws', + integration: 'route53', + version: '~1.2.3', + }, + { + package: 'system', + version: '^1.2.3', + }, + ], }; expect(result).toEqual(expected); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.ts index 81be2839c0029..18f23824b77a7 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.ts @@ -403,6 +403,7 @@ export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStep const baseFields = { type: ruleType, + related_integrations: defineStepData.relatedIntegrations?.filter((ri) => !isEmpty(ri.package)), ...(timeline.id != null && timeline.title != null && { timeline_id: timeline.id, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx index ff96fd64f027f..807dc4b04f1b3 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx @@ -397,6 +397,7 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => { const aboutStepFormValid = await aboutStepForm.validate(); const scheduleStepFormValid = await scheduleStepForm.validate(); const actionsStepFormValid = await actionsStepForm.validate(); + if ( defineStepFormValid && aboutStepFormValid && diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/__mocks__/mock.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/__mocks__/mock.ts index 49bd1649c3471..80b0d3eedc8b7 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/__mocks__/mock.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/__mocks__/mock.ts @@ -216,7 +216,17 @@ export const mockDefineStepRule = (): DefineStepRule => ({ queryBar: mockQueryBar, threatQueryBar: mockQueryBar, requiredFields: [], - relatedIntegrations: [], + relatedIntegrations: [ + { + package: 'aws', + integration: 'route53', + version: '~1.2.3', + }, + { + package: 'system', + version: '^1.2.3', + }, + ], threatMapping: [], timeline: { id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_monitoring/components/execution_events_table/use_execution_events.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_monitoring/components/execution_events_table/use_execution_events.test.tsx index 09caca91b30f3..f0480cbef8f3b 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_monitoring/components/execution_events_table/use_execution_events.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_monitoring/components/execution_events_table/use_execution_events.test.tsx @@ -5,9 +5,6 @@ * 2.0. */ -import type { FC, PropsWithChildren } from 'react'; -import React from 'react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { renderHook, cleanup } from '@testing-library/react-hooks'; import { @@ -18,6 +15,7 @@ import { import { useExecutionEvents } from './use_execution_events'; import { useToasts } from '../../../../common/lib/kibana'; import { api } from '../../api'; +import { createReactQueryWrapper } from '../../../../common/mock'; jest.mock('../../../../common/lib/kibana'); jest.mock('../../api'); @@ -33,21 +31,6 @@ describe('useExecutionEvents', () => { cleanup(); }); - const createReactQueryWrapper = () => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - // Turn retries off, otherwise we won't be able to test errors - retry: false, - }, - }, - }); - const wrapper: FC> = ({ children }) => ( - {children} - ); - return wrapper; - }; - const render = () => renderHook(() => useExecutionEvents({ ruleId: SOME_RULE_ID }), { wrapper: createReactQueryWrapper(), diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_monitoring/components/execution_results_table/use_execution_results.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_monitoring/components/execution_results_table/use_execution_results.test.tsx index 45c7eaca3599e..65d7ea7c3cda7 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_monitoring/components/execution_results_table/use_execution_results.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_monitoring/components/execution_results_table/use_execution_results.test.tsx @@ -5,14 +5,12 @@ * 2.0. */ -import type { FC, PropsWithChildren } from 'react'; -import React from 'react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { renderHook, cleanup } from '@testing-library/react-hooks'; import { useExecutionResults } from './use_execution_results'; import { useToasts } from '../../../../common/lib/kibana'; import { api } from '../../api'; +import { createReactQueryWrapper } from '../../../../common/mock'; jest.mock('../../../../common/lib/kibana'); jest.mock('../../api'); @@ -28,21 +26,6 @@ describe('useExecutionResults', () => { cleanup(); }); - const createReactQueryWrapper = () => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - // Turn retries off, otherwise we won't be able to test errors - retry: false, - }, - }, - }); - const wrapper: FC> = ({ children }) => ( - {children} - ); - return wrapper; - }; - const render = () => renderHook( () => diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integration_details.test.ts b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integration_details.test.ts index 54f6f9e1d24f8..30d656c3bf79c 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integration_details.test.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integration_details.test.ts @@ -77,15 +77,19 @@ describe('Integration Details', () => { { package_name: 'aws', package_title: 'AWS', - package_version: '1.3.0', + latest_package_version: '1.3.0', + installed_package_version: '1.3.0', integration_name: 'route53', integration_title: 'AWS Route 53', + is_installed: true, is_enabled: false, }, { package_name: 'system', package_title: 'System', - package_version: '1.2.5', + latest_package_version: '1.2.5', + installed_package_version: '1.2.5', + is_installed: true, is_enabled: true, }, ] @@ -121,15 +125,19 @@ describe('Integration Details', () => { { package_name: 'aws', package_title: 'AWS', - package_version: '1.2.0', + latest_package_version: '1.2.0', + installed_package_version: '1.2.0', integration_name: 'route53', integration_title: 'AWS Route 53', + is_installed: true, is_enabled: false, }, { package_name: 'system', package_title: 'System', - package_version: '1.2.2', + latest_package_version: '1.2.2', + installed_package_version: '1.2.2', + is_installed: true, is_enabled: true, }, ] @@ -156,15 +164,19 @@ describe('Integration Details', () => { { package_name: 'aws', package_title: 'AWS', - package_version: '2.0.1', + latest_package_version: '2.0.1', + installed_package_version: '2.0.1', integration_name: 'route53', integration_title: 'AWS Route 53', + is_installed: true, is_enabled: false, }, { package_name: 'system', package_title: 'System', - package_version: '1.3.0', + latest_package_version: '1.3.0', + installed_package_version: '1.3.0', + is_installed: true, is_enabled: true, }, ] diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integration_details.ts b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integration_details.ts index 2dfde8348f2f7..e8537931bae52 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integration_details.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integration_details.ts @@ -8,10 +8,7 @@ import { capitalize } from 'lodash'; import semver from 'semver'; -import type { - InstalledIntegration, - InstalledIntegrationArray, -} from '../../../../../common/api/detection_engine/fleet_integrations'; +import type { Integration } from '../../../../../common/api/detection_engine/fleet_integrations'; import type { RelatedIntegration, RelatedIntegrationArray, @@ -42,57 +39,60 @@ export interface UnknownInstallationStatus { } /** - * Given an array of integrations and an array of installed integrations this will return an - * array of integrations augmented with install details like targetVersion, and `version_satisfied` + * Given an array of integrations and an array of all known integrations this will return an + * array of integrations augmented with details like targetVersion, and `version_satisfied` * has. */ export const calculateIntegrationDetails = ( relatedIntegrations: RelatedIntegrationArray, - installedIntegrations: InstalledIntegrationArray | undefined + knownIntegrations: Integration[] | undefined ): IntegrationDetails[] => { - const integrationMatches = findIntegrationMatches(relatedIntegrations, installedIntegrations); - const integrationDetails = integrationMatches.map((integration) => { - return createIntegrationDetails(integration); - }); + const integrationMatches = findIntegrationMatches(relatedIntegrations, knownIntegrations); + const integrationDetails = integrationMatches.map((integration) => + createIntegrationDetails(integration) + ); - return integrationDetails.sort((a, b) => { - return a.integrationTitle.localeCompare(b.integrationTitle); - }); + return integrationDetails.sort((a, b) => a.integrationTitle.localeCompare(b.integrationTitle)); }; interface IntegrationMatch { related: RelatedIntegration; - installed: InstalledIntegration | null; + found?: Integration; isLoaded: boolean; } const findIntegrationMatches = ( relatedIntegrations: RelatedIntegrationArray, - installedIntegrations: InstalledIntegrationArray | undefined + integrations: Integration[] | undefined ): IntegrationMatch[] => { + const integrationsMap = new Map( + (integrations ?? []).map((integration) => [ + `${integration.package_name}${integration.integration_name ?? ''}`, + integration, + ]) + ); + return relatedIntegrations.map((ri: RelatedIntegration) => { - if (installedIntegrations == null) { + const key = `${ri.package}${ri.integration ?? ''}`; + const matchIntegration = integrationsMap.get(key); + + if (!matchIntegration) { return { related: ri, - installed: null, isLoaded: false, }; - } else { - const match = installedIntegrations.find( - (ii: InstalledIntegration) => - ii.package_name === ri.package && ii?.integration_name === ri?.integration - ); - return { - related: ri, - installed: match ?? null, - isLoaded: true, - }; } + + return { + related: ri, + found: matchIntegration, + isLoaded: true, + }; }); }; const createIntegrationDetails = (integration: IntegrationMatch): IntegrationDetails => { - const { related, installed, isLoaded } = integration; + const { related, found, isLoaded } = integration; const packageName = related.package; const integrationName = related.integration ?? null; @@ -117,8 +117,7 @@ const createIntegrationDetails = (integration: IntegrationMatch): IntegrationDet }; } - // We know that the integration is not installed - if (installed == null) { + if (!found) { const integrationTitle = getCapitalizedTitle(packageName, integrationName); const targetVersion = getMinimumConcreteVersionMatchingSemver(requiredVersion); const targetUrl = buildTargetUrl(packageName, integrationName, targetVersion); @@ -140,35 +139,34 @@ const createIntegrationDetails = (integration: IntegrationMatch): IntegrationDet }; } - // We know that the integration is installed - { - const integrationTitle = installed.integration_title ?? installed.package_title; - - // Version check e.g. installed version `1.2.3` satisfies required version `~1.2.1` - const installedVersion = installed.package_version; - const isVersionSatisfied = semver.satisfies(installedVersion, requiredVersion); - const targetVersion = isVersionSatisfied + const integrationTitle = found.integration_title ?? found.package_title; + // Version check e.g. installed version `1.2.3` satisfies required version `~1.2.1` + const installedVersion = found.installed_package_version ?? ''; + const isVersionSatisfied = installedVersion + ? semver.satisfies(installedVersion, requiredVersion, { includePrerelease: true }) + : true; + const targetVersion = + installedVersion && isVersionSatisfied ? installedVersion : getMinimumConcreteVersionMatchingSemver(requiredVersion); - const targetUrl = buildTargetUrl(packageName, integrationName, targetVersion); - - return { - packageName, - integrationName, - integrationTitle, - requiredVersion, - targetVersion, - targetUrl, - installationStatus: { - isKnown: true, - isInstalled: true, - isEnabled: installed.is_enabled, - isVersionMismatch: !isVersionSatisfied, - installedVersion, - }, - }; - } + const targetUrl = buildTargetUrl(packageName, integrationName, targetVersion); + + return { + packageName, + integrationName, + integrationTitle, + requiredVersion, + targetVersion, + targetUrl, + installationStatus: { + isKnown: true, + isInstalled: found.is_installed, + isEnabled: found.is_enabled, + isVersionMismatch: !isVersionSatisfied, + installedVersion, + }, + }; }; const getCapitalizedTitle = (packageName: string, integrationName: string | null): string => { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/integration_status_badge.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/integration_status_badge.tsx index 30463c744073e..fecbb3e85df39 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/integration_status_badge.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/integration_status_badge.tsx @@ -31,26 +31,22 @@ const IntegrationStatusBadgeComponent: React.FC = ( const { isInstalled, isEnabled } = installationStatus; - const badgeInstalledColor = 'success'; - const badgeUninstalledColor = '#E0E5EE'; - const badgeColor = isInstalled ? badgeInstalledColor : badgeUninstalledColor; - - const badgeTooltip = isInstalled + const color = isEnabled ? 'success' : isInstalled ? 'primary' : undefined; + const tooltipText = isInstalled ? isEnabled ? i18n.INTEGRATIONS_ENABLED_TOOLTIP : i18n.INTEGRATIONS_INSTALLED_TOOLTIP : i18n.INTEGRATIONS_UNINSTALLED_TOOLTIP; - - const badgeText = isInstalled - ? isEnabled - ? i18n.INTEGRATIONS_ENABLED - : i18n.INTEGRATIONS_INSTALLED + const statusText = isEnabled + ? i18n.INTEGRATIONS_ENABLED + : isInstalled + ? i18n.INTEGRATIONS_DISABLED : i18n.INTEGRATIONS_UNINSTALLED; return ( - - - {badgeText} + + + {statusText} ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/translations.ts b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/translations.ts index 1037993a246d2..dbd928315cf5c 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/translations.ts @@ -7,10 +7,10 @@ import { i18n } from '@kbn/i18n'; -export const INTEGRATIONS_INSTALLED = i18n.translate( - 'xpack.securitySolution.detectionEngine.relatedIntegrations.installedTitle', +export const INTEGRATIONS_DISABLED = i18n.translate( + 'xpack.securitySolution.detectionEngine.relatedIntegrations.disabledTitle', { - defaultMessage: 'Installed', + defaultMessage: 'Disabled', } ); @@ -40,7 +40,7 @@ export const INTEGRATIONS_UNINSTALLED_TOOLTIP = i18n.translate( export const INTEGRATIONS_ENABLED = i18n.translate( 'xpack.securitySolution.detectionEngine.relatedIntegrations.enabledTitle', { - defaultMessage: 'Installed: enabled', + defaultMessage: 'Enabled', } ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_installed_integrations.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_installed_integrations.tsx deleted file mode 100644 index 01b7d5fe6e613..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_installed_integrations.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { useQuery } from '@tanstack/react-query'; - -import type { InstalledIntegrationArray } from '../../../../../common/api/detection_engine/fleet_integrations'; -import { fleetIntegrationsApi } from '../../../../detection_engine/fleet_integrations'; -// import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; -// import * as i18n from './translations'; - -const ONE_MINUTE = 60000; - -export interface UseInstalledIntegrationsArgs { - packages?: string[]; - skip?: boolean; -} - -export const useInstalledIntegrations = ({ - packages, - skip = false, -}: UseInstalledIntegrationsArgs) => { - // const { addError } = useAppToasts(); - - return useQuery( - [ - 'installedIntegrations', - { - packages, - }, - ], - async ({ signal }) => { - const integrations = await fleetIntegrationsApi.fetchInstalledIntegrations({ - packages, - signal, - }); - return integrations.installed_integrations ?? []; - }, - { - keepPreviousData: true, - staleTime: ONE_MINUTE * 5, - enabled: !skip, - onError: (e) => { - // Suppressing for now to prevent excessive errors when fleet isn't configured - // addError(e, { title: i18n.INTEGRATIONS_FETCH_FAILURE }); - }, - } - ); -}; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_installed_integrations.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_integrations.test.tsx similarity index 65% rename from x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_installed_integrations.test.tsx rename to x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_integrations.test.tsx index 74791dfff2032..eba2248f82a4d 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_installed_integrations.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_integrations.test.tsx @@ -5,20 +5,18 @@ * 2.0. */ -import type { FC, PropsWithChildren } from 'react'; -import React from 'react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { renderHook, cleanup } from '@testing-library/react-hooks'; -import { useInstalledIntegrations } from './use_installed_integrations'; +import { useIntegrations } from './use_integrations'; import { fleetIntegrationsApi } from '../../../../detection_engine/fleet_integrations/api'; import { useToasts } from '../../../../common/lib/kibana'; +import { createReactQueryWrapper } from '../../../../common/mock'; jest.mock('../../../../detection_engine/fleet_integrations/api'); jest.mock('../../../../common/lib/kibana'); -describe('useInstalledIntegrations', () => { +describe('useIntegrations', () => { beforeEach(() => { jest.clearAllMocks(); }); @@ -27,26 +25,10 @@ describe('useInstalledIntegrations', () => { cleanup(); }); - const createReactQueryWrapper = () => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - // Turn retries off, otherwise we won't be able to test errors - retry: false, - }, - }, - }); - const wrapper: FC> = ({ children }) => ( - {children} - ); - return wrapper; - }; - const render = ({ skip } = { skip: false }) => renderHook( () => - useInstalledIntegrations({ - packages: [], + useIntegrations({ skip, }), { @@ -54,31 +36,22 @@ describe('useInstalledIntegrations', () => { } ); - it('calls the API via fetchInstalledIntegrations', async () => { - const fetchInstalledIntegrations = jest.spyOn( - fleetIntegrationsApi, - 'fetchInstalledIntegrations' - ); + it('calls the API via fetchAllIntegrations', async () => { + const fetchAllIntegrations = jest.spyOn(fleetIntegrationsApi, 'fetchAllIntegrations'); const { waitForNextUpdate } = render(); await waitForNextUpdate(); - expect(fetchInstalledIntegrations).toHaveBeenCalledTimes(1); - expect(fetchInstalledIntegrations).toHaveBeenLastCalledWith( - expect.objectContaining({ packages: [] }) - ); + expect(fetchAllIntegrations).toHaveBeenCalledTimes(1); }); it('does not call the API when skip is true', async () => { - const fetchInstalledIntegrations = jest.spyOn( - fleetIntegrationsApi, - 'fetchInstalledIntegrations' - ); + const fetchAllIntegrations = jest.spyOn(fleetIntegrationsApi, 'fetchAllIntegrations'); render({ skip: true }); - expect(fetchInstalledIntegrations).toHaveBeenCalledTimes(0); + expect(fetchAllIntegrations).toHaveBeenCalledTimes(0); }); it('fetches data from the API', async () => { @@ -97,19 +70,32 @@ describe('useInstalledIntegrations', () => { expect(result.current.isSuccess).toEqual(true); expect(result.current.isError).toEqual(false); expect(result.current.data).toEqual([ + { + package_name: 'o365', + package_title: 'Microsoft 365', + latest_package_version: '1.2.0', + installed_package_version: '1.0.0', + is_installed: false, + is_enabled: false, + }, { integration_name: 'audit', integration_title: 'Audit Logs', - is_enabled: true, + package_name: 'atlassian_bitbucket', package_title: 'Atlassian Bitbucket', - package_version: '1.0.1', + latest_package_version: '1.0.1', + installed_package_version: '1.0.1', + is_installed: true, + is_enabled: true, }, { - is_enabled: true, package_name: 'system', package_title: 'System', - package_version: '1.6.4', + latest_package_version: '1.6.4', + installed_package_version: '1.6.4', + is_installed: true, + is_enabled: true, }, ]); }); @@ -117,7 +103,7 @@ describe('useInstalledIntegrations', () => { // Skipping until we re-enable errors it.skip('handles exceptions from the API', async () => { const exception = new Error('Boom!'); - jest.spyOn(fleetIntegrationsApi, 'fetchInstalledIntegrations').mockRejectedValue(exception); + jest.spyOn(fleetIntegrationsApi, 'fetchAllIntegrations').mockRejectedValue(exception); const { result, waitForNextUpdate } = render(); @@ -138,7 +124,7 @@ describe('useInstalledIntegrations', () => { // And shows a toast with the caught exception expect(useToasts().addError).toHaveBeenCalledTimes(1); expect(useToasts().addError).toHaveBeenCalledWith(exception, { - title: 'Failed to fetch installed integrations', + title: 'Failed to fetch integrations', }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_integrations.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_integrations.tsx new file mode 100644 index 0000000000000..3a03fcb39fce6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_integrations.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useQuery } from '@tanstack/react-query'; + +import type { Integration } from '../../../../../common/api/detection_engine/fleet_integrations'; +import { fleetIntegrationsApi } from '../../../../detection_engine/fleet_integrations'; + +const ONE_MINUTE = 60000; + +export interface UseIntegrationsArgs { + skip?: boolean; +} + +export const useIntegrations = ({ skip = false }: UseIntegrationsArgs = {}) => { + return useQuery( + ['integrations'], + async ({ signal }) => { + const response = await fleetIntegrationsApi.fetchAllIntegrations({ + signal, + }); + + return response.integrations ?? []; + }, + { + keepPreviousData: true, + staleTime: ONE_MINUTE * 5, + enabled: !skip, + } + ); +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_related_integrations.ts b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_related_integrations.ts index dc16d365fff93..92ba42873e106 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_related_integrations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_related_integrations.ts @@ -10,7 +10,7 @@ import { useMemo } from 'react'; import type { RelatedIntegrationArray } from '../../../../../common/api/detection_engine/model/rule_schema'; import type { IntegrationDetails } from './integration_details'; import { calculateIntegrationDetails } from './integration_details'; -import { useInstalledIntegrations } from './use_installed_integrations'; +import { useIntegrations } from './use_integrations'; export interface UseRelatedIntegrationsResult { integrations: IntegrationDetails[]; @@ -20,17 +20,14 @@ export interface UseRelatedIntegrationsResult { export const useRelatedIntegrations = ( relatedIntegrations: RelatedIntegrationArray ): UseRelatedIntegrationsResult => { - const { data: installedIntegrations } = useInstalledIntegrations({ packages: [] }); + const { data: integrations } = useIntegrations(); return useMemo(() => { - const integrationDetails = calculateIntegrationDetails( - relatedIntegrations, - installedIntegrations - ); + const integrationDetails = calculateIntegrationDetails(relatedIntegrations, integrations); return { integrations: integrationDetails, - isLoaded: installedIntegrations != null, + isLoaded: integrations != null, }; - }, [relatedIntegrations, installedIntegrations]); + }, [relatedIntegrations, integrations]); }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts index fa0168c7d2e98..fa2ae6af7876e 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts @@ -37,6 +37,7 @@ import type { RuleAction, AlertSuppression, ThresholdAlertSuppression, + RelatedIntegration, } from '../../../../../common/api/detection_engine/model/rule_schema'; import type { SortOrder } from '../../../../../common/api/detection_engine'; import type { EqlOptionsSelected } from '../../../../../common/search_strategy'; @@ -144,7 +145,7 @@ export interface DefineStepRule { queryBar: FieldValueQueryBar; dataViewId?: string; dataViewTitle?: string; - relatedIntegrations: RelatedIntegrationArray; + relatedIntegrations?: RelatedIntegrationArray; requiredFields: RequiredFieldArray; ruleType: Type; timeline: FieldValueTimeline; @@ -223,6 +224,7 @@ export interface DefineStepRuleJson { event_category_override?: string; tiebreaker_field?: string; alert_suppression?: AlertSuppression | ThresholdAlertSuppression; + related_integrations?: RelatedIntegration[]; } export interface AboutStepRuleJson { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/use_fetch_alerts.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/use_fetch_alerts.test.ts similarity index 79% rename from x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/use_fetch_alerts.test.tsx rename to x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/use_fetch_alerts.test.ts index b0c2e1c3a2ef5..c4910f5daa42a 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/use_fetch_alerts.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/use_fetch_alerts.test.ts @@ -5,12 +5,11 @@ * 2.0. */ -import React from 'react'; import { renderHook } from '@testing-library/react-hooks'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { useKibana } from '../../../../common/lib/kibana'; import { createFindAlerts } from '../services/find_alerts'; import { useFetchAlerts, type UseAlertsQueryParams } from './use_fetch_alerts'; +import { createReactQueryWrapper } from '../../../../common/mock'; jest.mock('../../../../common/lib/kibana'); jest.mock('../services/find_alerts'); @@ -29,11 +28,6 @@ describe('useFetchAlerts', () => { }); it('fetches alerts and handles loading state', async () => { - const queryClient = new QueryClient(); - const wrapper = ({ children }: { children: React.ReactChild }) => ( - {children} - ); - jest .mocked(createFindAlerts) .mockReturnValue( @@ -47,7 +41,9 @@ describe('useFetchAlerts', () => { sort: [{ '@timestamp': 'desc' }], }; - const { result, waitFor } = renderHook(() => useFetchAlerts(params), { wrapper }); + const { result, waitFor } = renderHook(() => useFetchAlerts(params), { + wrapper: createReactQueryWrapper(), + }); expect(result.current.loading).toBe(true); @@ -60,11 +56,6 @@ describe('useFetchAlerts', () => { }); it('handles error state', async () => { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - const wrapper = ({ children }: { children: React.ReactChild }) => ( - {children} - ); - // hide console error due to the line after jest.spyOn(console, 'error').mockImplementation(() => {}); @@ -79,7 +70,9 @@ describe('useFetchAlerts', () => { sort: [{ '@timestamp': 'desc' }], }; - const { result, waitFor } = renderHook(() => useFetchAlerts(params), { wrapper }); + const { result, waitFor } = renderHook(() => useFetchAlerts(params), { + wrapper: createReactQueryWrapper(), + }); expect(result.current.loading).toBe(true); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_cases.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_cases.test.ts similarity index 66% rename from x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_cases.test.tsx rename to x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_cases.test.ts index c9d63d4432d6f..6ebdc2bc4b7c7 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_cases.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_cases.test.ts @@ -6,26 +6,27 @@ */ import { renderHook } from '@testing-library/react-hooks'; - +import { createReactQueryWrapper } from '../../../../common/mock'; import { useFetchRelatedCases } from './use_fetch_related_cases'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import type { ReactNode } from 'react'; -import React from 'react'; -const eventId = 'eventId'; +jest.mock('../../../../common/lib/kibana', () => ({ + useKibana: jest.fn().mockReturnValue({ + services: { + cases: { + api: { + getRelatedCases: jest.fn().mockResolvedValue([]), + }, + }, + }, + }), +})); -const createWrapper = () => { - const queryClient = new QueryClient(); - // eslint-disable-next-line react/display-name - return ({ children }: { children: ReactNode }) => ( - {children} - ); -}; +const eventId = 'eventId'; describe('useFetchRelatedCases', () => { it(`should return loading true while data is loading`, () => { const hookResult = renderHook(() => useFetchRelatedCases({ eventId }), { - wrapper: createWrapper(), + wrapper: createReactQueryWrapper(), }); expect(hookResult.result.current.loading).toEqual(true); diff --git a/x-pack/plugins/security_solution/public/shared_imports.ts b/x-pack/plugins/security_solution/public/shared_imports.ts index 8686aecb3b99f..80d345d7102d9 100644 --- a/x-pack/plugins/security_solution/public/shared_imports.ts +++ b/x-pack/plugins/security_solution/public/shared_imports.ts @@ -11,9 +11,12 @@ export type { FormData, FormHook, FormSchema, + FormSubmitHandler, ValidationError, + ValidationFuncArg, ValidationFunc, ArrayItem, + FieldConfig, } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; export { getUseField, @@ -23,6 +26,7 @@ export { FormDataProvider, UseField, UseMultiFields, + UseArray, useForm, useFormContext, useFormData, 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 c5ab49e8f4f72..9d9de2c900409 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 @@ -9,12 +9,11 @@ import { cloneDeep } from 'lodash/fp'; import moment from 'moment'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { fireEvent, screen, render, waitFor } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import React from 'react'; import '../../../../common/mock/formatted_relative'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { mockTimelineResults } from '../../../../common/mock/timeline_results'; -import { TestProviders } from '../../../../common/mock'; +import { createReactQueryWrapper, TestProviders } from '../../../../common/mock'; import type { OpenTimelineResult, TimelineResultNote } from '../types'; import { NotePreviews } from '.'; import { useDeleteNote } from './hooks/use_delete_note'; @@ -39,7 +38,6 @@ describe('NotePreviews', () => { let note1updated: number; let note2updated: number; let note3updated: number; - let queryClient: QueryClient; beforeEach(() => { mockResults = cloneDeep(mockTimelineResults); @@ -47,14 +45,6 @@ describe('NotePreviews', () => { note2updated = moment(note1updated).add(1, 'minute').valueOf(); note3updated = moment(note2updated).add(1, 'minute').valueOf(); (useDeepEqualSelector as jest.Mock).mockReset(); - queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); - (useDeleteNote as jest.Mock).mockReturnValue({ mutate: deleteMutateMock, onSuccess: jest.fn(), @@ -66,11 +56,9 @@ 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(, { + wrappingComponent: createReactQueryWrapper(), + }); hasNotes[0].notes?.forEach(({ savedObjectId }) => { expect(wrapper.find(`[data-test-subj="note-preview-${savedObjectId}"]`).exists()).toBe(true); @@ -80,11 +68,9 @@ 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(, { + wrappingComponent: createReactQueryWrapper(), + }); hasNotes[0].notes?.forEach(({ savedObjectId }) => { expect(wrapper.find(`[data-test-subj="note-preview-${savedObjectId}"]`).exists()).toBe(true); @@ -113,11 +99,9 @@ describe('NotePreviews', () => { }, ]; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(, { + wrappingComponent: createReactQueryWrapper(), + }); expect(wrapper.find('div.euiCommentEvent__headerUsername').at(1).text()).toEqual('bob'); }); @@ -144,11 +128,9 @@ describe('NotePreviews', () => { }, ]; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(, { + wrappingComponent: createReactQueryWrapper(), + }); expect(wrapper.find('div.euiCommentEvent__headerUsername').at(2).text()).toEqual('bob'); }); @@ -174,11 +156,9 @@ describe('NotePreviews', () => { }, ]; - const wrapper = mountWithIntl( - - {' '} - - ); + const wrapper = mountWithIntl(, { + wrappingComponent: createReactQueryWrapper(), + }); expect(wrapper.find('div.euiCommentEvent__headerUsername').at(2).text()).toEqual('bob'); }); @@ -188,9 +168,10 @@ describe('NotePreviews', () => { (useDeepEqualSelector as jest.Mock).mockReturnValue(timeline); const wrapper = mountWithIntl( - - - + , + { + wrappingComponent: createReactQueryWrapper(), + } ); expect(wrapper.find('[data-test-subj="note-preview-description"]').first().text()).toContain( @@ -202,11 +183,9 @@ describe('NotePreviews', () => { const timeline = mockTimelineResults[0]; (useDeepEqualSelector as jest.Mock).mockReturnValue({ ...timeline, description: undefined }); - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(, { + wrappingComponent: createReactQueryWrapper(), + }); expect(wrapper.find('[data-test-subj="note-preview-description"]').exists()).toBe(false); }); @@ -216,19 +195,20 @@ describe('NotePreviews', () => { (useDeepEqualSelector as jest.Mock).mockReturnValue(timeline); const wrapper = mountWithIntl( - - - + , + { + wrappingComponent: createReactQueryWrapper(), + } ); expect(wrapper.find('[data-test-subj="delete-note"] button').prop('disabled')).toBeTruthy(); @@ -239,20 +219,21 @@ describe('NotePreviews', () => { (useDeepEqualSelector as jest.Mock).mockReturnValue(timeline); const wrapper = mountWithIntl( - - - + , + { + wrappingComponent: createReactQueryWrapper(), + } ); expect(wrapper.find('[data-test-subj="delete-note"] button').prop('disabled')).toBeFalsy(); @@ -268,30 +249,31 @@ describe('NotePreviews', () => { render( - - - - + + , + { + wrapper: createReactQueryWrapper(), + } ); fireEvent.click(screen.queryAllByTestId('delete-note')[0]); 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 b6ed788faae0c..a46fd3e70616f 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 @@ -9,7 +9,6 @@ import type { EuiButtonIconProps } from '@elastic/eui'; import { cloneDeep, omit } from 'lodash/fp'; import React from 'react'; import { ThemeProvider } from 'styled-components'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import '../../../../common/mock/formatted_relative'; @@ -24,6 +23,7 @@ import { TimelinesTable } from '.'; import * as i18n from '../translations'; import { getMockTimelinesTableProps } from './mocks'; import { getMockTheme } from '../../../../common/lib/kibana/kibana_react.mock'; +import { createReactQueryWrapper } from '../../../../common/mock'; const mockTheme = getMockTheme({ eui: { euiColorMediumShade: '#ece' } }); @@ -40,17 +40,9 @@ jest.mock('react-redux', () => { describe('#getCommonColumns', () => { let mockResults: OpenTimelineResult[]; - let queryClient: QueryClient; beforeEach(() => { mockResults = cloneDeep(mockTimelineResults); - queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); }); describe('Expand column', () => { @@ -60,11 +52,9 @@ describe('#getCommonColumns', () => { const testProps: TimelinesTableProps = { ...getMockTimelinesTableProps(hasNotes), }; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(, { + wrappingComponent: createReactQueryWrapper(), + }); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(true); }); @@ -75,11 +65,9 @@ describe('#getCommonColumns', () => { const testProps: TimelinesTableProps = { ...getMockTimelinesTableProps(missingNotes), }; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(, { + wrappingComponent: createReactQueryWrapper(), + }); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); }); @@ -89,11 +77,9 @@ describe('#getCommonColumns', () => { const testProps: TimelinesTableProps = { ...getMockTimelinesTableProps(nullNotes), }; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(, { + wrappingComponent: createReactQueryWrapper(), + }); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); }); @@ -103,11 +89,9 @@ describe('#getCommonColumns', () => { const testProps: TimelinesTableProps = { ...getMockTimelinesTableProps(emptylNotes), }; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(, { + wrappingComponent: createReactQueryWrapper(), + }); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); }); @@ -118,11 +102,9 @@ describe('#getCommonColumns', () => { const testProps: TimelinesTableProps = { ...getMockTimelinesTableProps(missingSavedObjectId), }; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(, { + wrappingComponent: createReactQueryWrapper(), + }); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); }); @@ -132,11 +114,9 @@ describe('#getCommonColumns', () => { const testProps: TimelinesTableProps = { ...getMockTimelinesTableProps(nullSavedObjectId), }; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(, { + wrappingComponent: createReactQueryWrapper(), + }); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); }); @@ -146,11 +126,9 @@ describe('#getCommonColumns', () => { const testProps: TimelinesTableProps = { ...getMockTimelinesTableProps(hasNotes), }; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(, { + wrappingComponent: createReactQueryWrapper(), + }); const props = wrapper .find('[data-test-subj="expand-notes"]') .first() @@ -170,11 +148,9 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(hasNotes), itemIdToExpandedNotesRowMap, }; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(, { + wrappingComponent: createReactQueryWrapper(), + }); const props = wrapper .find('[data-test-subj="expand-notes"]') .first() @@ -197,11 +173,9 @@ describe('#getCommonColumns', () => { itemIdToExpandedNotesRowMap, onToggleShowNotes, }; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(, { + wrappingComponent: createReactQueryWrapper(), + }); wrapper.find('[data-test-subj="expand-notes"]').first().simulate('click'); expect(onToggleShowNotes).toBeCalledWith({ @@ -229,11 +203,12 @@ describe('#getCommonColumns', () => { }; const wrapper = mountWithIntl( - - - - - + + + , + { + wrappingComponent: createReactQueryWrapper(), + } ); wrapper.find('[data-test-subj="expand-notes"]').first().simulate('click'); @@ -250,11 +225,12 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(mockResults), }; const wrapper = mountWithIntl( - - - - - + + + , + { + wrappingComponent: createReactQueryWrapper(), + } ); expect(wrapper.find('thead tr th').at(1).text()).toContain(i18n.TIMELINE_NAME); @@ -265,11 +241,12 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(mockResults), }; const wrapper = mountWithIntl( - - - - - + + + , + { + wrappingComponent: createReactQueryWrapper(), + } ); expect( @@ -289,11 +266,12 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(missingSavedObjectId), }; const wrapper = mountWithIntl( - - - - - + + + , + { + wrappingComponent: createReactQueryWrapper(), + } ); expect( @@ -311,11 +289,12 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(missingTitle), }; const wrapper = mountWithIntl( - - - - - + + + , + { + wrappingComponent: createReactQueryWrapper(), + } ); expect( @@ -335,11 +314,12 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(withMissingSavedObjectIdAndTitle), }; const wrapper = mountWithIntl( - - - - - + + + , + { + wrappingComponent: createReactQueryWrapper(), + } ); expect( @@ -356,11 +336,12 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(withJustWhitespaceTitle), }; const wrapper = mountWithIntl( - - - - - + + + , + { + wrappingComponent: createReactQueryWrapper(), + } ); expect( @@ -380,11 +361,12 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(withMissingSavedObjectId), }; const wrapper = mountWithIntl( - - - - - + + + , + { + wrappingComponent: createReactQueryWrapper(), + } ); expect( @@ -397,11 +379,12 @@ describe('#getCommonColumns', () => { test('it renders a hyperlink when the timeline has a saved object id', () => { const wrapper = mountWithIntl( - - - - - + + + , + { + wrappingComponent: createReactQueryWrapper(), + } ); expect( @@ -421,11 +404,12 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(missingSavedObjectId), }; const wrapper = mountWithIntl( - - - - - + + + , + { + wrappingComponent: createReactQueryWrapper(), + } ); expect( @@ -444,11 +428,12 @@ describe('#getCommonColumns', () => { onOpenTimeline, }; const wrapper = mountWithIntl( - - - - - + + + , + { + wrappingComponent: createReactQueryWrapper(), + } ); wrapper @@ -466,11 +451,12 @@ describe('#getCommonColumns', () => { describe('Description column', () => { test('it renders the expected column name', () => { const wrapper = mountWithIntl( - - - - - + + + , + { + wrappingComponent: createReactQueryWrapper(), + } ); expect(wrapper.find('thead tr th').at(2).text()).toContain(i18n.DESCRIPTION); @@ -478,11 +464,12 @@ describe('#getCommonColumns', () => { test('it renders the description when the timeline has a description', () => { const wrapper = mountWithIntl( - - - - - + + + , + { + wrappingComponent: createReactQueryWrapper(), + } ); expect(wrapper.find('[data-test-subj="description"]').first().text()).toEqual( @@ -494,11 +481,12 @@ describe('#getCommonColumns', () => { const missingDescription: OpenTimelineResult[] = [omit('description', { ...mockResults[0] })]; const wrapper = mountWithIntl( - - - - - + + + , + { + wrappingComponent: createReactQueryWrapper(), + } ); expect(wrapper.find('[data-test-subj="description"]').first().text()).toEqual( getEmptyValue() @@ -514,11 +502,12 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(justWhitespaceDescription), }; const wrapper = mountWithIntl( - - - - - + + + , + { + wrappingComponent: createReactQueryWrapper(), + } ); expect(wrapper.find('[data-test-subj="description"]').first().text()).toEqual( getEmptyValue() @@ -529,11 +518,12 @@ describe('#getCommonColumns', () => { describe('Last Modified column', () => { test('it renders the expected column name', () => { const wrapper = mountWithIntl( - - - - - + + + , + { + wrappingComponent: createReactQueryWrapper(), + } ); expect(wrapper.find('thead tr th').at(3).text()).toContain(i18n.LAST_MODIFIED); @@ -541,11 +531,12 @@ describe('#getCommonColumns', () => { test('it renders the last modified (updated) date when the timeline has an updated property', () => { const wrapper = mountWithIntl( - - - - - + + + , + { + wrappingComponent: createReactQueryWrapper(), + } ); expect(wrapper.find('[data-test-subj="updated"]').first().text().length).toBeGreaterThan( @@ -558,11 +549,12 @@ describe('#getCommonColumns', () => { const missingUpdated: OpenTimelineResult[] = [omit('updated', { ...mockResults[0] })]; const wrapper = mountWithIntl( - - - - - + + + , + { + wrappingComponent: createReactQueryWrapper(), + } ); expect(wrapper.find('[data-test-subj="updated"]').first().text()).toEqual(getEmptyValue()); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/hooks/use_managed_user.test.ts b/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/hooks/use_managed_user.test.ts index 5a26600948267..94b01fbc0d57a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/hooks/use_managed_user.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/hooks/use_managed_user.test.ts @@ -6,31 +6,30 @@ */ import { renderHook } from '@testing-library/react-hooks'; -import type { InstalledIntegration } from '../../../../../../common/api/detection_engine/fleet_integrations'; +import type { Integration } from '../../../../../../common/api/detection_engine/fleet_integrations'; import { TestProviders } from '../../../../../common/mock'; import { ENTRA_ID_PACKAGE_NAME } from '../constants'; import { useManagedUser } from './use_managed_user'; -const makeInstalledIntegration = ( - pkgName = 'testPkg', - isEnabled = false -): InstalledIntegration => ({ +const makeIntegration = (pkgName = 'testPkg', isEnabled = false): Integration => ({ package_name: pkgName, package_title: '', - package_version: '', + latest_package_version: '', + installed_package_version: '', integration_name: '', integration_title: '', + is_installed: true, is_enabled: isEnabled, }); -const mockUseInstalledIntegrations = jest.fn().mockReturnValue({ +const mockUseIntegrations = jest.fn().mockReturnValue({ data: [], }); jest.mock( - '../../../../../detections/components/rules/related_integrations/use_installed_integrations', + '../../../../../detections/components/rules/related_integrations/use_integrations', () => ({ - useInstalledIntegrations: () => mockUseInstalledIntegrations(), + useIntegrations: () => mockUseIntegrations(), }) ); @@ -67,8 +66,8 @@ describe('useManagedUser', () => { mockSearch.mockClear(); }); it('returns isIntegrationEnabled:true when it finds an enabled integration with the given name', () => { - mockUseInstalledIntegrations.mockReturnValue({ - data: [makeInstalledIntegration(ENTRA_ID_PACKAGE_NAME, true)], + mockUseIntegrations.mockReturnValue({ + data: [makeIntegration(ENTRA_ID_PACKAGE_NAME, true)], }); const { result } = renderHook(() => useManagedUser('test-userName', undefined, false), { @@ -79,8 +78,8 @@ describe('useManagedUser', () => { }); it('returns isIntegrationEnabled:false when it does not find an enabled integration with the given name', () => { - mockUseInstalledIntegrations.mockReturnValue({ - data: [makeInstalledIntegration('fake-name', true)], + mockUseIntegrations.mockReturnValue({ + data: [makeIntegration('fake-name', true)], }); const { result } = renderHook(() => useManagedUser('test-userName', undefined, false), { @@ -130,7 +129,7 @@ describe('useManagedUser', () => { it('should return loading false when the feature is disabled', () => { mockUseIsExperimentalFeatureEnabled.mockReturnValue(false); - mockUseInstalledIntegrations.mockReturnValue({ + mockUseIntegrations.mockReturnValue({ data: [], isLoading: true, }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/hooks/use_managed_user.ts b/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/hooks/use_managed_user.ts index 2b98529163895..46191c8a8fb35 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/hooks/use_managed_user.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/new_user_detail/hooks/use_managed_user.ts @@ -9,7 +9,7 @@ import { useEffect, useMemo } from 'react'; import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; import type { ManagedUserHits } from '../../../../../../common/search_strategy/security_solution/users/managed_details'; -import { useInstalledIntegrations } from '../../../../../detections/components/rules/related_integrations/use_installed_integrations'; +import { useIntegrations } from '../../../../../detections/components/rules/related_integrations/use_integrations'; import { UsersQueries } from '../../../../../../common/search_strategy'; import { useSpaceId } from '../../../../../common/hooks/use_space_id'; import { useSearchStrategy } from '../../../../../common/containers/use_search_strategy'; @@ -69,8 +69,7 @@ export const useManagedUser = ( } }, [from, search, to, isInitializing, defaultIndex, userName, isLoading, email, skip]); - const { data: installedIntegrations, isLoading: loadingIntegrations } = useInstalledIntegrations({ - packages, + const { data: integrations, isLoading: loadingIntegrations } = useIntegrations({ skip, }); @@ -85,11 +84,11 @@ export const useManagedUser = ( const isIntegrationEnabled = useMemo( () => - !!installedIntegrations?.some( + !!integrations?.some( ({ package_name: packageName, is_enabled: isEnabled }) => isEnabled && packages.includes(packageName) ), - [installedIntegrations] + [integrations] ); return useMemo( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_all_integrations/extract_integrations.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_all_integrations/extract_integrations.test.ts new file mode 100644 index 0000000000000..e5837c8184a5f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_all_integrations/extract_integrations.test.ts @@ -0,0 +1,715 @@ +/* + * 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 type { PackageList, PackagePolicy, PackagePolicyInput } from '@kbn/fleet-plugin/common'; +import { extractIntegrations } from './extract_integrations'; + +describe('extractIntegrations', () => { + describe('for packages with multiple policy templates', () => { + it('extracts package title', () => { + const packages = [ + { + name: 'package-a', + title: 'Package A', + version: '1.1.1', + policy_templates: [ + { + name: 'integration-a', + title: 'Integration A', + }, + { + name: 'integration-b', + title: 'Integration B', + }, + ], + }, + ] as PackageList; + + const result = extractIntegrations(packages, []); + + expect(result).toEqual([ + expect.objectContaining({ + package_name: 'package-a', + integration_name: 'integration-a', + package_title: 'Package A', + }), + expect.objectContaining({ + package_name: 'package-a', + integration_name: 'integration-b', + package_title: 'Package A', + }), + ]); + }); + + it('extracts integration title by concatenating package and capitalized integration titles', () => { + const packages = [ + { + name: 'package-a', + title: 'Package A', + version: '1.1.1', + policy_templates: [ + { + name: 'integration-a', + title: 'Integration A', + }, + { + name: 'integration-b', + title: 'Integration B', + }, + ], + }, + ] as PackageList; + + const result = extractIntegrations(packages, []); + + expect(result).toEqual([ + expect.objectContaining({ + integration_name: 'integration-a', + integration_title: 'Package A Integration a', + }), + expect.objectContaining({ + integration_name: 'integration-b', + integration_title: 'Package A Integration b', + }), + ]); + }); + + it('extracts latest available version', () => { + const packages = [ + { + name: 'package-a', + title: 'Package A', + version: '1.1.1', + policy_templates: [ + { + name: 'integration-a', + title: 'Integration A', + }, + { + name: 'integration-b', + title: 'Integration B', + }, + ], + }, + ] as PackageList; + + const result = extractIntegrations(packages, []); + + expect(result).toEqual([ + expect.objectContaining({ + integration_name: 'integration-a', + latest_package_version: '1.1.1', + }), + expect.objectContaining({ + integration_name: 'integration-b', + latest_package_version: '1.1.1', + }), + ]); + }); + + it('extracts not installed integrations', () => { + const packages = [ + { + name: 'package-a', + title: 'Package A', + version: '1.1.1', + policy_templates: [ + { + name: 'integration-a', + title: 'Integration A', + }, + { + name: 'integration-b', + title: 'Integration B', + }, + ], + }, + ] as PackageList; + + const result = extractIntegrations(packages, []); + + expect(result).toEqual([ + expect.objectContaining({ + integration_name: 'integration-a', + is_installed: false, + is_enabled: false, + }), + expect.objectContaining({ + integration_name: 'integration-b', + is_installed: false, + is_enabled: false, + }), + ]); + }); + + it('extracts installed integrations', () => { + const packages = [ + { + name: 'package-a', + title: 'Package A', + version: '1.1.1', + status: 'installed', + policy_templates: [ + { + name: 'integration-a', + title: 'Integration A', + }, + { + name: 'integration-b', + title: 'Integration B', + }, + ], + savedObject: { + attributes: { + install_version: '1.0.0', + }, + }, + }, + ] as PackageList; + + const result = extractIntegrations(packages, []); + + expect(result).toEqual([ + expect.objectContaining({ + integration_name: 'integration-a', + is_installed: true, + is_enabled: false, + }), + expect.objectContaining({ + integration_name: 'integration-b', + is_installed: true, + is_enabled: false, + }), + ]); + }); + + it('extracts enabled integrations', () => { + const packages = [ + { + name: 'package-a', + title: 'Package A', + version: '1.1.1', + status: 'installed', + policy_templates: [ + { + name: 'integration-a', + title: 'Integration A', + }, + { + name: 'integration-b', + title: 'Integration B', + }, + ], + savedObject: { + attributes: { + install_version: '1.0.0', + }, + }, + }, + ] as PackageList; + const policies = [ + { + inputs: [ + { + enabled: true, + policy_template: 'integration-a', + }, + { + enabled: true, + type: 'integration-b', + }, + ], + package: { + name: 'package-a', + }, + }, + ] as PackagePolicy[]; + + const result = extractIntegrations(packages, policies); + + expect(result).toEqual([ + expect.objectContaining({ + integration_name: 'integration-a', + is_installed: true, + is_enabled: true, + }), + expect.objectContaining({ + integration_name: 'integration-b', + is_installed: true, + is_enabled: true, + }), + ]); + }); + + it('extracts installed package version', () => { + const packages = [ + { + name: 'package-a', + title: 'Package A', + version: '1.1.1', + status: 'installed', + policy_templates: [ + { + name: 'integration-a', + title: 'Integration A', + }, + { + name: 'integration-b', + title: 'Integration B', + }, + ], + savedObject: { + attributes: { + install_version: '1.0.0', + }, + }, + }, + ] as PackageList; + const policies = [ + { + inputs: [ + { + enabled: true, + policy_template: 'integration-a', + }, + { + enabled: true, + type: 'integration-b', + }, + ], + package: { + name: 'package-a', + }, + }, + ] as PackagePolicy[]; + + const result = extractIntegrations(packages, policies); + + expect(result).toEqual([ + expect.objectContaining({ + integration_name: 'integration-a', + installed_package_version: '1.0.0', + }), + expect.objectContaining({ + integration_name: 'integration-b', + installed_package_version: '1.0.0', + }), + ]); + }); + }); + + describe('for packages with only one policy template', () => { + it('extracts package title', () => { + const packages = [ + { + name: 'package-a', + title: 'Package A', + version: '1.1.1', + policy_templates: [ + { + name: 'integration-a', + title: 'Integration A', + }, + ], + }, + ] as PackageList; + + const result = extractIntegrations(packages, []); + + expect(result).toEqual([ + expect.objectContaining({ + package_name: 'package-a', + package_title: 'Package A', + }), + ]); + }); + + it('extracts integration title by concatenating package and capitalized integration titles', () => { + const packages = [ + { + name: 'package-a', + title: 'Package A', + version: '1.1.1', + policy_templates: [ + { + name: 'integration-a', + title: 'Integration A', + }, + ], + }, + ] as PackageList; + + const result = extractIntegrations(packages, []); + + expect(result).toEqual([ + expect.objectContaining({ + integration_name: 'integration-a', + integration_title: 'Package A Integration a', + }), + ]); + }); + + it('omits integration_name and integration_title are omitted when package and integration names match', () => { + const packages = [ + { + name: 'integration-a', + title: 'Integration A', + version: '1.1.1', + policy_templates: [ + { + name: 'integration-a', + title: 'Integration A', + }, + ], + }, + ] as PackageList; + + const result = extractIntegrations(packages, []); + + expect(result).toEqual([ + expect.not.objectContaining({ + integration_name: expect.anything(), + integration_title: expect.anything(), + }), + ]); + }); + + it('extracts latest available version', () => { + const packages = [ + { + name: 'package-a', + title: 'Package A', + version: '1.1.1', + policy_templates: [ + { + name: 'integration-a', + title: 'Integration A', + }, + ], + }, + ] as PackageList; + + const result = extractIntegrations(packages, []); + + expect(result).toEqual([ + expect.objectContaining({ + latest_package_version: '1.1.1', + }), + ]); + }); + + it('extracts not installed integrations', () => { + const packages = [ + { + name: 'package-a', + title: 'Package A', + version: '1.1.1', + policy_templates: [ + { + name: 'integration-a', + title: 'Integration A', + }, + ], + }, + ] as PackageList; + + const result = extractIntegrations(packages, []); + + expect(result).toEqual([ + expect.objectContaining({ + is_installed: false, + is_enabled: false, + }), + ]); + }); + + it('extracts installed integrations', () => { + const packages = [ + { + name: 'package-a', + title: 'Package A', + version: '1.1.1', + status: 'installed', + policy_templates: [ + { + name: 'integration-a', + title: 'Integration A', + }, + ], + savedObject: { + attributes: { + install_version: '1.0.0', + }, + }, + }, + ] as PackageList; + + const result = extractIntegrations(packages, []); + + expect(result).toEqual([ + expect.objectContaining({ + is_installed: true, + is_enabled: false, + }), + ]); + }); + + it('extracts enabled integrations', () => { + const packages = [ + { + name: 'package-a', + title: 'Package A', + version: '1.1.1', + status: 'installed', + policy_templates: [ + { + name: 'integration-a', + title: 'Integration A', + }, + ], + savedObject: { + attributes: { + install_version: '1.0.0', + }, + }, + }, + ] as PackageList; + const policies = [ + { + inputs: [ + { + enabled: true, + policy_template: 'integration-a', + }, + ], + package: { + name: 'package-a', + }, + }, + ] as PackagePolicy[]; + + const result = extractIntegrations(packages, policies); + + expect(result).toEqual([ + expect.objectContaining({ + is_installed: true, + is_enabled: true, + }), + ]); + }); + + it('extracts installed package version', () => { + const packages = [ + { + name: 'package-a', + title: 'Package A', + version: '1.1.1', + status: 'installed', + policy_templates: [ + { + name: 'integration-a', + title: 'Integration A', + }, + ], + savedObject: { + attributes: { + install_version: '1.0.0', + }, + }, + }, + ] as PackageList; + const policies = [ + { + inputs: [ + { + enabled: true, + policy_template: 'integration-a', + }, + ], + package: { + name: 'package-a', + }, + }, + ] as PackagePolicy[]; + + const result = extractIntegrations(packages, policies); + + expect(result).toEqual([ + expect.objectContaining({ + installed_package_version: '1.0.0', + }), + ]); + }); + }); + + describe('for packages without policy templates', () => { + it('extracts package title', () => { + const packages = [ + { + name: 'package-a', + title: 'Package A', + version: '1.1.1', + }, + ] as PackageList; + + const result = extractIntegrations(packages, []); + + expect(result).toEqual([ + expect.objectContaining({ + package_name: 'package-a', + package_title: 'Package A', + }), + ]); + }); + + it('omits integration_name and integration_title', () => { + const packages = [ + { + name: 'integration-a', + title: 'Integration A', + version: '1.1.1', + }, + ] as PackageList; + + const result = extractIntegrations(packages, []); + + expect(result).toEqual([ + expect.not.objectContaining({ + integration_name: expect.anything(), + integration_title: expect.anything(), + }), + ]); + }); + + it('extracts latest available version', () => { + const packages = [ + { + name: 'package-a', + title: 'Package A', + version: '1.1.1', + }, + ] as PackageList; + + const result = extractIntegrations(packages, []); + + expect(result).toEqual([ + expect.objectContaining({ + latest_package_version: '1.1.1', + }), + ]); + }); + + it('extracts not installed integrations', () => { + const packages = [ + { + name: 'package-a', + title: 'Package A', + version: '1.1.1', + }, + ] as PackageList; + + const result = extractIntegrations(packages, []); + + expect(result).toEqual([ + expect.objectContaining({ + is_installed: false, + is_enabled: false, + }), + ]); + }); + + it('extracts installed integrations', () => { + const packages = [ + { + name: 'package-a', + title: 'Package A', + version: '1.1.1', + status: 'installed', + savedObject: { + attributes: { + install_version: '1.0.0', + }, + }, + }, + ] as PackageList; + + const result = extractIntegrations(packages, []); + + expect(result).toEqual([ + expect.objectContaining({ + is_installed: true, + is_enabled: false, + }), + ]); + }); + + it('extracts enabled integrations', () => { + const packages = [ + { + name: 'package-a', + title: 'Package A', + version: '1.1.1', + status: 'installed', + savedObject: { + attributes: { + install_version: '1.0.0', + }, + }, + }, + ] as PackageList; + const policies = [ + { + package: { + name: 'package-a', + }, + inputs: [] as PackagePolicyInput[], + }, + ] as PackagePolicy[]; + + const result = extractIntegrations(packages, policies); + + expect(result).toEqual([ + expect.objectContaining({ + is_installed: true, + is_enabled: true, + }), + ]); + }); + + it('extracts installed package version', () => { + const packages = [ + { + name: 'package-a', + title: 'Package A', + version: '1.1.1', + status: 'installed', + savedObject: { + attributes: { + install_version: '1.0.0', + }, + }, + }, + ] as PackageList; + const policies = [ + { + package: { + name: 'package-a', + }, + inputs: [] as PackagePolicyInput[], + }, + ] as PackagePolicy[]; + + const result = extractIntegrations(packages, policies); + + expect(result).toEqual([ + expect.objectContaining({ + installed_package_version: '1.0.0', + }), + ]); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_all_integrations/extract_integrations.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_all_integrations/extract_integrations.ts new file mode 100644 index 0000000000000..23cd03cedbe36 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_all_integrations/extract_integrations.ts @@ -0,0 +1,93 @@ +/* + * 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 { capitalize } from 'lodash'; +import type { PackageList, PackagePolicy } from '@kbn/fleet-plugin/common'; +import type { Integration } from '../../../../../../common/api/detection_engine/fleet_integrations/model/integrations'; + +export function extractIntegrations( + packages: PackageList, + packagePolicies: PackagePolicy[] +): Integration[] { + const result: Integration[] = []; + const enabledIntegrationsSet = extractEnabledIntegrations(packagePolicies); + + for (const fleetPackage of packages) { + const packageName = fleetPackage.name; + const packageTitle = fleetPackage.title; + const isPackageInstalled = fleetPackage.status === 'installed'; + // Actual `installed_version` is buried in SO, root `version` is latest package version available + const installedPackageVersion = fleetPackage.savedObject?.attributes.install_version; + // Policy templates correspond to package's integrations. + const packagePolicyTemplates = fleetPackage.policy_templates ?? []; + + for (const policyTemplate of packagePolicyTemplates) { + const integrationId = getIntegrationId(packageName, policyTemplate.name); + const integrationName = policyTemplate.name; + const integrationTitle = + packagePolicyTemplates.length === 1 && policyTemplate.name === fleetPackage.name + ? packageTitle + : `${packageTitle} ${capitalize(policyTemplate.title)}`; + + const integration: Integration = { + package_name: packageName, + package_title: packageTitle, + latest_package_version: fleetPackage.version, + installed_package_version: installedPackageVersion, + integration_name: packageName !== integrationName ? integrationName : undefined, + integration_title: packageName !== integrationName ? integrationTitle : undefined, + is_installed: isPackageInstalled, // All integrations installed as a part of the package + is_enabled: enabledIntegrationsSet.has(integrationId), + }; + + result.push(integration); + } + + // some packages don't have policy templates at al, e.g. Lateral Movement Detection + if (packagePolicyTemplates.length === 0) { + result.push({ + package_name: packageName, + package_title: packageTitle, + latest_package_version: fleetPackage.version, + installed_package_version: installedPackageVersion, + is_installed: isPackageInstalled, + is_enabled: enabledIntegrationsSet.has(getIntegrationId(packageName, '')), + }); + } + } + + return result; +} + +function extractEnabledIntegrations(packagePolicies: PackagePolicy[]): Set { + const enabledIntegrations = new Set(); + + for (const packagePolicy of packagePolicies) { + for (const input of packagePolicy.inputs) { + if (input.enabled) { + const packageName = packagePolicy.package?.name.trim() ?? ''; // e.g. 'cloudtrail' + const integrationName = (input.policy_template ?? input.type ?? '').trim(); // e.g. 'cloudtrail' + const enabledIntegrationKey = `${packageName}${integrationName}`; + + enabledIntegrations.add(enabledIntegrationKey); + } + } + + // Base package may not have policy template, so pull directly from `policy.package` if so + if (packagePolicy.package) { + const packageName = packagePolicy.package.name.trim(); + + enabledIntegrations.add(packageName); + } + } + + return enabledIntegrations; +} + +function getIntegrationId(packageName: string, integrationName: string): string { + return `${packageName}${integrationName}`; +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_all_integrations/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_all_integrations/route.ts new file mode 100644 index 0000000000000..e5bae99052803 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_all_integrations/route.ts @@ -0,0 +1,74 @@ +/* + * 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 { transformError } from '@kbn/securitysolution-es-utils'; +import { PREBUILT_RULES_PACKAGE_NAME } from '../../../../../../common/detection_engine/constants'; +import { buildSiemResponse } from '../../../routes/utils'; +import type { SecuritySolutionPluginRouter } from '../../../../../types'; +import type { GetAllIntegrationsResponse } from '../../../../../../common/api/detection_engine/fleet_integrations'; +import { GET_ALL_INTEGRATIONS_URL } from '../../../../../../common/api/detection_engine/fleet_integrations'; +import { extractIntegrations } from './extract_integrations'; +import { sortPackagesBySecurityCategory } from './sort_packages_by_security_category'; +import { sortIntegrationsByStatus } from './sort_integrations_by_status'; + +/** + * Returns an array of Fleet integrations and their packages + */ +export const getAllIntegrationsRoute = (router: SecuritySolutionPluginRouter) => { + router.versioned + .get({ + access: 'internal', + path: GET_ALL_INTEGRATIONS_URL, + options: { + tags: ['access:securitySolution'], + }, + }) + .addVersion( + { + version: '1', + validate: false, + }, + async (context, _, response) => { + const siemResponse = buildSiemResponse(response); + + try { + const ctx = await context.resolve(['core', 'securitySolution']); + const fleet = ctx.securitySolution.getInternalFleetServices(); + + const [packages, packagePolicies] = await Promise.all([ + fleet.packages.getPackages(), + fleet.packagePolicy.list(fleet.internalReadonlySoClient, {}), + ]); + // Elastic prebuilt rules is a special package and should be skipped + const packagesWithoutPrebuiltSecurityRules = packages.filter( + (x) => x.name !== PREBUILT_RULES_PACKAGE_NAME + ); + + sortPackagesBySecurityCategory(packagesWithoutPrebuiltSecurityRules); + + const integrations = extractIntegrations( + packagesWithoutPrebuiltSecurityRules, + packagePolicies.items + ); + + sortIntegrationsByStatus(integrations); + + const body: GetAllIntegrationsResponse = { + integrations, + }; + + return response.ok({ body }); + } catch (err) { + const error = transformError(err); + return siemResponse.error({ + body: error.message, + statusCode: error.statusCode, + }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_all_integrations/sort_integrations_by_status.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_all_integrations/sort_integrations_by_status.ts new file mode 100644 index 0000000000000..fa626f369621b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_all_integrations/sort_integrations_by_status.ts @@ -0,0 +1,29 @@ +/* + * 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 type { Integration } from '../../../../../../common/api/detection_engine/fleet_integrations/model/integrations'; + +/** + * Sorts integrations in place + */ +export function sortIntegrationsByStatus(integration: Integration[]): void { + integration.sort((a, b) => { + if (a.is_enabled && !b.is_enabled) { + return -1; + } else if (!a.is_enabled && b.is_enabled) { + return 1; + } + + if (a.is_installed && !b.is_installed) { + return -1; + } else if (!a.is_installed && b.is_installed) { + return 1; + } + + return 0; + }); +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_all_integrations/sort_packages_by_security_category.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_all_integrations/sort_packages_by_security_category.ts new file mode 100644 index 0000000000000..25faae31dbd93 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_all_integrations/sort_packages_by_security_category.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PackageList } from '@kbn/fleet-plugin/common'; + +/** + * Sorts packages in place + */ +export function sortPackagesBySecurityCategory(packages: PackageList): void { + packages.sort((a, b) => { + if (a.categories?.includes('security') && !b.categories?.includes('security')) { + return -1; + } + + if (!a.categories?.includes('security') && b.categories?.includes('security')) { + return 1; + } + + return 0; + }); +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_installed_integrations/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_installed_integrations/route.ts index bf13e1f49134e..407c7d54adc52 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_installed_integrations/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/get_installed_integrations/route.ts @@ -5,7 +5,6 @@ * 2.0. */ -import type { Logger } from '@kbn/core/server'; import { transformError } from '@kbn/securitysolution-es-utils'; import { buildSiemResponse } from '../../../routes/utils'; import type { SecuritySolutionPluginRouter } from '../../../../../types'; @@ -17,10 +16,7 @@ import { createInstalledIntegrationSet } from './installed_integration_set'; /** * Returns an array of installed Fleet integrations and their packages. */ -export const getInstalledIntegrationsRoute = ( - router: SecuritySolutionPluginRouter, - logger: Logger -) => { +export const getInstalledIntegrationsRoute = (router: SecuritySolutionPluginRouter) => { router.versioned .get({ access: 'internal', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/register_routes.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/register_routes.ts index 2c6c2c2ae21f8..cd60509ca5f51 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/register_routes.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/fleet_integrations/api/register_routes.ts @@ -5,14 +5,11 @@ * 2.0. */ -import type { Logger } from '@kbn/core/server'; import type { SecuritySolutionPluginRouter } from '../../../../types'; - +import { getAllIntegrationsRoute } from './get_all_integrations/route'; import { getInstalledIntegrationsRoute } from './get_installed_integrations/route'; -export const registerFleetIntegrationsRoutes = ( - router: SecuritySolutionPluginRouter, - logger: Logger -) => { - getInstalledIntegrationsRoute(router, logger); +export const registerFleetIntegrationsRoutes = (router: SecuritySolutionPluginRouter) => { + getAllIntegrationsRoute(router); + getInstalledIntegrationsRoute(router); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.ts index 07fb5640eb482..48f097ac7a860 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.ts @@ -7,7 +7,6 @@ import * as z from 'zod'; import { - RelatedIntegrationArray, RequiredFieldArray, SetupGuide, RuleSignatureId, @@ -35,7 +34,6 @@ export const PrebuiltRuleAsset = BaseCreateProps.and(TypeSpecificCreateProps).an z.object({ rule_id: RuleSignatureId, version: RuleVersion, - related_integrations: RelatedIntegrationArray.optional(), required_fields: RequiredFieldArray.optional(), setup: SetupGuide.optional(), }) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/crud/update_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/crud/update_rules.ts index a4fcd4797b75b..d36f0ab4ad66e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/crud/update_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/crud/update_rules.ts @@ -57,7 +57,7 @@ export const updateRules = async ({ timelineTitle: ruleUpdate.timeline_title, meta: ruleUpdate.meta, maxSignals: ruleUpdate.max_signals ?? DEFAULT_MAX_SIGNALS, - relatedIntegrations: existingRule.params.relatedIntegrations, + relatedIntegrations: ruleUpdate.related_integrations ?? [], requiredFields: existingRule.params.requiredFields, riskScore: ruleUpdate.risk_score, riskScoreMapping: ruleUpdate.risk_score_mapping ?? [], diff --git a/x-pack/plugins/security_solution/server/routes/index.ts b/x-pack/plugins/security_solution/server/routes/index.ts index 4365eb4bcc3fa..bc1a26534cd6c 100644 --- a/x-pack/plugins/security_solution/server/routes/index.ts +++ b/x-pack/plugins/security_solution/server/routes/index.ts @@ -78,7 +78,7 @@ export const initRoutes = ( previewRuleDataClient: IRuleDataClient, previewTelemetryReceiver: ITelemetryReceiver ) => { - registerFleetIntegrationsRoutes(router, logger); + registerFleetIntegrationsRoutes(router); registerLegacyRuleActionsRoutes(router, logger); registerPrebuiltRulesRoutes(router, security); registerRuleExceptionsRoutes(router); diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 7e7d4218db8fe..9d357b045bb5f 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -34320,8 +34320,6 @@ "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldNameLabel": "Nom", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldNewTermsFieldHelpText": "Sélectionnez un champ pour vérifier les nouveaux termes.", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldReferenceUrlsLabel": "URL de référence", - "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRelatedIntegrationsHelpText": "Intégration liée à cette règle.", - "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRelatedIntegrationsLabel": "Intégrations liées", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRequiredFieldsHelpText": "Champs requis pour le fonctionnement de cette règle.", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRequiredFieldsLabel": "Champ requis", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRuleNameOverrideHelpText": "Choisissez un champ de l'événement source pour remplir le nom de règle dans la liste d'alertes.", @@ -35154,7 +35152,6 @@ "xpack.securitySolution.detectionEngine.relatedIntegrations.badgeTitle": "intégrations", "xpack.securitySolution.detectionEngine.relatedIntegrations.enabledTitle": "Installé : activé", "xpack.securitySolution.detectionEngine.relatedIntegrations.enabledTooltip": "L'intégration est installée et une politique d'intégration avec la configuration requise existe. Assurez-vous que des agents Elastic sont affectés à cette politique pour ingérer des événements compatibles.", - "xpack.securitySolution.detectionEngine.relatedIntegrations.installedTitle": "Installé", "xpack.securitySolution.detectionEngine.relatedIntegrations.installedTooltip": "L’intégration est installée. Configurez une politique d’intégration et assurez-vous que des agents Elastic sont affectés à cette politique pour ingérer des événements compatibles.", "xpack.securitySolution.detectionEngine.relatedIntegrations.uninstalledTitle": "Non installé", "xpack.securitySolution.detectionEngine.relatedIntegrations.uninstalledTooltip": "L’intégration n’est pas installée. Suivez le lien d'intégration pour installer et configurer l'intégration.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 60e15dd80a172..9900efe948cae 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -34289,8 +34289,6 @@ "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldNameLabel": "名前", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldNewTermsFieldHelpText": "新しい用語を確認するフィールドを選択します。", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldReferenceUrlsLabel": "参照URL", - "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRelatedIntegrationsHelpText": "統合はこのルールに関連しています。", - "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRelatedIntegrationsLabel": "関連する統合", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRequiredFieldsHelpText": "このルールの機能に必要なフィールド。", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRequiredFieldsLabel": "必須フィールド", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRuleNameOverrideHelpText": "ソースイベントからフィールドを選択し、アラートリストのルール名を入力します。", @@ -35123,7 +35121,6 @@ "xpack.securitySolution.detectionEngine.relatedIntegrations.badgeTitle": "統合", "xpack.securitySolution.detectionEngine.relatedIntegrations.enabledTitle": "インストール済み:有効", "xpack.securitySolution.detectionEngine.relatedIntegrations.enabledTooltip": "統合はインストールされ、必要な構成が行われている統合ポリシーが存在します。Elasticエージェントにこのポリシーが割り当てられていることを確認し、互換性があるイベントを取り込みます。", - "xpack.securitySolution.detectionEngine.relatedIntegrations.installedTitle": "インストール済み", "xpack.securitySolution.detectionEngine.relatedIntegrations.installedTooltip": "統合がインストールされています。統合ポリシーを構成し、Elasticエージェントにこのポリシーが割り当てられていることを確認して、対応するイベントを取り込みます。", "xpack.securitySolution.detectionEngine.relatedIntegrations.uninstalledTitle": "未インストール", "xpack.securitySolution.detectionEngine.relatedIntegrations.uninstalledTooltip": "統合はインストールされていません。統合リンクに従って、インストールし、統合を構成してください。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 430a3b8d46916..44d958e7b4527 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -34332,8 +34332,6 @@ "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldNameLabel": "名称", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldNewTermsFieldHelpText": "选择字段以检查新字词。", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldReferenceUrlsLabel": "引用 URL", - "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRelatedIntegrationsHelpText": "与此规则相关的集成。", - "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRelatedIntegrationsLabel": "相关集成", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRequiredFieldsHelpText": "此规则正常运行所需的字段。", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRequiredFieldsLabel": "必填字段", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRuleNameOverrideHelpText": "从源事件中选择字段来填充告警列表中的规则名称。", @@ -35166,7 +35164,6 @@ "xpack.securitySolution.detectionEngine.relatedIntegrations.badgeTitle": "集成", "xpack.securitySolution.detectionEngine.relatedIntegrations.enabledTitle": "已安装:已启用", "xpack.securitySolution.detectionEngine.relatedIntegrations.enabledTooltip": "集成已安装,并且存在具有所需配置的集成策略。确保 Elastic 代理已分配此策略以采集兼容的事件。", - "xpack.securitySolution.detectionEngine.relatedIntegrations.installedTitle": "已安装", "xpack.securitySolution.detectionEngine.relatedIntegrations.installedTooltip": "已安装集成。配置集成策略,并确保 Elastic 代理已分配此策略以采集兼容的事件。", "xpack.securitySolution.detectionEngine.relatedIntegrations.uninstalledTitle": "未安装", "xpack.securitySolution.detectionEngine.relatedIntegrations.uninstalledTooltip": "未安装集成。访问集成链接以安装和配置集成。", diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_bulk_actions/trial_license_complete_tier/perform_bulk_action.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_bulk_actions/trial_license_complete_tier/perform_bulk_action.ts index 8b59070202b08..fb7543b9fe700 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_bulk_actions/trial_license_complete_tier/perform_bulk_action.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_bulk_actions/trial_license_complete_tier/perform_bulk_action.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect'; +import expect from 'expect'; import { DETECTION_ENGINE_RULES_BULK_ACTION, DETECTION_ENGINE_RULES_URL, @@ -19,9 +19,11 @@ import { getCreateExceptionListDetectionSchemaMock } from '@kbn/lists-plugin/com import { EXCEPTION_LIST_ITEM_URL, EXCEPTION_LIST_URL } from '@kbn/securitysolution-list-constants'; import { getCreateExceptionListItemMinimalSchemaMock } from '@kbn/lists-plugin/common/schemas/request/create_exception_list_item_schema.mock'; import { WebhookAuthType } from '@kbn/stack-connectors-plugin/common/webhook/constants'; +import { BaseDefaultableFields } from '@kbn/security-solution-plugin/common/api/detection_engine'; import { binaryToString, getSimpleMlRule, + getCustomQueryRuleParams, getSimpleRule, getSimpleRuleOutput, getSlackAction, @@ -42,6 +44,7 @@ import { FtrProviderContext } from '../../../../../ftr_provider_context'; export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); + const securitySolutionApi = getService('securitySolutionApi'); const es = getService('es'); const log = getService('log'); const esArchiver = getService('esArchiver'); @@ -98,7 +101,9 @@ export default ({ getService }: FtrProviderContext): void => { }); it('should export rules', async () => { - await createRule(supertest, log, getSimpleRule()); + const mockRule = getCustomQueryRuleParams(); + + await securitySolutionApi.createRule({ body: mockRule }); const { body } = await postBulkAction() .send({ query: '', action: BulkActionTypeEnum.export }) @@ -109,12 +114,8 @@ export default ({ getService }: FtrProviderContext): void => { const [ruleJson, exportDetailsJson] = body.toString().split(/\n/); - const rule = removeServerGeneratedProperties(JSON.parse(ruleJson)); - const expectedRule = updateUsername(getSimpleRuleOutput(), ELASTICSEARCH_USERNAME); - expect(rule).to.eql(expectedRule); - - const exportDetails = JSON.parse(exportDetailsJson); - expect(exportDetails).to.eql({ + expect(JSON.parse(ruleJson)).toMatchObject(mockRule); + expect(JSON.parse(exportDetailsJson)).toEqual({ exported_exception_list_count: 0, exported_exception_list_item_count: 0, exported_count: 1, @@ -132,6 +133,35 @@ export default ({ getService }: FtrProviderContext): void => { missing_action_connections: [], }); }); + + it('should export rules with defaultbale fields when values are set', async () => { + const defaultableFields: BaseDefaultableFields = { + related_integrations: [ + { package: 'package-a', version: '^1.2.3' }, + { package: 'package-b', integration: 'integration-b', version: '~1.1.1' }, + ], + }; + const mockRule = getCustomQueryRuleParams(defaultableFields); + + await securitySolutionApi.createRule({ body: mockRule }); + + const { body } = await securitySolutionApi + .performBulkAction({ + query: {}, + body: { + action: BulkActionTypeEnum.export, + }, + }) + .expect(200) + .expect('Content-Type', 'application/ndjson') + .expect('Content-Disposition', 'attachment; filename="rules_export.ndjson"') + .parse(binaryToString); + + const [ruleJson] = body.toString().split(/\n/); + + expect(JSON.parse(ruleJson)).toMatchObject(defaultableFields); + }); + it('should export rules with actions connectors', async () => { // create new actions const webHookAction = await createWebHookConnector(); @@ -184,7 +214,7 @@ export default ({ getService }: FtrProviderContext): void => { const rule = removeServerGeneratedProperties(JSON.parse(ruleJson)); const expectedRule = updateUsername(getSimpleRuleOutput(), ELASTICSEARCH_USERNAME); - expect(rule).to.eql({ + expect(rule).toEqual({ ...expectedRule, actions: [ { @@ -200,14 +230,14 @@ export default ({ getService }: FtrProviderContext): void => { ], }); const { attributes, id, type } = JSON.parse(connectorsJson); - expect(attributes.actionTypeId).to.eql(exportedConnectors.attributes.actionTypeId); - expect(id).to.eql(exportedConnectors.id); - expect(type).to.eql(exportedConnectors.type); - expect(attributes.name).to.eql(exportedConnectors.attributes.name); - expect(attributes.secrets).to.eql(exportedConnectors.attributes.secrets); - expect(attributes.isMissingSecrets).to.eql(exportedConnectors.attributes.isMissingSecrets); + expect(attributes.actionTypeId).toEqual(exportedConnectors.attributes.actionTypeId); + expect(id).toEqual(exportedConnectors.id); + expect(type).toEqual(exportedConnectors.type); + expect(attributes.name).toEqual(exportedConnectors.attributes.name); + expect(attributes.secrets).toEqual(exportedConnectors.attributes.secrets); + expect(attributes.isMissingSecrets).toEqual(exportedConnectors.attributes.isMissingSecrets); const exportDetails = JSON.parse(exportDetailsJson); - expect(exportDetails).to.eql({ + expect(exportDetails).toEqual({ exported_exception_list_count: 0, exported_exception_list_item_count: 0, exported_count: 2, @@ -235,10 +265,10 @@ export default ({ getService }: FtrProviderContext): void => { .send({ query: '', action: BulkActionTypeEnum.delete }) .expect(200); - expect(body.attributes.summary).to.eql({ failed: 0, skipped: 0, succeeded: 1, total: 1 }); + expect(body.attributes.summary).toEqual({ failed: 0, skipped: 0, succeeded: 1, total: 1 }); // Check that the deleted rule is returned with the response - expect(body.attributes.results.deleted[0].name).to.eql(testRule.name); + expect(body.attributes.results.deleted[0].name).toEqual(testRule.name); // Check that the updates have been persisted await fetchRule(ruleId).expect(404); @@ -252,14 +282,14 @@ export default ({ getService }: FtrProviderContext): void => { .send({ query: '', action: BulkActionTypeEnum.enable }) .expect(200); - expect(body.attributes.summary).to.eql({ failed: 0, skipped: 0, succeeded: 1, total: 1 }); + expect(body.attributes.summary).toEqual({ failed: 0, skipped: 0, succeeded: 1, total: 1 }); // Check that the updated rule is returned with the response - expect(body.attributes.results.updated[0].enabled).to.eql(true); + expect(body.attributes.results.updated[0].enabled).toEqual(true); // Check that the updates have been persisted const { body: ruleBody } = await fetchRule(ruleId).expect(200); - expect(ruleBody.enabled).to.eql(true); + expect(ruleBody.enabled).toEqual(true); }); it('should disable rules', async () => { @@ -270,20 +300,27 @@ export default ({ getService }: FtrProviderContext): void => { .send({ query: '', action: BulkActionTypeEnum.disable }) .expect(200); - expect(body.attributes.summary).to.eql({ failed: 0, skipped: 0, succeeded: 1, total: 1 }); + expect(body.attributes.summary).toEqual({ failed: 0, skipped: 0, succeeded: 1, total: 1 }); // Check that the updated rule is returned with the response - expect(body.attributes.results.updated[0].enabled).to.eql(false); + expect(body.attributes.results.updated[0].enabled).toEqual(false); // Check that the updates have been persisted const { body: ruleBody } = await fetchRule(ruleId).expect(200); - expect(ruleBody.enabled).to.eql(false); + expect(ruleBody.enabled).toEqual(false); }); it('should duplicate rules', async () => { const ruleId = 'ruleId'; - const ruleToDuplicate = getSimpleRule(ruleId); - await createRule(supertest, log, ruleToDuplicate); + const ruleToDuplicate = getCustomQueryRuleParams({ + rule_id: ruleId, + related_integrations: [ + { package: 'package-a', version: '^1.2.3' }, + { package: 'package-b', integration: 'integration-b', version: '~1.1.1' }, + ], + }); + + await securitySolutionApi.createRule({ body: ruleToDuplicate }); const { body } = await postBulkAction() .send({ @@ -293,19 +330,30 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(200); - expect(body.attributes.summary).to.eql({ failed: 0, skipped: 0, succeeded: 1, total: 1 }); + expect(body.attributes.summary).toEqual({ failed: 0, skipped: 0, succeeded: 1, total: 1 }); // Check that the duplicated rule is returned with the response - expect(body.attributes.results.created[0].name).to.eql(`${ruleToDuplicate.name} [Duplicate]`); + expect(body.attributes.results.created[0].name).toEqual( + `${ruleToDuplicate.name} [Duplicate]` + ); // Check that the updates have been persisted - const { body: rulesResponse } = await supertest - .get(`${DETECTION_ENGINE_RULES_URL}/_find`) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '2023-10-31') + const { body: rulesResponse } = await securitySolutionApi.findRules({ query: {} }); + + expect(rulesResponse.total).toEqual(2); + + const duplicatedRuleId = body.attributes.results.created[0].id; + const { body: duplicatedRule } = await securitySolutionApi + .readRule({ + query: { id: duplicatedRuleId }, + }) .expect(200); - expect(rulesResponse.total).to.eql(2); + expect(duplicatedRule).toMatchObject({ + ...ruleToDuplicate, + name: `${ruleToDuplicate.name} [Duplicate]`, + rule_id: expect.any(String), + }); }); it('should duplicate rules with exceptions - expired exceptions included', async () => { @@ -381,15 +429,17 @@ export default ({ getService }: FtrProviderContext): void => { .expect(200); // Item should have been duplicated, even if expired - expect(foundItems.total).to.eql(1); + expect(foundItems.total).toEqual(1); - expect(body.attributes.summary).to.eql({ failed: 0, skipped: 0, succeeded: 1, total: 1 }); + expect(body.attributes.summary).toEqual({ failed: 0, skipped: 0, succeeded: 1, total: 1 }); // Check that the duplicated rule is returned with the response - expect(body.attributes.results.created[0].name).to.eql(`${ruleToDuplicate.name} [Duplicate]`); + expect(body.attributes.results.created[0].name).toEqual( + `${ruleToDuplicate.name} [Duplicate]` + ); // Check that the exceptions are duplicated - expect(body.attributes.results.created[0].exceptions_list).to.eql([ + expect(body.attributes.results.created[0].exceptions_list).toEqual([ { type: exceptionList.type, list_id: exceptionList.list_id, @@ -411,7 +461,7 @@ export default ({ getService }: FtrProviderContext): void => { .set('elastic-api-version', '2023-10-31') .expect(200); - expect(rulesResponse.total).to.eql(2); + expect(rulesResponse.total).toEqual(2); }); it('should duplicate rules with exceptions - expired exceptions excluded', async () => { @@ -487,15 +537,17 @@ export default ({ getService }: FtrProviderContext): void => { .expect(200); // Item should NOT have been duplicated, since it is expired - expect(foundItems.total).to.eql(0); + expect(foundItems.total).toEqual(0); - expect(body.attributes.summary).to.eql({ failed: 0, skipped: 0, succeeded: 1, total: 1 }); + expect(body.attributes.summary).toEqual({ failed: 0, skipped: 0, succeeded: 1, total: 1 }); // Check that the duplicated rule is returned with the response - expect(body.attributes.results.created[0].name).to.eql(`${ruleToDuplicate.name} [Duplicate]`); + expect(body.attributes.results.created[0].name).toEqual( + `${ruleToDuplicate.name} [Duplicate]` + ); // Check that the exceptions are duplicted - expect(body.attributes.results.created[0].exceptions_list).to.eql([ + expect(body.attributes.results.created[0].exceptions_list).toEqual([ { type: exceptionList.type, list_id: exceptionList.list_id, @@ -517,7 +569,7 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .expect(200); - expect(rulesResponse.total).to.eql(2); + expect(rulesResponse.total).toEqual(2); }); describe('edit action', () => { @@ -575,7 +627,7 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(200); - expect(bulkEditResponse.attributes.summary).to.eql({ + expect(bulkEditResponse.attributes.summary).toEqual({ failed: 0, skipped: 0, succeeded: 1, @@ -583,12 +635,12 @@ export default ({ getService }: FtrProviderContext): void => { }); // Check that the updated rule is returned with the response - expect(bulkEditResponse.attributes.results.updated[0].tags).to.eql(resultingTags); + expect(bulkEditResponse.attributes.results.updated[0].tags).toEqual(resultingTags); // Check that the updates have been persisted const { body: updatedRule } = await fetchRule(ruleId).expect(200); - expect(updatedRule.tags).to.eql(resultingTags); + expect(updatedRule.tags).toEqual(resultingTags); }); }); @@ -632,7 +684,7 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(200); - expect(bulkEditResponse.attributes.summary).to.eql({ + expect(bulkEditResponse.attributes.summary).toEqual({ failed: 0, skipped: 0, succeeded: 1, @@ -640,12 +692,12 @@ export default ({ getService }: FtrProviderContext): void => { }); // Check that the updated rule is returned with the response - expect(bulkEditResponse.attributes.results.updated[0].tags).to.eql(resultingTags); + expect(bulkEditResponse.attributes.results.updated[0].tags).toEqual(resultingTags); // Check that the updates have been persisted const { body: updatedRule } = await fetchRule(ruleId).expect(200); - expect(updatedRule.tags).to.eql(resultingTags); + expect(updatedRule.tags).toEqual(resultingTags); }); }); @@ -688,7 +740,7 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(200); - expect(bulkEditResponse.attributes.summary).to.eql({ + expect(bulkEditResponse.attributes.summary).toEqual({ failed: 0, skipped: 0, succeeded: 1, @@ -696,12 +748,12 @@ export default ({ getService }: FtrProviderContext): void => { }); // Check that the updated rule is returned with the response - expect(bulkEditResponse.attributes.results.updated[0].tags).to.eql(resultingTags); + expect(bulkEditResponse.attributes.results.updated[0].tags).toEqual(resultingTags); // Check that the updates have been persisted const { body: updatedRule } = await fetchRule(ruleId).expect(200); - expect(updatedRule.tags).to.eql(resultingTags); + expect(updatedRule.tags).toEqual(resultingTags); }); }); @@ -765,7 +817,7 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(200); - expect(bulkEditResponse.attributes.summary).to.eql({ + expect(bulkEditResponse.attributes.summary).toEqual({ failed: 0, skipped: 1, succeeded: 0, @@ -773,14 +825,14 @@ export default ({ getService }: FtrProviderContext): void => { }); // Check that the rules is returned as skipped with expected skip reason - expect(bulkEditResponse.attributes.results.skipped[0].skip_reason).to.eql( + expect(bulkEditResponse.attributes.results.skipped[0].skip_reason).toEqual( 'RULE_NOT_MODIFIED' ); // Check that the no changes have been persisted const { body: updatedRule } = await fetchRule(ruleId).expect(200); - expect(updatedRule.tags).to.eql(resultingTags); + expect(updatedRule.tags).toEqual(resultingTags); }); } ); @@ -804,7 +856,7 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(200); - expect(bulkEditResponse.attributes.summary).to.eql({ + expect(bulkEditResponse.attributes.summary).toEqual({ failed: 0, skipped: 0, succeeded: 1, @@ -812,12 +864,12 @@ export default ({ getService }: FtrProviderContext): void => { }); // Check that the updated rule is returned with the response - expect(bulkEditResponse.attributes.results.updated[0].index).to.eql(['initial-index-*']); + expect(bulkEditResponse.attributes.results.updated[0].index).toEqual(['initial-index-*']); // Check that the updates have been persisted const { body: updatedRule } = await fetchRule(ruleId).expect(200); - expect(updatedRule.index).to.eql(['initial-index-*']); + expect(updatedRule.index).toEqual(['initial-index-*']); }); it('should add index patterns to rules', async () => { @@ -839,7 +891,7 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(200); - expect(bulkEditResponse.attributes.summary).to.eql({ + expect(bulkEditResponse.attributes.summary).toEqual({ failed: 0, skipped: 0, succeeded: 1, @@ -847,14 +899,14 @@ export default ({ getService }: FtrProviderContext): void => { }); // Check that the updated rule is returned with the response - expect(bulkEditResponse.attributes.results.updated[0].index).to.eql( + expect(bulkEditResponse.attributes.results.updated[0].index).toEqual( resultingIndexPatterns ); // Check that the updates have been persisted const { body: updatedRule } = await fetchRule(ruleId).expect(200); - expect(updatedRule.index).to.eql(resultingIndexPatterns); + expect(updatedRule.index).toEqual(resultingIndexPatterns); }); it('should delete index patterns from rules', async () => { @@ -876,7 +928,7 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(200); - expect(bulkEditResponse.attributes.summary).to.eql({ + expect(bulkEditResponse.attributes.summary).toEqual({ failed: 0, skipped: 0, succeeded: 1, @@ -884,14 +936,14 @@ export default ({ getService }: FtrProviderContext): void => { }); // Check that the updated rule is returned with the response - expect(bulkEditResponse.attributes.results.updated[0].index).to.eql( + expect(bulkEditResponse.attributes.results.updated[0].index).toEqual( resultingIndexPatterns ); // Check that the updates have been persisted const { body: updatedRule } = await fetchRule(ruleId).expect(200); - expect(updatedRule.index).to.eql(resultingIndexPatterns); + expect(updatedRule.index).toEqual(resultingIndexPatterns); }); it('should return error if index patterns action is applied to machine learning rule', async () => { @@ -910,8 +962,13 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(500); - expect(body.attributes.summary).to.eql({ failed: 1, skipped: 0, succeeded: 0, total: 1 }); - expect(body.attributes.errors[0]).to.eql({ + expect(body.attributes.summary).toEqual({ + failed: 1, + skipped: 0, + succeeded: 0, + total: 1, + }); + expect(body.attributes.errors[0]).toEqual({ message: "Index patterns can't be added. Machine learning rule doesn't have index patterns property", status_code: 500, @@ -943,8 +1000,13 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(500); - expect(body.attributes.summary).to.eql({ failed: 1, skipped: 0, succeeded: 0, total: 1 }); - expect(body.attributes.errors[0]).to.eql({ + expect(body.attributes.summary).toEqual({ + failed: 1, + skipped: 0, + succeeded: 0, + total: 1, + }); + expect(body.attributes.errors[0]).toEqual({ message: "Mutated params invalid: Index patterns can't be empty", status_code: 500, rules: [ @@ -976,8 +1038,13 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(500); - expect(body.attributes.summary).to.eql({ failed: 1, skipped: 0, succeeded: 0, total: 1 }); - expect(body.attributes.errors[0]).to.eql({ + expect(body.attributes.summary).toEqual({ + failed: 1, + skipped: 0, + succeeded: 0, + total: 1, + }); + expect(body.attributes.errors[0]).toEqual({ message: "Mutated params invalid: Index patterns can't be empty", status_code: 500, rules: [ @@ -991,7 +1058,7 @@ export default ({ getService }: FtrProviderContext): void => { // Check that the rule hasn't been updated const { body: reFetchedRule } = await fetchRule(ruleId).expect(200); - expect(reFetchedRule.index).to.eql(['simple-index-*']); + expect(reFetchedRule.index).toEqual(['simple-index-*']); }); const skipIndexPatternsUpdateCases = [ @@ -1063,7 +1130,7 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(200); - expect(bulkEditResponse.attributes.summary).to.eql({ + expect(bulkEditResponse.attributes.summary).toEqual({ failed: 0, skipped: 1, succeeded: 0, @@ -1071,14 +1138,14 @@ export default ({ getService }: FtrProviderContext): void => { }); // Check that the rules is returned as skipped with expected skip reason - expect(bulkEditResponse.attributes.results.skipped[0].skip_reason).to.eql( + expect(bulkEditResponse.attributes.results.skipped[0].skip_reason).toEqual( 'RULE_NOT_MODIFIED' ); // Check that the no changes have been persisted const { body: updatedRule } = await fetchRule(ruleId).expect(200); - expect(updatedRule.index).to.eql(resultingIndexPatterns); + expect(updatedRule.index).toEqual(resultingIndexPatterns); }); } ); @@ -1106,17 +1173,17 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(200); - expect(body.attributes.summary).to.eql({ failed: 0, skipped: 0, succeeded: 1, total: 1 }); + expect(body.attributes.summary).toEqual({ failed: 0, skipped: 0, succeeded: 1, total: 1 }); // Check that the updated rule is returned with the response - expect(body.attributes.results.updated[0].timeline_id).to.eql(timelineId); - expect(body.attributes.results.updated[0].timeline_title).to.eql(timelineTitle); + expect(body.attributes.results.updated[0].timeline_id).toEqual(timelineId); + expect(body.attributes.results.updated[0].timeline_title).toEqual(timelineTitle); // Check that the updates have been persisted const { body: rule } = await fetchRule(ruleId).expect(200); - expect(rule.timeline_id).to.eql(timelineId); - expect(rule.timeline_title).to.eql(timelineTitle); + expect(rule.timeline_id).toEqual(timelineId); + expect(rule.timeline_title).toEqual(timelineTitle); }); it('should correctly remove timeline template', async () => { @@ -1130,8 +1197,8 @@ export default ({ getService }: FtrProviderContext): void => { }); // ensure rule has been created with timeline properties - expect(createdRule.timeline_id).to.be(timelineId); - expect(createdRule.timeline_title).to.be(timelineTitle); + expect(createdRule.timeline_id).toBe(timelineId); + expect(createdRule.timeline_title).toBe(timelineTitle); const { body } = await postBulkAction() .send({ @@ -1149,17 +1216,17 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(200); - expect(body.attributes.summary).to.eql({ failed: 0, skipped: 0, succeeded: 1, total: 1 }); + expect(body.attributes.summary).toEqual({ failed: 0, skipped: 0, succeeded: 1, total: 1 }); // Check that the updated rule is returned with the response - expect(body.attributes.results.updated[0].timeline_id).to.be(undefined); - expect(body.attributes.results.updated[0].timeline_title).to.be(undefined); + expect(body.attributes.results.updated[0].timeline_id).toBe(undefined); + expect(body.attributes.results.updated[0].timeline_title).toBe(undefined); // Check that the updates have been persisted const { body: rule } = await fetchRule(ruleId).expect(200); - expect(rule.timeline_id).to.be(undefined); - expect(rule.timeline_title).to.be(undefined); + expect(rule.timeline_id).toBe(undefined); + expect(rule.timeline_title).toBe(undefined); }); it('should return error if index patterns action is applied to machine learning rule', async () => { @@ -1178,8 +1245,8 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(500); - expect(body.attributes.summary).to.eql({ failed: 1, skipped: 0, succeeded: 0, total: 1 }); - expect(body.attributes.errors[0]).to.eql({ + expect(body.attributes.summary).toEqual({ failed: 1, skipped: 0, succeeded: 0, total: 1 }); + expect(body.attributes.errors[0]).toEqual({ message: "Index patterns can't be added. Machine learning rule doesn't have index patterns property", status_code: 500, @@ -1211,8 +1278,8 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(500); - expect(body.attributes.summary).to.eql({ failed: 1, skipped: 0, succeeded: 0, total: 1 }); - expect(body.attributes.errors[0]).to.eql({ + expect(body.attributes.summary).toEqual({ failed: 1, skipped: 0, succeeded: 0, total: 1 }); + expect(body.attributes.errors[0]).toEqual({ message: "Mutated params invalid: Index patterns can't be empty", status_code: 500, rules: [ @@ -1240,12 +1307,12 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(200); - expect(body.attributes.results.updated[0].version).to.be(rule.version + 1); + expect(body.attributes.results.updated[0].version).toBe(rule.version + 1); // Check that the updates have been persisted const { body: updatedRule } = await fetchRule(ruleId).expect(200); - expect(updatedRule.version).to.be(rule.version + 1); + expect(updatedRule.version).toBe(rule.version + 1); }); describe('prebuilt rules', () => { @@ -1301,13 +1368,13 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(500); - expect(body.attributes.summary).to.eql({ + expect(body.attributes.summary).toEqual({ failed: 1, skipped: 0, succeeded: 0, total: 1, }); - expect(body.attributes.errors[0]).to.eql({ + expect(body.attributes.errors[0]).toEqual({ message: "Elastic rule can't be edited", status_code: 500, rules: [ @@ -1369,12 +1436,12 @@ export default ({ getService }: FtrProviderContext): void => { ]; // Check that the updated rule is returned with the response - expect(body.attributes.results.updated[0].actions).to.eql(expectedRuleActions); + expect(body.attributes.results.updated[0].actions).toEqual(expectedRuleActions); // Check that the updates have been persisted const { body: readRule } = await fetchRule(ruleId).expect(200); - expect(readRule.actions).to.eql(expectedRuleActions); + expect(readRule.actions).toEqual(expectedRuleActions); }); it('should set action correctly to existing non empty actions list', async () => { @@ -1427,12 +1494,12 @@ export default ({ getService }: FtrProviderContext): void => { ]; // Check that the updated rule is returned with the response - expect(body.attributes.results.updated[0].actions).to.eql(expectedRuleActions); + expect(body.attributes.results.updated[0].actions).toEqual(expectedRuleActions); // Check that the updates have been persisted const { body: readRule } = await fetchRule(ruleId).expect(200); - expect(readRule.actions).to.eql(expectedRuleActions); + expect(readRule.actions).toEqual(expectedRuleActions); }); it('should set actions to empty list, actions payload is empty list', async () => { @@ -1472,12 +1539,12 @@ export default ({ getService }: FtrProviderContext): void => { .expect(200); // Check that the updated rule is returned with the response - expect(body.attributes.results.updated[0].actions).to.eql([]); + expect(body.attributes.results.updated[0].actions).toEqual([]); // Check that the updates have been persisted const { body: readRule } = await fetchRule(ruleId).expect(200); - expect(readRule.actions).to.eql([]); + expect(readRule.actions).toEqual([]); }); }); @@ -1521,12 +1588,12 @@ export default ({ getService }: FtrProviderContext): void => { ]; // Check that the updated rule is returned with the response - expect(body.attributes.results.updated[0].actions).to.eql(expectedRuleActions); + expect(body.attributes.results.updated[0].actions).toEqual(expectedRuleActions); // Check that the updates have been persisted const { body: readRule } = await fetchRule(ruleId).expect(200); - expect(readRule.actions).to.eql(expectedRuleActions); + expect(readRule.actions).toEqual(expectedRuleActions); }); it('should add action correctly to non empty actions list of the same type', async () => { @@ -1586,12 +1653,12 @@ export default ({ getService }: FtrProviderContext): void => { ]; // Check that the updated rule is returned with the response - expect(body.attributes.results.updated[0].actions).to.eql(expectedRuleActions); + expect(body.attributes.results.updated[0].actions).toEqual(expectedRuleActions); // Check that the updates have been persisted const { body: readRule } = await fetchRule(ruleId).expect(200); - expect(readRule.actions).to.eql(expectedRuleActions); + expect(readRule.actions).toEqual(expectedRuleActions); }); it('should add action correctly to non empty actions list of a different type', async () => { @@ -1659,12 +1726,12 @@ export default ({ getService }: FtrProviderContext): void => { ]; // Check that the updated rule is returned with the response - expect(body.attributes.results.updated[0].actions).to.eql(expectedRuleActions); + expect(body.attributes.results.updated[0].actions).toEqual(expectedRuleActions); // Check that the updates have been persisted const { body: readRule } = await fetchRule(ruleId).expect(200); - expect(readRule.actions).to.eql(expectedRuleActions); + expect(readRule.actions).toEqual(expectedRuleActions); }); it('should not change actions of rule if empty list of actions added', async () => { @@ -1704,12 +1771,12 @@ export default ({ getService }: FtrProviderContext): void => { .expect(200); // Check that the rule is skipped and was not updated - expect(body.attributes.results.skipped[0].id).to.eql(createdRule.id); + expect(body.attributes.results.skipped[0].id).toEqual(createdRule.id); // Check that the updates have been persisted const { body: readRule } = await fetchRule(ruleId).expect(200); - expect(readRule.actions).to.eql([ + expect(readRule.actions).toEqual([ { ...defaultRuleAction, uuid: createdRule.actions[0].uuid, @@ -1755,13 +1822,13 @@ export default ({ getService }: FtrProviderContext): void => { .expect(200); // Check that the rule is skipped and was not updated - expect(body.attributes.results.skipped[0].id).to.eql(createdRule.id); + expect(body.attributes.results.skipped[0].id).toEqual(createdRule.id); // Check that the updates have been persisted const { body: readRule } = await fetchRule(ruleId).expect(200); - expect(readRule.throttle).to.eql(undefined); - expect(readRule.actions).to.eql(createdRule.actions); + expect(readRule.throttle).toEqual(undefined); + expect(readRule.actions).toEqual(createdRule.actions); }); }); @@ -1803,7 +1870,7 @@ export default ({ getService }: FtrProviderContext): void => { const editedRule = body.attributes.results.updated[0]; // Check that the updated rule is returned with the response - expect(editedRule.actions).to.eql([ + expect(editedRule.actions).toEqual([ { ...webHookActionMock, id: webHookConnector.id, @@ -1813,12 +1880,12 @@ export default ({ getService }: FtrProviderContext): void => { }, ]); // version of prebuilt rule should not change - expect(editedRule.version).to.be(prebuiltRule.version); + expect(editedRule.version).toBe(prebuiltRule.version); // Check that the updates have been persisted const { body: readRule } = await fetchRule(prebuiltRule.rule_id).expect(200); - expect(readRule.actions).to.eql([ + expect(readRule.actions).toEqual([ { ...webHookActionMock, id: webHookConnector.id, @@ -1827,7 +1894,7 @@ export default ({ getService }: FtrProviderContext): void => { frequency: { summary: true, throttle: '1h', notifyWhen: 'onThrottleInterval' }, }, ]); - expect(prebuiltRule.version).to.be(readRule.version); + expect(prebuiltRule.version).toBe(readRule.version); }); }); @@ -1863,13 +1930,13 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(500); - expect(body.attributes.summary).to.eql({ + expect(body.attributes.summary).toEqual({ failed: 1, skipped: 0, succeeded: 0, total: 1, }); - expect(body.attributes.errors[0]).to.eql({ + expect(body.attributes.errors[0]).toEqual({ message: "Elastic rule can't be edited", status_code: 500, rules: [ @@ -1883,9 +1950,9 @@ export default ({ getService }: FtrProviderContext): void => { // Check that the updates were not made const { body: readRule } = await fetchRule(prebuiltRule.rule_id).expect(200); - expect(readRule.actions).to.eql(prebuiltRule.actions); - expect(readRule.tags).to.eql(prebuiltRule.tags); - expect(readRule.version).to.be(prebuiltRule.version); + expect(readRule.actions).toEqual(prebuiltRule.actions); + expect(readRule.tags).toEqual(prebuiltRule.tags); + expect(readRule.version).toBe(prebuiltRule.version); }); }); @@ -1925,12 +1992,12 @@ export default ({ getService }: FtrProviderContext): void => { .expect(200); // Check that the rule is skipped and was not updated - expect(body.attributes.results.skipped[0].id).to.eql(createdRule.id); + expect(body.attributes.results.skipped[0].id).toEqual(createdRule.id); // Check that the updates have been persisted const { body: rule } = await fetchRule(ruleId).expect(200); - expect(rule.throttle).to.eql(undefined); + expect(rule.throttle).toEqual(undefined); }); }); @@ -1987,7 +2054,7 @@ export default ({ getService }: FtrProviderContext): void => { .expect(200); // Check that the updated rule is returned with the response - expect(body.attributes.results.updated[0].throttle).to.eql(expectedThrottle); + expect(body.attributes.results.updated[0].throttle).toEqual(expectedThrottle); const expectedActions = body.attributes.results.updated[0].actions.map( (action: any) => ({ @@ -2004,8 +2071,8 @@ export default ({ getService }: FtrProviderContext): void => { // Check that the updates have been persisted const { body: rule } = await fetchRule(ruleId).expect(200); - expect(rule.throttle).to.eql(expectedThrottle); - expect(rule.actions).to.eql(expectedActions); + expect(rule.throttle).toEqual(expectedThrottle); + expect(rule.actions).toEqual(expectedActions); }); }); }); @@ -2047,7 +2114,7 @@ export default ({ getService }: FtrProviderContext): void => { // Check whether notifyWhen set correctly const { body: rule } = await fetchRuleByAlertApi(createdRule.id).expect(200); - expect(rule.notify_when).to.eql(expected.notifyWhen); + expect(rule.notify_when).toEqual(expected.notifyWhen); }); }); }); @@ -2078,10 +2145,10 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(400); - expect(body.statusCode).to.eql(400); - expect(body.error).to.eql('Bad Request'); - expect(body.message).to.contain('edit.0.value.interval: Invalid'); - expect(body.message).to.contain('edit.0.value.lookback: Invalid'); + expect(body.statusCode).toEqual(400); + expect(body.error).toEqual('Bad Request'); + expect(body.message).toContain('edit.0.value.interval: Invalid'); + expect(body.message).toContain('edit.0.value.lookback: Invalid'); }); it('should update schedule values in rules with a valid payload', async () => { @@ -2108,11 +2175,16 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(200); - expect(body.attributes.summary).to.eql({ failed: 0, skipped: 0, succeeded: 1, total: 1 }); + expect(body.attributes.summary).toEqual({ + failed: 0, + skipped: 0, + succeeded: 1, + total: 1, + }); - expect(body.attributes.results.updated[0].interval).to.eql(interval); - expect(body.attributes.results.updated[0].meta).to.eql({ from: `${lookbackMinutes}m` }); - expect(body.attributes.results.updated[0].from).to.eql( + expect(body.attributes.results.updated[0].interval).toEqual(interval); + expect(body.attributes.results.updated[0].meta).toEqual({ from: `${lookbackMinutes}m` }); + expect(body.attributes.results.updated[0].from).toEqual( `now-${(intervalMinutes + lookbackMinutes) * 60}s` ); }); @@ -2144,7 +2216,7 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(200); - expect(setIndexBody.attributes.summary).to.eql({ + expect(setIndexBody.attributes.summary).toEqual({ failed: 0, skipped: 0, succeeded: 1, @@ -2152,13 +2224,13 @@ export default ({ getService }: FtrProviderContext): void => { }); // Check that the updated rule is returned with the response - expect(setIndexBody.attributes.results.updated[0].index).to.eql(['initial-index-*']); - expect(setIndexBody.attributes.results.updated[0].data_view_id).to.eql(undefined); + expect(setIndexBody.attributes.results.updated[0].index).toEqual(['initial-index-*']); + expect(setIndexBody.attributes.results.updated[0].data_view_id).toEqual(undefined); // Check that the updates have been persisted const { body: setIndexRule } = await fetchRule(ruleId).expect(200); - expect(setIndexRule.index).to.eql(['initial-index-*']); + expect(setIndexRule.index).toEqual(['initial-index-*']); }); it('should return skipped rule and NOT add an index pattern to a rule or overwrite the data view when overwrite_data_views is false', async () => { @@ -2185,25 +2257,25 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(200); - expect(setIndexBody.attributes.summary).to.eql({ + expect(setIndexBody.attributes.summary).toEqual({ failed: 0, skipped: 1, succeeded: 0, total: 1, }); - expect(setIndexBody.attributes.errors).to.be(undefined); + expect(setIndexBody.attributes.errors).toBe(undefined); // Check that the skipped rule is returned with the response - expect(setIndexBody.attributes.results.skipped[0].id).to.eql(simpleRule.id); - expect(setIndexBody.attributes.results.skipped[0].name).to.eql(simpleRule.name); - expect(setIndexBody.attributes.results.skipped[0].skip_reason).to.eql('RULE_NOT_MODIFIED'); + expect(setIndexBody.attributes.results.skipped[0].id).toEqual(simpleRule.id); + expect(setIndexBody.attributes.results.skipped[0].name).toEqual(simpleRule.name); + expect(setIndexBody.attributes.results.skipped[0].skip_reason).toEqual('RULE_NOT_MODIFIED'); // Check that the rule has not been updated const { body: setIndexRule } = await fetchRule(ruleId).expect(200); - expect(setIndexRule.index).to.eql(undefined); - expect(setIndexRule.data_view_id).to.eql(dataViewId); + expect(setIndexRule.index).toEqual(undefined); + expect(setIndexRule.data_view_id).toEqual(dataViewId); }); it('should set an index pattern to a rule and overwrite the data view when overwrite_data_views is true', async () => { @@ -2230,7 +2302,7 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(200); - expect(setIndexBody.attributes.summary).to.eql({ + expect(setIndexBody.attributes.summary).toEqual({ failed: 0, skipped: 0, succeeded: 1, @@ -2238,14 +2310,14 @@ export default ({ getService }: FtrProviderContext): void => { }); // Check that the updated rule is returned with the response - expect(setIndexBody.attributes.results.updated[0].index).to.eql(['initial-index-*']); - expect(setIndexBody.attributes.results.updated[0].data_view_id).to.eql(undefined); + expect(setIndexBody.attributes.results.updated[0].index).toEqual(['initial-index-*']); + expect(setIndexBody.attributes.results.updated[0].data_view_id).toEqual(undefined); // Check that the updates have been persisted const { body: setIndexRule } = await fetchRule(ruleId).expect(200); - expect(setIndexRule.index).to.eql(['initial-index-*']); - expect(setIndexRule.data_view_id).to.eql(undefined); + expect(setIndexRule.index).toEqual(['initial-index-*']); + expect(setIndexRule.data_view_id).toEqual(undefined); }); it('should return error when set an empty index pattern to a rule and overwrite the data view when overwrite_data_views is true', async () => { @@ -2271,8 +2343,8 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(500); - expect(body.attributes.summary).to.eql({ failed: 1, skipped: 0, succeeded: 0, total: 1 }); - expect(body.attributes.errors[0]).to.eql({ + expect(body.attributes.summary).toEqual({ failed: 1, skipped: 0, succeeded: 0, total: 1 }); + expect(body.attributes.errors[0]).toEqual({ message: "Mutated params invalid: Index patterns can't be empty", status_code: 500, rules: [ @@ -2307,25 +2379,25 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(200); - expect(setIndexBody.attributes.summary).to.eql({ + expect(setIndexBody.attributes.summary).toEqual({ failed: 0, skipped: 1, succeeded: 0, total: 1, }); - expect(setIndexBody.attributes.errors).to.be(undefined); + expect(setIndexBody.attributes.errors).toBe(undefined); // Check that the skipped rule is returned with the response - expect(setIndexBody.attributes.results.skipped[0].id).to.eql(simpleRule.id); - expect(setIndexBody.attributes.results.skipped[0].name).to.eql(simpleRule.name); - expect(setIndexBody.attributes.results.skipped[0].skip_reason).to.eql('RULE_NOT_MODIFIED'); + expect(setIndexBody.attributes.results.skipped[0].id).toEqual(simpleRule.id); + expect(setIndexBody.attributes.results.skipped[0].name).toEqual(simpleRule.name); + expect(setIndexBody.attributes.results.skipped[0].skip_reason).toEqual('RULE_NOT_MODIFIED'); // Check that the rule has not been updated const { body: setIndexRule } = await fetchRule(ruleId).expect(200); - expect(setIndexRule.index).to.eql(undefined); - expect(setIndexRule.data_view_id).to.eql(dataViewId); + expect(setIndexRule.index).toEqual(undefined); + expect(setIndexRule.data_view_id).toEqual(dataViewId); }); // This rule will now not have a source defined - as has been the behavior of rules since the beginning @@ -2353,7 +2425,7 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(200); - expect(body.attributes.summary).to.eql({ + expect(body.attributes.summary).toEqual({ failed: 0, skipped: 0, succeeded: 1, @@ -2361,14 +2433,14 @@ export default ({ getService }: FtrProviderContext): void => { }); // Check that the updated rule is returned with the response - expect(body.attributes.results.updated[0].index).to.eql(undefined); - expect(body.attributes.results.updated[0].data_view_id).to.eql(undefined); + expect(body.attributes.results.updated[0].index).toEqual(undefined); + expect(body.attributes.results.updated[0].data_view_id).toEqual(undefined); // Check that the updates have been persisted const { body: setIndexRule } = await fetchRule(ruleId).expect(200); - expect(setIndexRule.index).to.eql(undefined); - expect(setIndexRule.data_view_id).to.eql(undefined); + expect(setIndexRule.index).toEqual(undefined); + expect(setIndexRule.data_view_id).toEqual(undefined); }); it('should return error if all index patterns removed from a rule with data views and overwrite_data_views is true', async () => { @@ -2394,8 +2466,8 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(500); - expect(body.attributes.summary).to.eql({ failed: 1, skipped: 0, succeeded: 0, total: 1 }); - expect(body.attributes.errors[0]).to.eql({ + expect(body.attributes.summary).toEqual({ failed: 1, skipped: 0, succeeded: 0, total: 1 }); + expect(body.attributes.errors[0]).toEqual({ message: "Mutated params invalid: Index patterns can't be empty", status_code: 500, rules: [ @@ -2430,13 +2502,13 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(200); - expect(body.attributes.summary).to.eql({ failed: 0, skipped: 1, succeeded: 0, total: 1 }); - expect(body.attributes.errors).to.be(undefined); + expect(body.attributes.summary).toEqual({ failed: 0, skipped: 1, succeeded: 0, total: 1 }); + expect(body.attributes.errors).toBe(undefined); // Check that the skipped rule is returned with the response - expect(body.attributes.results.skipped[0].id).to.eql(rule.id); - expect(body.attributes.results.skipped[0].name).to.eql(rule.name); - expect(body.attributes.results.skipped[0].skip_reason).to.eql('RULE_NOT_MODIFIED'); + expect(body.attributes.results.skipped[0].id).toEqual(rule.id); + expect(body.attributes.results.skipped[0].name).toEqual(rule.name); + expect(body.attributes.results.skipped[0].skip_reason).toEqual('RULE_NOT_MODIFIED'); }); }); @@ -2466,17 +2538,17 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(200); - expect(body.attributes.summary).to.eql({ failed: 0, skipped: 0, succeeded: 1, total: 1 }); + expect(body.attributes.summary).toEqual({ failed: 0, skipped: 0, succeeded: 1, total: 1 }); // Check that the updated rule is returned with the response - expect(body.attributes.results.updated[0].tags).to.eql(['tag1', 'tag2', 'tag3']); - expect(body.attributes.results.updated[0].index).to.eql(['index1-*', 'initial-index-*']); + expect(body.attributes.results.updated[0].tags).toEqual(['tag1', 'tag2', 'tag3']); + expect(body.attributes.results.updated[0].index).toEqual(['index1-*', 'initial-index-*']); // Check that the rule has been persisted const { body: updatedRule } = await fetchRule(ruleId).expect(200); - expect(updatedRule.index).to.eql(['index1-*', 'initial-index-*']); - expect(updatedRule.tags).to.eql(['tag1', 'tag2', 'tag3']); + expect(updatedRule.index).toEqual(['index1-*', 'initial-index-*']); + expect(updatedRule.tags).toEqual(['tag1', 'tag2', 'tag3']); }); it('should return one updated rule when applying one valid operation and one operation to be skipped on a rule', async () => { @@ -2506,17 +2578,17 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(200); - expect(body.attributes.summary).to.eql({ failed: 0, skipped: 0, succeeded: 1, total: 1 }); + expect(body.attributes.summary).toEqual({ failed: 0, skipped: 0, succeeded: 1, total: 1 }); // Check that the updated rule is returned with the response - expect(body.attributes.results.updated[0].tags).to.eql(['tag1', 'tag2']); - expect(body.attributes.results.updated[0].index).to.eql(['index1-*', 'initial-index-*']); + expect(body.attributes.results.updated[0].tags).toEqual(['tag1', 'tag2']); + expect(body.attributes.results.updated[0].index).toEqual(['index1-*', 'initial-index-*']); // Check that the rule has been persisted const { body: updatedRule } = await fetchRule(ruleId).expect(200); - expect(updatedRule.index).to.eql(['index1-*', 'initial-index-*']); - expect(updatedRule.tags).to.eql(['tag1', 'tag2']); + expect(updatedRule.index).toEqual(['index1-*', 'initial-index-*']); + expect(updatedRule.tags).toEqual(['tag1', 'tag2']); }); it('should return one skipped rule when two (all) operations result in a no-op', async () => { @@ -2546,18 +2618,18 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(200); - expect(body.attributes.summary).to.eql({ failed: 0, skipped: 1, succeeded: 0, total: 1 }); + expect(body.attributes.summary).toEqual({ failed: 0, skipped: 1, succeeded: 0, total: 1 }); // Check that the skipped rule is returned with the response - expect(body.attributes.results.skipped[0].name).to.eql(rule.name); - expect(body.attributes.results.skipped[0].id).to.eql(rule.id); - expect(body.attributes.results.skipped[0].skip_reason).to.eql('RULE_NOT_MODIFIED'); + expect(body.attributes.results.skipped[0].name).toEqual(rule.name); + expect(body.attributes.results.skipped[0].id).toEqual(rule.id); + expect(body.attributes.results.skipped[0].skip_reason).toEqual('RULE_NOT_MODIFIED'); // Check that no change to the rule have been persisted const { body: skippedRule } = await fetchRule(ruleId).expect(200); - expect(skippedRule.index).to.eql(['index1-*']); - expect(skippedRule.tags).to.eql(['tag1', 'tag2']); + expect(skippedRule.index).toEqual(['index1-*']); + expect(skippedRule.tags).toEqual(['tag1', 'tag2']); }); }); @@ -2585,7 +2657,7 @@ export default ({ getService }: FtrProviderContext): void => { ) ); - expect(responses.filter((r) => r.body.statusCode === 429).length).to.eql(5); + expect(responses.filter((r) => r.body.statusCode === 429).length).toEqual(5); }); it('should bulk update rule by id', async () => { @@ -2613,17 +2685,17 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(200); - expect(body.attributes.summary).to.eql({ failed: 0, skipped: 0, succeeded: 1, total: 1 }); + expect(body.attributes.summary).toEqual({ failed: 0, skipped: 0, succeeded: 1, total: 1 }); // Check that the updated rule is returned with the response - expect(body.attributes.results.updated[0].timeline_id).to.eql(timelineId); - expect(body.attributes.results.updated[0].timeline_title).to.eql(timelineTitle); + expect(body.attributes.results.updated[0].timeline_id).toEqual(timelineId); + expect(body.attributes.results.updated[0].timeline_title).toEqual(timelineTitle); // Check that the updates have been persisted const { body: rule } = await fetchRule(ruleId).expect(200); - expect(rule.timeline_id).to.eql(timelineId); - expect(rule.timeline_title).to.eql(timelineTitle); + expect(rule.timeline_id).toEqual(timelineId); + expect(rule.timeline_title).toEqual(timelineTitle); }); }); }; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_creation/basic_license_essentials_tier/create_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_creation/basic_license_essentials_tier/create_rules.ts index 28a362213ae3b..6ba0cc273c8c5 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_creation/basic_license_essentials_tier/create_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_creation/basic_license_essentials_tier/create_rules.ts @@ -5,11 +5,12 @@ * 2.0. */ -import expect from '@kbn/expect'; +import expect from 'expect'; import { RuleCreateProps } from '@kbn/security-solution-plugin/common/api/detection_engine'; import { getSimpleRule, + getCustomQueryRuleParams, getSimpleRuleOutputWithoutRuleId, getSimpleRuleWithoutRuleId, removeServerGeneratedProperties, @@ -65,7 +66,31 @@ export default ({ getService }: FtrProviderContext) => { const bodyToCompare = removeServerGeneratedProperties(body); const expectedRule = updateUsername(getSimpleRuleOutput(), ELASTICSEARCH_USERNAME); - expect(bodyToCompare).to.eql(expectedRule); + expect(bodyToCompare).toEqual(expectedRule); + }); + + it('should create a rule with defaultable fields', async () => { + const expectedRule = getCustomQueryRuleParams({ + rule_id: 'rule-1', + related_integrations: [ + { package: 'package-a', version: '^1.2.3' }, + { package: 'package-b', integration: 'integration-b', version: '~1.1.1' }, + ], + }); + + const { body: createdRuleResponse } = await securitySolutionApi + .createRule({ body: expectedRule }) + .expect(200); + + expect(createdRuleResponse).toMatchObject(expectedRule); + + const { body: createdRule } = await securitySolutionApi + .readRule({ + query: { rule_id: 'rule-1' }, + }) + .expect(200); + + expect(createdRule).toMatchObject(expectedRule); }); it('should create a single rule without an input index', async () => { @@ -120,7 +145,7 @@ export default ({ getService }: FtrProviderContext) => { ELASTICSEARCH_USERNAME ); - expect(bodyToCompare).to.eql(expectedRule); + expect(bodyToCompare).toEqual(expectedRule); }); it('should create a single rule without a rule_id', async () => { @@ -134,7 +159,7 @@ export default ({ getService }: FtrProviderContext) => { ELASTICSEARCH_USERNAME ); - expect(bodyToCompare).to.eql(expectedRule); + expect(bodyToCompare).toEqual(expectedRule); }); it('should cause a 409 conflict if we attempt to create the same rule_id twice', async () => { @@ -144,7 +169,7 @@ export default ({ getService }: FtrProviderContext) => { .createRule({ body: getSimpleRule() }) .expect(409); - expect(body).to.eql({ + expect(body).toEqual({ message: 'rule_id: "rule-1" already exists', status_code: 409, }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_creation/basic_license_essentials_tier/create_rules_bulk.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_creation/basic_license_essentials_tier/create_rules_bulk.ts index 3a1cdbbf373ed..17b4ea3e3604e 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_creation/basic_license_essentials_tier/create_rules_bulk.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_creation/basic_license_essentials_tier/create_rules_bulk.ts @@ -5,13 +5,14 @@ * 2.0. */ -import expect from '@kbn/expect'; +import expect from 'expect'; import { EsArchivePathBuilder } from '../../../../../es_archive_path_builder'; import { FtrProviderContext } from '../../../../../ftr_provider_context'; import { getSimpleRule, getSimpleRuleOutput, + getCustomQueryRuleParams, getSimpleRuleOutputWithoutRuleId, getSimpleRuleWithoutRuleId, removeServerGeneratedProperties, @@ -64,7 +65,31 @@ export default ({ getService }: FtrProviderContext): void => { const bodyToCompare = removeServerGeneratedProperties(body[0]); const expectedRule = updateUsername(getSimpleRuleOutput(), ELASTICSEARCH_USERNAME); - expect(bodyToCompare).to.eql(expectedRule); + expect(bodyToCompare).toEqual(expectedRule); + }); + + it('should create a rule with defaultable fields', async () => { + const expectedRule = getCustomQueryRuleParams({ + rule_id: 'rule-1', + related_integrations: [ + { package: 'package-a', version: '^1.2.3' }, + { package: 'package-b', integration: 'integration-b', version: '~1.1.1' }, + ], + }); + + const { body: createdRulesBulkResponse } = await securitySolutionApi + .bulkCreateRules({ body: [expectedRule] }) + .expect(200); + + expect(createdRulesBulkResponse[0]).toMatchObject(expectedRule); + + const { body: createdRule } = await securitySolutionApi + .readRule({ + query: { rule_id: 'rule-1' }, + }) + .expect(200); + + expect(createdRule).toMatchObject(expectedRule); }); it('should create a single rule without a rule_id', async () => { @@ -78,7 +103,7 @@ export default ({ getService }: FtrProviderContext): void => { ELASTICSEARCH_USERNAME ); - expect(bodyToCompare).to.eql(expectedRule); + expect(bodyToCompare).toEqual(expectedRule); }); it('should return a 200 ok but have a 409 conflict if we attempt to create the same rule_id twice', async () => { @@ -86,7 +111,7 @@ export default ({ getService }: FtrProviderContext): void => { .bulkCreateRules({ body: [getSimpleRule(), getSimpleRule()] }) .expect(200); - expect(body).to.eql([ + expect(body).toEqual([ { error: { message: 'rule_id: "rule-1" already exists', @@ -104,7 +129,7 @@ export default ({ getService }: FtrProviderContext): void => { .bulkCreateRules({ body: [getSimpleRule()] }) .expect(200); - expect(body).to.eql([ + expect(body).toEqual([ { error: { message: 'rule_id: "rule-1" already exists', diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/basic_license_essentials_tier/export_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/basic_license_essentials_tier/export_rules.ts index f355e9ed61fc4..d91c1ab18b44a 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/basic_license_essentials_tier/export_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/basic_license_essentials_tier/export_rules.ts @@ -8,6 +8,7 @@ import expect from 'expect'; import { DETECTION_ENGINE_RULES_URL } from '@kbn/security-solution-plugin/common/constants'; +import { BaseDefaultableFields } from '@kbn/security-solution-plugin/common/api/detection_engine'; import { FtrProviderContext } from '../../../../../ftr_provider_context'; import { binaryToString, getCustomQueryRuleParams } from '../../../utils'; import { @@ -18,6 +19,7 @@ import { } from '../../../../../../common/utils/security_solution'; export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); + const securitySolutionApi = getService('securitySolutionApi'); const log = getService('log'); const es = getService('es'); @@ -63,6 +65,27 @@ export default ({ getService }: FtrProviderContext): void => { expect(exportedRule).toMatchObject(ruleToExport); }); + it('should export defaultable fields when values are set', async () => { + const defaultableFields: BaseDefaultableFields = { + related_integrations: [ + { package: 'package-a', version: '^1.2.3' }, + { package: 'package-b', integration: 'integration-b', version: '~1.1.1' }, + ], + }; + const ruleToExport = getCustomQueryRuleParams(defaultableFields); + + await securitySolutionApi.createRule({ body: ruleToExport }); + + const { body } = await securitySolutionApi + .exportRules({ query: {}, body: null }) + .expect(200) + .parse(binaryToString); + + const exportedRule = JSON.parse(body.toString().split(/\n/)[0]); + + expect(exportedRule).toMatchObject(defaultableFields); + }); + it('should have export summary reflecting a number of rules', async () => { await createRule(supertest, log, getCustomQueryRuleParams()); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/basic_license_essentials_tier/import_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/basic_license_essentials_tier/import_rules.ts index d1b2fb041f4bc..f4fc373965df9 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/basic_license_essentials_tier/import_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_import_export/basic_license_essentials_tier/import_rules.ts @@ -8,6 +8,7 @@ import expect from 'expect'; import { DETECTION_ENGINE_RULES_URL } from '@kbn/security-solution-plugin/common/constants'; +import { BaseDefaultableFields } from '@kbn/security-solution-plugin/common/api/detection_engine'; import { FtrProviderContext } from '../../../../../ftr_provider_context'; import { getCustomQueryRuleParams, combineToNdJson, fetchRule } from '../../../utils'; import { @@ -19,6 +20,7 @@ import { export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); + const securitySolutionApi = getService('securitySolutionApi'); const log = getService('log'); const es = getService('es'); @@ -135,6 +137,33 @@ export default ({ getService }: FtrProviderContext): void => { expect(body.errors[0].error.message).toBe('from: Failed to parse date-math expression'); }); + it('should be able to import rules with defaultable fields', async () => { + const defaultableFields: BaseDefaultableFields = { + related_integrations: [ + { package: 'package-a', version: '^1.2.3' }, + { package: 'package-b', integration: 'integration-b', version: '~1.1.1' }, + ], + }; + const ruleToImport = getCustomQueryRuleParams({ + ...defaultableFields, + rule_id: 'rule-1', + }); + const ndjson = combineToNdJson(ruleToImport); + + await securitySolutionApi + .importRules({ query: {} }) + .attach('file', Buffer.from(ndjson), 'rules.ndjson') + .expect(200); + + const { body: importedRule } = await securitySolutionApi + .readRule({ + query: { rule_id: 'rule-1' }, + }) + .expect(200); + + expect(importedRule).toMatchObject(ruleToImport); + }); + it('should be able to import two rules', async () => { const ndjson = combineToNdJson( getCustomQueryRuleParams({ diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules.ts index 7abca99e6e052..27990708215d3 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules.ts @@ -5,12 +5,13 @@ * 2.0. */ -import expect from '@kbn/expect'; +import expect from 'expect'; import { FtrProviderContext } from '../../../../../ftr_provider_context'; import { getSimpleRule, getSimpleRuleOutput, + getCustomQueryRuleParams, removeServerGeneratedProperties, removeServerGeneratedPropertiesIncludingRuleId, getSimpleRuleOutputWithoutRuleId, @@ -56,7 +57,40 @@ export default ({ getService }: FtrProviderContext) => { const expectedRule = updateUsername(outputRule, ELASTICSEARCH_USERNAME); const bodyToCompare = removeServerGeneratedProperties(body); - expect(bodyToCompare).to.eql(expectedRule); + expect(bodyToCompare).toEqual(expectedRule); + }); + + it('should patch defaultable fields', async () => { + const expectedRule = getCustomQueryRuleParams({ + rule_id: 'rule-1', + related_integrations: [ + { package: 'package-a', version: '^1.2.3' }, + { package: 'package-b', integration: 'integration-b', version: '~1.1.1' }, + ], + }); + + await securitySolutionApi.createRule({ + body: getCustomQueryRuleParams({ rule_id: 'rule-1' }), + }); + + const { body: patchedRuleResponse } = await securitySolutionApi + .patchRule({ + body: { + rule_id: 'rule-1', + related_integrations: expectedRule.related_integrations, + }, + }) + .expect(200); + + expect(patchedRuleResponse).toMatchObject(expectedRule); + + const { body: patchedRule } = await securitySolutionApi + .readRule({ + query: { rule_id: 'rule-1' }, + }) + .expect(200); + + expect(patchedRule).toMatchObject(expectedRule); }); it('@skipInServerless should return a "403 forbidden" using a rule_id of type "machine learning"', async () => { @@ -67,7 +101,7 @@ export default ({ getService }: FtrProviderContext) => { .patchRule({ body: { rule_id: 'rule-1', type: 'machine_learning' } }) .expect(403); - expect(body).to.eql({ + expect(body).toEqual({ message: 'Your license does not support machine learning. Please upgrade your license.', status_code: 403, }); @@ -90,7 +124,7 @@ export default ({ getService }: FtrProviderContext) => { const expectedRule = updateUsername(outputRule, ELASTICSEARCH_USERNAME); const bodyToCompare = removeServerGeneratedPropertiesIncludingRuleId(body); - expect(bodyToCompare).to.eql(expectedRule); + expect(bodyToCompare).toEqual(expectedRule); }); it('should patch a single rule property of name using the auto-generated id', async () => { @@ -107,7 +141,7 @@ export default ({ getService }: FtrProviderContext) => { const expectedRule = updateUsername(outputRule, ELASTICSEARCH_USERNAME); const bodyToCompare = removeServerGeneratedProperties(body); - expect(bodyToCompare).to.eql(expectedRule); + expect(bodyToCompare).toEqual(expectedRule); }); it('should not change the revision of a rule when it patches only enabled', async () => { @@ -123,7 +157,7 @@ export default ({ getService }: FtrProviderContext) => { const expectedRule = updateUsername(outputRule, ELASTICSEARCH_USERNAME); const bodyToCompare = removeServerGeneratedProperties(body); - expect(bodyToCompare).to.eql(expectedRule); + expect(bodyToCompare).toEqual(expectedRule); }); it('should change the revision of a rule when it patches enabled and another property', async () => { @@ -141,7 +175,7 @@ export default ({ getService }: FtrProviderContext) => { const expectedRule = updateUsername(outputRule, ELASTICSEARCH_USERNAME); const bodyToCompare = removeServerGeneratedProperties(body); - expect(bodyToCompare).to.eql(expectedRule); + expect(bodyToCompare).toEqual(expectedRule); }); it('should not change other properties when it does patches', async () => { @@ -167,7 +201,7 @@ export default ({ getService }: FtrProviderContext) => { const expectedRule = updateUsername(outputRule, ELASTICSEARCH_USERNAME); const bodyToCompare = removeServerGeneratedProperties(body); - expect(bodyToCompare).to.eql(expectedRule); + expect(bodyToCompare).toEqual(expectedRule); }); it('should give a 404 if it is given a fake id', async () => { @@ -177,7 +211,7 @@ export default ({ getService }: FtrProviderContext) => { }) .expect(404); - expect(body).to.eql({ + expect(body).toEqual({ status_code: 404, message: 'id: "5096dec6-b6b9-4d8d-8f93-6c2602079d9d" not found', }); @@ -188,7 +222,7 @@ export default ({ getService }: FtrProviderContext) => { .patchRule({ body: { rule_id: 'fake_id', name: 'some other name' } }) .expect(404); - expect(body).to.eql({ + expect(body).toEqual({ status_code: 404, message: 'rule_id: "fake_id" not found', }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules_bulk.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules_bulk.ts index bb86ae5d17354..ef3c944bf9931 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules_bulk.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules_bulk.ts @@ -5,12 +5,13 @@ * 2.0. */ -import expect from '@kbn/expect'; +import expect from 'expect'; import { FtrProviderContext } from '../../../../../ftr_provider_context'; import { getSimpleRule, getSimpleRuleOutput, + getCustomQueryRuleParams, removeServerGeneratedProperties, getSimpleRuleOutputWithoutRuleId, removeServerGeneratedPropertiesIncludingRuleId, @@ -55,7 +56,42 @@ export default ({ getService }: FtrProviderContext) => { outputRule.revision = 1; const bodyToCompare = removeServerGeneratedProperties(body[0]); const expectedRule = updateUsername(outputRule, ELASTICSEARCH_USERNAME); - expect(bodyToCompare).to.eql(expectedRule); + expect(bodyToCompare).toEqual(expectedRule); + }); + + it('should patch defaultable fields', async () => { + const expectedRule = getCustomQueryRuleParams({ + rule_id: 'rule-1', + related_integrations: [ + { package: 'package-a', version: '^1.2.3' }, + { package: 'package-b', integration: 'integration-b', version: '~1.1.1' }, + ], + }); + + await securitySolutionApi.createRule({ + body: getCustomQueryRuleParams({ rule_id: 'rule-1' }), + }); + + const { body: patchedRulesBulkResponse } = await securitySolutionApi + .bulkPatchRules({ + body: [ + { + rule_id: 'rule-1', + related_integrations: expectedRule.related_integrations, + }, + ], + }) + .expect(200); + + expect(patchedRulesBulkResponse[0]).toMatchObject(expectedRule); + + const { body: patchedRule } = await securitySolutionApi + .readRule({ + query: { rule_id: 'rule-1' }, + }) + .expect(200); + + expect(patchedRule).toMatchObject(expectedRule); }); it('should patch two rule properties of name using the two rules rule_id', async () => { @@ -84,8 +120,8 @@ export default ({ getService }: FtrProviderContext) => { const bodyToCompare1 = removeServerGeneratedProperties(body[0]); const bodyToCompare2 = removeServerGeneratedProperties(body[1]); - expect(bodyToCompare1).to.eql(expectedRule1); - expect(bodyToCompare2).to.eql(expectedRule2); + expect(bodyToCompare1).toEqual(expectedRule1); + expect(bodyToCompare2).toEqual(expectedRule2); }); it('should patch a single rule property of name using an id', async () => { @@ -101,7 +137,7 @@ export default ({ getService }: FtrProviderContext) => { outputRule.revision = 1; const expectedRule = updateUsername(outputRule, ELASTICSEARCH_USERNAME); const bodyToCompare = removeServerGeneratedProperties(body[0]); - expect(bodyToCompare).to.eql(expectedRule); + expect(bodyToCompare).toEqual(expectedRule); }); it('should patch two rule properties of name using the two rules id', async () => { @@ -130,8 +166,8 @@ export default ({ getService }: FtrProviderContext) => { const bodyToCompare1 = removeServerGeneratedPropertiesIncludingRuleId(body[0]); const bodyToCompare2 = removeServerGeneratedPropertiesIncludingRuleId(body[1]); - expect(bodyToCompare1).to.eql(expectedRule); - expect(bodyToCompare2).to.eql(expectedRule2); + expect(bodyToCompare1).toEqual(expectedRule); + expect(bodyToCompare2).toEqual(expectedRule2); }); it('should patch a single rule property of name using the auto-generated id', async () => { @@ -148,7 +184,7 @@ export default ({ getService }: FtrProviderContext) => { const expectedRule = updateUsername(outputRule, ELASTICSEARCH_USERNAME); const bodyToCompare = removeServerGeneratedProperties(body[0]); - expect(bodyToCompare).to.eql(expectedRule); + expect(bodyToCompare).toEqual(expectedRule); }); it('should not change the revision of a rule when it patches only enabled', async () => { @@ -164,7 +200,7 @@ export default ({ getService }: FtrProviderContext) => { const expectedRule = updateUsername(outputRule, ELASTICSEARCH_USERNAME); const bodyToCompare = removeServerGeneratedProperties(body[0]); - expect(bodyToCompare).to.eql(expectedRule); + expect(bodyToCompare).toEqual(expectedRule); }); it('should change the revision of a rule when it patches enabled and another property', async () => { @@ -182,7 +218,7 @@ export default ({ getService }: FtrProviderContext) => { const expectedRule = updateUsername(outputRule, ELASTICSEARCH_USERNAME); const bodyToCompare = removeServerGeneratedProperties(body[0]); - expect(bodyToCompare).to.eql(expectedRule); + expect(bodyToCompare).toEqual(expectedRule); }); it('should not change other properties when it does patches', async () => { @@ -208,7 +244,7 @@ export default ({ getService }: FtrProviderContext) => { const expectedRule = updateUsername(outputRule, ELASTICSEARCH_USERNAME); const bodyToCompare = removeServerGeneratedProperties(body[0]); - expect(bodyToCompare).to.eql(expectedRule); + expect(bodyToCompare).toEqual(expectedRule); }); it('should return a 200 but give a 404 in the message if it is given a fake id', async () => { @@ -218,7 +254,7 @@ export default ({ getService }: FtrProviderContext) => { }) .expect(200); - expect(body).to.eql([ + expect(body).toEqual([ { id: '5096dec6-b6b9-4d8d-8f93-6c2602079d9d', error: { @@ -234,7 +270,7 @@ export default ({ getService }: FtrProviderContext) => { .bulkPatchRules({ body: [{ rule_id: 'fake_id', name: 'some other name' }] }) .expect(200); - expect(body).to.eql([ + expect(body).toEqual([ { rule_id: 'fake_id', error: { status_code: 404, message: 'rule_id: "fake_id" not found' }, @@ -261,7 +297,7 @@ export default ({ getService }: FtrProviderContext) => { const expectedRule = updateUsername(outputRule, ELASTICSEARCH_USERNAME); const bodyToCompare = removeServerGeneratedProperties(body[0]); - expect([bodyToCompare, body[1]]).to.eql([ + expect([bodyToCompare, body[1]]).toEqual([ expectedRule, { error: { @@ -292,7 +328,7 @@ export default ({ getService }: FtrProviderContext) => { const expectedRule = updateUsername(outputRule, ELASTICSEARCH_USERNAME); const bodyToCompare = removeServerGeneratedProperties(body[0]); - expect([bodyToCompare, body[1]]).to.eql([ + expect([bodyToCompare, body[1]]).toEqual([ expectedRule, { error: { diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules.ts index 301b4413805a9..08dfdac9a7e82 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules.ts @@ -5,11 +5,12 @@ * 2.0. */ -import expect from '@kbn/expect'; +import expect from 'expect'; import { FtrProviderContext } from '../../../../../ftr_provider_context'; import { getSimpleRuleOutput, + getCustomQueryRuleParams, removeServerGeneratedProperties, removeServerGeneratedPropertiesIncludingRuleId, getSimpleRuleOutputWithoutRuleId, @@ -61,7 +62,37 @@ export default ({ getService }: FtrProviderContext) => { const expectedRule = updateUsername(outputRule, ELASTICSEARCH_USERNAME); const bodyToCompare = removeServerGeneratedProperties(body); - expect(bodyToCompare).to.eql(expectedRule); + expect(bodyToCompare).toEqual(expectedRule); + }); + + it('should update a rule with defaultable fields', async () => { + const expectedRule = getCustomQueryRuleParams({ + rule_id: 'rule-1', + related_integrations: [ + { package: 'package-a', version: '^1.2.3' }, + { package: 'package-b', integration: 'integration-b', version: '~1.1.1' }, + ], + }); + + await securitySolutionApi.createRule({ + body: getCustomQueryRuleParams({ rule_id: 'rule-1' }), + }); + + const { body: updatedRuleResponse } = await securitySolutionApi + .updateRule({ + body: expectedRule, + }) + .expect(200); + + expect(updatedRuleResponse).toMatchObject(expectedRule); + + const { body: updatedRule } = await securitySolutionApi + .readRule({ + query: { rule_id: 'rule-1' }, + }) + .expect(200); + + expect(updatedRule).toMatchObject(expectedRule); }); it('@skipInServerless should return a 403 forbidden if it is a machine learning job', async () => { @@ -75,7 +106,7 @@ export default ({ getService }: FtrProviderContext) => { const { body } = await securitySolutionApi.updateRule({ body: updatedRule }).expect(403); - expect(body).to.eql({ + expect(body).toEqual({ message: 'Your license does not support machine learning. Please upgrade your license.', status_code: 403, }); @@ -100,7 +131,7 @@ export default ({ getService }: FtrProviderContext) => { const expectedRule = updateUsername(outputRule, ELASTICSEARCH_USERNAME); const bodyToCompare = removeServerGeneratedPropertiesIncludingRuleId(body); - expect(bodyToCompare).to.eql(expectedRule); + expect(bodyToCompare).toEqual(expectedRule); }); it('should update a single rule property of name using the auto-generated id', async () => { @@ -120,7 +151,7 @@ export default ({ getService }: FtrProviderContext) => { const expectedRule = updateUsername(outputRule, ELASTICSEARCH_USERNAME); const bodyToCompare = removeServerGeneratedProperties(body); - expect(bodyToCompare).to.eql(expectedRule); + expect(bodyToCompare).toEqual(expectedRule); }); it('should change the revision of a rule when it updates enabled and another property', async () => { @@ -140,7 +171,7 @@ export default ({ getService }: FtrProviderContext) => { const expectedRule = updateUsername(outputRule, ELASTICSEARCH_USERNAME); const bodyToCompare = removeServerGeneratedProperties(body); - expect(bodyToCompare).to.eql(expectedRule); + expect(bodyToCompare).toEqual(expectedRule); }); it('should change other properties when it does updates and effectively delete them such as timeline_title', async () => { @@ -165,7 +196,7 @@ export default ({ getService }: FtrProviderContext) => { const expectedRule = updateUsername(outputRule, ELASTICSEARCH_USERNAME); const bodyToCompare = removeServerGeneratedProperties(body); - expect(bodyToCompare).to.eql(expectedRule); + expect(bodyToCompare).toEqual(expectedRule); }); it('should give a 404 if it is given a fake id', async () => { @@ -175,7 +206,7 @@ export default ({ getService }: FtrProviderContext) => { const { body } = await securitySolutionApi.updateRule({ body: simpleRule }).expect(404); - expect(body).to.eql({ + expect(body).toEqual({ status_code: 404, message: 'id: "5096dec6-b6b9-4d8d-8f93-6c2602079d9d" not found', }); @@ -188,7 +219,7 @@ export default ({ getService }: FtrProviderContext) => { const { body } = await securitySolutionApi.updateRule({ body: simpleRule }).expect(404); - expect(body).to.eql({ + expect(body).toEqual({ status_code: 404, message: 'rule_id: "fake_id" not found', }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules_bulk.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules_bulk.ts index 4696a5d82444c..d28d9efd41350 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules_bulk.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules_bulk.ts @@ -5,11 +5,12 @@ * 2.0. */ -import expect from '@kbn/expect'; +import expect from 'expect'; import { FtrProviderContext } from '../../../../../ftr_provider_context'; import { getSimpleRuleOutput, + getCustomQueryRuleParams, removeServerGeneratedProperties, getSimpleRuleOutputWithoutRuleId, removeServerGeneratedPropertiesIncludingRuleId, @@ -60,7 +61,37 @@ export default ({ getService }: FtrProviderContext) => { const expectedRule = updateUsername(outputRule, ELASTICSEARCH_USERNAME); const bodyToCompare = removeServerGeneratedProperties(body[0]); - expect(bodyToCompare).to.eql(expectedRule); + expect(bodyToCompare).toEqual(expectedRule); + }); + + it('should update a rule with defaultable fields', async () => { + const expectedRule = getCustomQueryRuleParams({ + rule_id: 'rule-1', + related_integrations: [ + { package: 'package-a', version: '^1.2.3' }, + { package: 'package-b', integration: 'integration-b', version: '~1.1.1' }, + ], + }); + + await securitySolutionApi.createRule({ + body: getCustomQueryRuleParams({ rule_id: 'rule-1' }), + }); + + const { body: updatedRulesBulkResponse } = await securitySolutionApi + .bulkUpdateRules({ + body: [expectedRule], + }) + .expect(200); + + expect(updatedRulesBulkResponse[0]).toMatchObject(expectedRule); + + const { body: updatedRule } = await securitySolutionApi + .readRule({ + query: { rule_id: 'rule-1' }, + }) + .expect(200); + + expect(updatedRule).toMatchObject(expectedRule); }); it('should update two rule properties of name using the two rules rule_id', async () => { @@ -92,8 +123,8 @@ export default ({ getService }: FtrProviderContext) => { const bodyToCompare1 = removeServerGeneratedProperties(body[0]); const bodyToCompare2 = removeServerGeneratedProperties(body[1]); - expect(bodyToCompare1).to.eql(expectedRule); - expect(bodyToCompare2).to.eql(expectedRule2); + expect(bodyToCompare1).toEqual(expectedRule); + expect(bodyToCompare2).toEqual(expectedRule2); }); it('should update a single rule property of name using an id', async () => { @@ -115,7 +146,7 @@ export default ({ getService }: FtrProviderContext) => { const expectedRule = updateUsername(outputRule, ELASTICSEARCH_USERNAME); const bodyToCompare = removeServerGeneratedProperties(body[0]); - expect(bodyToCompare).to.eql(expectedRule); + expect(bodyToCompare).toEqual(expectedRule); }); it('should update two rule properties of name using the two rules id', async () => { @@ -149,8 +180,8 @@ export default ({ getService }: FtrProviderContext) => { const bodyToCompare1 = removeServerGeneratedPropertiesIncludingRuleId(body[0]); const bodyToCompare2 = removeServerGeneratedPropertiesIncludingRuleId(body[1]); - expect(bodyToCompare1).to.eql(expectedRule); - expect(bodyToCompare2).to.eql(expectedRule2); + expect(bodyToCompare1).toEqual(expectedRule); + expect(bodyToCompare2).toEqual(expectedRule2); }); it('should update a single rule property of name using the auto-generated id', async () => { @@ -172,7 +203,7 @@ export default ({ getService }: FtrProviderContext) => { const expectedRule = updateUsername(outputRule, ELASTICSEARCH_USERNAME); const bodyToCompare = removeServerGeneratedProperties(body[0]); - expect(bodyToCompare).to.eql(expectedRule); + expect(bodyToCompare).toEqual(expectedRule); }); it('should change the revision of a rule when it updates enabled and another property', async () => { @@ -194,7 +225,7 @@ export default ({ getService }: FtrProviderContext) => { const expectedRule = updateUsername(outputRule, ELASTICSEARCH_USERNAME); const bodyToCompare = removeServerGeneratedProperties(body[0]); - expect(bodyToCompare).to.eql(expectedRule); + expect(bodyToCompare).toEqual(expectedRule); }); it('should change other properties when it does updates and effectively delete them such as timeline_title', async () => { @@ -221,7 +252,7 @@ export default ({ getService }: FtrProviderContext) => { const expectedRule = updateUsername(outputRule, ELASTICSEARCH_USERNAME); const bodyToCompare = removeServerGeneratedProperties(body[0]); - expect(bodyToCompare).to.eql(expectedRule); + expect(bodyToCompare).toEqual(expectedRule); }); it('should return a 200 but give a 404 in the message if it is given a fake id', async () => { @@ -233,7 +264,7 @@ export default ({ getService }: FtrProviderContext) => { .bulkUpdateRules({ body: [ruleUpdate] }) .expect(200); - expect(body).to.eql([ + expect(body).toEqual([ { id: '1fd52120-d3a9-4e7a-b23c-96c0e1a74ae5', error: { @@ -253,7 +284,7 @@ export default ({ getService }: FtrProviderContext) => { .bulkUpdateRules({ body: [ruleUpdate] }) .expect(200); - expect(body).to.eql([ + expect(body).toEqual([ { rule_id: 'fake_id', error: { status_code: 404, message: 'rule_id: "fake_id" not found' }, @@ -283,7 +314,7 @@ export default ({ getService }: FtrProviderContext) => { const expectedRule = updateUsername(outputRule, ELASTICSEARCH_USERNAME); const bodyToCompare = removeServerGeneratedProperties(body[0]); - expect([bodyToCompare, body[1]]).to.eql([ + expect([bodyToCompare, body[1]]).toEqual([ expectedRule, { error: { @@ -319,7 +350,7 @@ export default ({ getService }: FtrProviderContext) => { const expectedRule = updateUsername(outputRule, ELASTICSEARCH_USERNAME); const bodyToCompare = removeServerGeneratedProperties(body[0]); - expect([bodyToCompare, body[1]]).to.eql([ + expect([bodyToCompare, body[1]]).toEqual([ expectedRule, { error: { diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/common_flows.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/common_flows.cy.ts index c9d83ae4d67e1..a5903af58f1ee 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/common_flows.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/common_flows.cy.ts @@ -27,6 +27,7 @@ import { fillFrom, fillNote, fillReferenceUrls, + fillRelatedIntegrations, fillRiskScore, fillRuleName, fillRuleTags, @@ -59,6 +60,7 @@ describe('Common rule creation flows', { tags: ['@ess', '@serverless'] }, () => it('Creates and enables a rule', function () { cy.log('Filling define section'); importSavedQuery(this.timelineId); + fillRelatedIntegrations(); cy.get(DEFINE_CONTINUE_BUTTON).click(); cy.log('Filling about section'); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/related_integrations/related_integrations.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/related_integrations/related_integrations.cy.ts index 413504800c2a7..06e73f78ac2ad 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/related_integrations/related_integrations.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/related_integrations/related_integrations.cy.ts @@ -5,8 +5,10 @@ * 2.0. */ -import { omit } from 'lodash'; -import { PerformRuleInstallationResponseBody } from '@kbn/security-solution-plugin/common/api/detection_engine'; +import { + PerformRuleInstallationResponseBody, + RelatedIntegration, +} from '@kbn/security-solution-plugin/common/api/detection_engine'; import { generateEvent } from '../../../../objects/event'; import { createDocument, deleteDataStream } from '../../../../tasks/api_calls/elasticsearch'; import { createRuleAssetSavedObject } from '../../../../helpers/rules'; @@ -50,37 +52,59 @@ import { describe('Related integrations', { tags: ['@ess', '@serverless', '@skipInServerlessMKI'] }, () => { const DATA_STREAM_NAME = 'logs-related-integrations-test'; const PREBUILT_RULE_NAME = 'Prebuilt rule with related integrations'; - const RULE_RELATED_INTEGRATIONS: IntegrationDefinition[] = [ + const RELATED_INTEGRATIONS: RelatedIntegration[] = [ + { + package: 'auditd', + version: '1.16.0', + }, { package: 'aws', version: '1.17.0', integration: 'cloudfront', - installed: true, - enabled: true, }, { package: 'aws', version: '1.17.0', integration: 'cloudtrail', - installed: true, - enabled: false, }, { package: 'aws', version: '1.17.0', integration: 'unknown', - installed: false, - enabled: false, }, - { package: 'system', version: '1.17.0', installed: true, enabled: true }, + { package: 'system', version: '1.17.0' }, ]; const PREBUILT_RULE = createRuleAssetSavedObject({ name: PREBUILT_RULE_NAME, index: [DATA_STREAM_NAME], query: '*:*', rule_id: 'rule_1', - related_integrations: RULE_RELATED_INTEGRATIONS.map((x) => omit(x, ['installed', 'enabled'])), + related_integrations: RELATED_INTEGRATIONS, }); + const EXPECTED_RELATED_INTEGRATIONS: ExpectedRelatedIntegration[] = [ + { + title: 'Auditd Logs', + status: 'Not installed', + }, + { + title: 'AWS Amazon cloudfront', + status: 'Enabled', + }, + { + title: 'AWS Aws cloudtrail', + status: 'Disabled', + }, + { + title: 'Aws Unknown', + }, + { + title: 'System', + status: 'Enabled', + }, + ]; + const EXPECTED_KNOWN_RELATED_INTEGRATIONS = EXPECTED_RELATED_INTEGRATIONS.filter((x) => + Boolean(x.status) + ); beforeEach(() => { login(); @@ -99,7 +123,7 @@ describe('Related integrations', { tags: ['@ess', '@serverless', '@skipInServerl it('should display a badge with the installed integrations', () => { cy.get(INTEGRATIONS_POPOVER).should( 'have.text', - `0/${RULE_RELATED_INTEGRATIONS.length} integrations` + `0/${EXPECTED_RELATED_INTEGRATIONS.length} integrations` ); }); @@ -108,15 +132,19 @@ describe('Related integrations', { tags: ['@ess', '@serverless', '@skipInServerl cy.get(INTEGRATIONS_POPOVER_TITLE).should( 'have.text', - `[${RULE_RELATED_INTEGRATIONS.length}] Related integrations available` + `[${EXPECTED_RELATED_INTEGRATIONS.length}] Related integrations available` + ); + cy.get(INTEGRATION_LINK).should('have.length', EXPECTED_RELATED_INTEGRATIONS.length); + cy.get(INTEGRATION_STATUS).should( + 'have.length', + EXPECTED_KNOWN_RELATED_INTEGRATIONS.length ); - cy.get(INTEGRATION_LINK).should('have.length', RULE_RELATED_INTEGRATIONS.length); - cy.get(INTEGRATION_STATUS).should('have.length', RULE_RELATED_INTEGRATIONS.length); - RULE_RELATED_INTEGRATIONS.forEach((integration, index) => { - cy.get(INTEGRATION_LINK).eq(index).contains(getIntegrationName(integration), { - matchCase: false, - }); + EXPECTED_RELATED_INTEGRATIONS.forEach((expected, index) => { + cy.get(INTEGRATION_LINK).eq(index).contains(expected.title); + }); + + EXPECTED_KNOWN_RELATED_INTEGRATIONS.forEach((_, index) => { cy.get(INTEGRATION_STATUS).eq(index).should('have.text', 'Not installed'); }); }); @@ -128,13 +156,17 @@ describe('Related integrations', { tags: ['@ess', '@serverless', '@skipInServerl }); it('should display the integrations in the definition section', () => { - cy.get(INTEGRATION_LINK).should('have.length', RULE_RELATED_INTEGRATIONS.length); - cy.get(INTEGRATION_STATUS).should('have.length', RULE_RELATED_INTEGRATIONS.length); + cy.get(INTEGRATION_LINK).should('have.length', EXPECTED_RELATED_INTEGRATIONS.length); + cy.get(INTEGRATION_STATUS).should( + 'have.length', + EXPECTED_KNOWN_RELATED_INTEGRATIONS.length + ); - RULE_RELATED_INTEGRATIONS.forEach((integration, index) => { - cy.get(INTEGRATION_LINK).eq(index).contains(getIntegrationName(integration), { - matchCase: false, - }); + EXPECTED_RELATED_INTEGRATIONS.forEach((expected, index) => { + cy.get(INTEGRATION_LINK).eq(index).contains(expected.title); + }); + + EXPECTED_KNOWN_RELATED_INTEGRATIONS.forEach((_, index) => { cy.get(INTEGRATION_STATUS).eq(index).should('have.text', 'Not installed'); }); }); @@ -165,12 +197,9 @@ describe('Related integrations', { tags: ['@ess', '@serverless', '@skipInServerl }); it('should display a badge with the installed integrations', () => { - const enabledIntegrations = RULE_RELATED_INTEGRATIONS.filter((x) => x.enabled).length; - const totalIntegrations = RULE_RELATED_INTEGRATIONS.length; - cy.get(INTEGRATIONS_POPOVER).should( 'have.text', - `${enabledIntegrations}/${totalIntegrations} integrations` + `2/${EXPECTED_RELATED_INTEGRATIONS.length} integrations` ); }); @@ -179,18 +208,20 @@ describe('Related integrations', { tags: ['@ess', '@serverless', '@skipInServerl cy.get(INTEGRATIONS_POPOVER_TITLE).should( 'have.text', - `[${RULE_RELATED_INTEGRATIONS.length}] Related integrations available` + `[${EXPECTED_RELATED_INTEGRATIONS.length}] Related integrations available` + ); + cy.get(INTEGRATION_LINK).should('have.length', EXPECTED_RELATED_INTEGRATIONS.length); + cy.get(INTEGRATION_STATUS).should( + 'have.length', + EXPECTED_KNOWN_RELATED_INTEGRATIONS.length ); - cy.get(INTEGRATION_LINK).should('have.length', RULE_RELATED_INTEGRATIONS.length); - cy.get(INTEGRATION_STATUS).should('have.length', RULE_RELATED_INTEGRATIONS.length); - RULE_RELATED_INTEGRATIONS.forEach((integration, index) => { - cy.get(INTEGRATION_LINK).eq(index).contains(getIntegrationName(integration), { - matchCase: false, - }); - cy.get(INTEGRATION_STATUS) - .eq(index) - .should('have.text', getIntegrationStatus(integration)); + EXPECTED_RELATED_INTEGRATIONS.forEach((expected, index) => { + cy.get(INTEGRATION_LINK).eq(index).contains(expected.title); + }); + + EXPECTED_KNOWN_RELATED_INTEGRATIONS.forEach((expected, index) => { + cy.get(INTEGRATION_STATUS).eq(index).should('have.text', expected.status); }); }); }); @@ -202,16 +233,18 @@ describe('Related integrations', { tags: ['@ess', '@serverless', '@skipInServerl }); it('should display the integrations in the definition section', () => { - cy.get(INTEGRATION_LINK).should('have.length', RULE_RELATED_INTEGRATIONS.length); - cy.get(INTEGRATION_STATUS).should('have.length', RULE_RELATED_INTEGRATIONS.length); + cy.get(INTEGRATION_LINK).should('have.length', EXPECTED_RELATED_INTEGRATIONS.length); + cy.get(INTEGRATION_STATUS).should( + 'have.length', + EXPECTED_KNOWN_RELATED_INTEGRATIONS.length + ); - RULE_RELATED_INTEGRATIONS.forEach((integration, index) => { - cy.get(INTEGRATION_LINK).eq(index).contains(getIntegrationName(integration), { - matchCase: false, - }); - cy.get(INTEGRATION_STATUS) - .eq(index) - .should('have.text', getIntegrationStatus(integration)); + EXPECTED_RELATED_INTEGRATIONS.forEach((expected, index) => { + cy.get(INTEGRATION_LINK).eq(index).contains(expected.title); + }); + + EXPECTED_KNOWN_RELATED_INTEGRATIONS.forEach((expected, index) => { + cy.get(INTEGRATION_STATUS).eq(index).should('have.text', expected.status); }); }); @@ -230,9 +263,7 @@ describe('Related integrations', { tags: ['@ess', '@serverless', '@skipInServerl size: 1, }).then((alertsResponse) => { expect(alertsResponse.body.hits.hits[0].fields).to.deep.equal({ - [RELATED_INTEGRATION_FIELD]: RULE_RELATED_INTEGRATIONS.map((x) => - omit(x, ['installed', 'enabled']) - ), + [RELATED_INTEGRATION_FIELD]: RELATED_INTEGRATIONS, }); }); }); @@ -263,13 +294,17 @@ describe('Related integrations', { tags: ['@ess', '@serverless', '@skipInServerl }); it('should display the integrations in the definition section', () => { - cy.get(INTEGRATION_LINK).should('have.length', RULE_RELATED_INTEGRATIONS.length); - cy.get(INTEGRATION_STATUS).should('have.length', RULE_RELATED_INTEGRATIONS.length); + cy.get(INTEGRATION_LINK).should('have.length', EXPECTED_RELATED_INTEGRATIONS.length); + cy.get(INTEGRATION_STATUS).should( + 'have.length', + EXPECTED_KNOWN_RELATED_INTEGRATIONS.length + ); - RULE_RELATED_INTEGRATIONS.forEach((integration, index) => { - cy.get(INTEGRATION_LINK).eq(index).contains(getIntegrationName(integration), { - matchCase: false, - }); + EXPECTED_RELATED_INTEGRATIONS.forEach((expected, index) => { + cy.get(INTEGRATION_LINK).eq(index).contains(expected.title); + }); + + EXPECTED_KNOWN_RELATED_INTEGRATIONS.forEach((_, index) => { cy.get(INTEGRATION_STATUS).eq(index).should('have.text', 'Not installed'); }); }); @@ -290,22 +325,9 @@ function visitFirstInstalledPrebuiltRuleDetailsPage(): void { ).then((response) => visitRuleDetailsPage(response.body.results.created[0].id)); } -interface IntegrationDefinition { - package: string; - version: string; - installed: boolean; - enabled: boolean; - integration?: string; -} - -function getIntegrationName(integration: IntegrationDefinition): string { - return `${integration.package} ${integration.integration ?? ''}`.trim(); -} - -function getIntegrationStatus(integration: IntegrationDefinition): string { - return `${integration.installed ? 'Installed' : 'Not installed'}${ - integration.enabled ? ': enabled' : '' - }`.trim(); +interface ExpectedRelatedIntegration { + title: string; + status?: string; } /** diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/entity_flyout.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/entity_flyout.cy.ts index 0b98ae36f1ec9..9ccedac0c2504 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/entity_flyout.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/entity_flyout.cy.ts @@ -32,7 +32,7 @@ import { ENTITY_DETAILS_FLYOUT_ASSET_CRITICALITY_SELECTOR, } from '../../screens/asset_criticality/flyouts'; import { deleteCriticality } from '../../tasks/api_calls/entity_analytics'; -import { mockFleetInstalledIntegrations } from '../../tasks/fleet_integrations'; +import { mockFleetIntegrations } from '../../tasks/fleet_integrations'; import { expandManagedDataEntraPanel, expandManagedDataOktaPanel, @@ -146,18 +146,22 @@ describe( // https://github.com/elastic/kibana/issues/179248 describe('Managed data section', { tags: ['@skipInServerlessMKI'] }, () => { beforeEach(() => { - mockFleetInstalledIntegrations([ + mockFleetIntegrations([ { package_name: ENTRA_ID_PACKAGE_NAME, - is_enabled: true, package_title: 'azure entra', - package_version: 'test_package_version', + latest_package_version: 'test_package_version', + installed_package_version: 'test_package_version', + is_installed: true, + is_enabled: true, }, { package_name: OKTA_PACKAGE_NAME, - is_enabled: true, package_title: 'okta', - package_version: 'test_package_version', + latest_package_version: 'test_package_version', + installed_package_version: 'test_package_version', + is_installed: true, + is_enabled: true, }, ]); }); diff --git a/x-pack/test/security_solution_cypress/cypress/screens/common.ts b/x-pack/test/security_solution_cypress/cypress/screens/common.ts index 3d6aae9785018..b121badc9e20d 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/common.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/common.ts @@ -8,3 +8,5 @@ export const TOOLTIP = '[role="tooltip"]'; export const BASIC_TABLE_LOADING = '.euiBasicTable.euiBasicTable-loading'; + +export const COMBO_BOX_OPTION = '.euiComboBoxOptionsList button[role="option"]'; diff --git a/x-pack/test/security_solution_cypress/cypress/screens/create_new_rule.ts b/x-pack/test/security_solution_cypress/cypress/screens/create_new_rule.ts index ac474a56bd5b3..bf88869973cee 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/create_new_rule.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/create_new_rule.ts @@ -125,6 +125,9 @@ export const EQL_OPTIONS_TIMESTAMP_INPUT = export const IMPORT_QUERY_FROM_SAVED_TIMELINE_LINK = '[data-test-subj="importQueryFromSavedTimeline"]'; +export const RELATED_INTEGRATION_COMBO_BOX_INPUT = + '[data-test-subj="relatedIntegrationComboBox"] [data-test-subj="comboBoxSearchInput"]'; + export const INDICATOR_MATCH_TYPE = '[data-test-subj="threatMatchRuleType"]'; export const INPUT = '[data-test-subj="input"]'; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/create_new_rule.ts b/x-pack/test/security_solution_cypress/cypress/tasks/create_new_rule.ts index 091d592dd04ae..f40cecee5a981 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/create_new_rule.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/create_new_rule.ts @@ -125,6 +125,7 @@ import { ALERTS_INDEX_BUTTON, INVESTIGATIONS_INPUT, QUERY_BAR_ADD_FILTER, + RELATED_INTEGRATION_COMBO_BOX_INPUT, } from '../screens/create_new_rule'; import { INDEX_SELECTOR, @@ -147,7 +148,7 @@ import { ruleFields } from '../data/detection_engine'; import { waitForAlerts } from './alerts'; import { refreshPage } from './security_header'; import { EMPTY_ALERT_TABLE } from '../screens/alerts'; -import { TOOLTIP } from '../screens/common'; +import { COMBO_BOX_OPTION, TOOLTIP } from '../screens/common'; export const createAndEnableRule = () => { cy.get(CREATE_AND_ENABLE_BTN).click(); @@ -272,6 +273,17 @@ export const importSavedQuery = (timelineId: string) => { removeAlertsIndex(); }; +export const fillRelatedIntegrations = (): void => { + addFirstIntegration(); + addFirstIntegration(); +}; + +const addFirstIntegration = (): void => { + cy.get('button').contains('Add integration').click(); + cy.get(RELATED_INTEGRATION_COMBO_BOX_INPUT).last().should('be.enabled').click(); + cy.get(COMBO_BOX_OPTION).first().click(); +}; + export const fillRuleName = (ruleName: string = ruleFields.ruleName) => { cy.get(RULE_NAME_INPUT).clear({ force: true }); cy.get(RULE_NAME_INPUT).type(ruleName, { force: true }); diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/fleet_integrations.ts b/x-pack/test/security_solution_cypress/cypress/tasks/fleet_integrations.ts index b60c6a8c5622f..7105ebc4df70f 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/fleet_integrations.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/fleet_integrations.ts @@ -6,19 +6,19 @@ */ import { - GET_INSTALLED_INTEGRATIONS_URL, - InstalledIntegration, + GET_ALL_INTEGRATIONS_URL, + Integration, } from '@kbn/security-solution-plugin/common/api/detection_engine'; import { login } from './login'; import { visitGetStartedPage } from './navigation'; -export const mockFleetInstalledIntegrations = (integrations: InstalledIntegration[] = []) => { - cy.intercept('GET', `${GET_INSTALLED_INTEGRATIONS_URL}*`, { +export const mockFleetIntegrations = (integrations: Integration[] = []) => { + cy.intercept('GET', `${GET_ALL_INTEGRATIONS_URL}*`, { statusCode: 200, body: { - installed_integrations: integrations, + integrations, }, - }).as('installedIntegrations'); + }).as('integrations'); }; export const waitForFleetSetup = () => {