diff --git a/.buildkite/ftr_configs.yml b/.buildkite/ftr_configs.yml index f16b0e46ada953..aa35797d1f986a 100644 --- a/.buildkite/ftr_configs.yml +++ b/.buildkite/ftr_configs.yml @@ -176,6 +176,7 @@ enabled: - x-pack/test/functional/apps/lens/group1/config.ts - x-pack/test/functional/apps/lens/group2/config.ts - x-pack/test/functional/apps/lens/group3/config.ts + - x-pack/test/functional/apps/lens/open_in_lens/config.ts - x-pack/test/functional/apps/license_management/config.ts - x-pack/test/functional/apps/logstash/config.ts - x-pack/test/functional/apps/management/config.ts diff --git a/.buildkite/pipeline-utils/ci-stats/pick_test_group_run_order.ts b/.buildkite/pipeline-utils/ci-stats/pick_test_group_run_order.ts index c326e979b93db4..b7c223b3ca595b 100644 --- a/.buildkite/pipeline-utils/ci-stats/pick_test_group_run_order.ts +++ b/.buildkite/pipeline-utils/ci-stats/pick_test_group_run_order.ts @@ -59,11 +59,15 @@ function getRunGroups(bk: BuildkiteClient, allTypes: RunGroup[], typeName: strin if (tooLongs.length > 0) { bk.setAnnotation( `test-group-too-long:${typeName}`, - 'error', + 'warning', [ tooLongs.length === 1 - ? `The following "${typeName}" config has a duration that exceeds the maximum amount of time desired for a single CI job. Please split it up.` - : `The following "${typeName}" configs have durations that exceed the maximum amount of time desired for a single CI job. Please split them up.`, + ? `The following "${typeName}" config has a duration that exceeds the maximum amount of time desired for a single CI job. ` + + `This is not an error, and if you don't own this config then you can ignore this warning. ` + + `If you own this config please split it up ASAP and ask Operations if you have questions about how to do that.` + : `The following "${typeName}" configs have durations that exceed the maximum amount of time desired for a single CI job. ` + + `This is not an error, and if you don't own any of these configs then you can ignore this warning.` + + `If you own any of these configs please split them up ASAP and ask Operations if you have questions about how to do that.`, '', ...tooLongs.map(({ config, durationMin }) => ` - ${config}: ${durationMin} minutes`), ].join('\n') diff --git a/docs/settings/reporting-settings.asciidoc b/docs/settings/reporting-settings.asciidoc index afdfbdfd02eb07..90ea49fd281fe9 100644 --- a/docs/settings/reporting-settings.asciidoc +++ b/docs/settings/reporting-settings.asciidoc @@ -83,7 +83,9 @@ security is enabled, < { expect(wrapper.getByTestId('fieldAutocompleteComboBox')).toHaveTextContent('_source') ); }); + + it('it allows custom user input if "acceptsCustomOptions" is "true"', async () => { + const mockOnChange = jest.fn(); + const wrapper = render( + + ); + + const fieldAutocompleteComboBox = wrapper.getByTestId('comboBoxSearchInput'); + fireEvent.change(fieldAutocompleteComboBox, { target: { value: 'custom' } }); + await waitFor(() => + expect(wrapper.getByTestId('fieldAutocompleteComboBox')).toHaveTextContent('custom') + ); + }); }); diff --git a/packages/kbn-securitysolution-autocomplete/src/field/__tests__/use_field.test.ts b/packages/kbn-securitysolution-autocomplete/src/field/__tests__/use_field.test.ts index 68748bf82a20f2..d060f585e91186 100644 --- a/packages/kbn-securitysolution-autocomplete/src/field/__tests__/use_field.test.ts +++ b/packages/kbn-securitysolution-autocomplete/src/field/__tests__/use_field.test.ts @@ -346,6 +346,18 @@ describe('useField', () => { ]); }); }); + it('should invoke onChange with custom option if one is sent', () => { + const { result } = renderHook(() => useField({ indexPattern, onChange: onChangeMock })); + act(() => { + result.current.handleCreateCustomOption('madeUpField'); + expect(onChangeMock).toHaveBeenCalledWith([ + { + name: 'madeUpField', + type: 'text', + }, + ]); + }); + }); }); describe('fieldWidth', () => { diff --git a/packages/kbn-securitysolution-autocomplete/src/field/index.tsx b/packages/kbn-securitysolution-autocomplete/src/field/index.tsx index dad13434779e79..704433b79560b8 100644 --- a/packages/kbn-securitysolution-autocomplete/src/field/index.tsx +++ b/packages/kbn-securitysolution-autocomplete/src/field/index.tsx @@ -25,6 +25,7 @@ export const FieldComponent: React.FC = ({ onChange, placeholder, selectedField, + acceptsCustomOptions = false, }): JSX.Element => { const { isInvalid, @@ -35,6 +36,7 @@ export const FieldComponent: React.FC = ({ renderFields, handleTouch, handleValuesChange, + handleCreateCustomOption, } = useField({ indexPattern, fieldTypeFilter, @@ -43,6 +45,29 @@ export const FieldComponent: React.FC = ({ fieldInputWidth, onChange, }); + + if (acceptsCustomOptions) { + return ( + + ); + } + return ( { const [touched, setIsTouched] = useState(false); - const { availableFields, selectedFields } = useMemo( - () => getComboBoxFields(indexPattern, selectedField, fieldTypeFilter), - [indexPattern, fieldTypeFilter, selectedField] - ); + const [customOption, setCustomOption] = useState(null); + + const { availableFields, selectedFields } = useMemo(() => { + const indexPatternsToUse = + customOption != null && indexPattern != null + ? { ...indexPattern, fields: [...indexPattern?.fields, customOption] } + : indexPattern; + return getComboBoxFields(indexPatternsToUse, selectedField, fieldTypeFilter); + }, [indexPattern, fieldTypeFilter, selectedField, customOption]); const { comboOptions, labels, selectedComboOptions, disabledLabelTooltipTexts } = useMemo( () => getComboBoxProps({ availableFields, selectedFields }), @@ -117,6 +122,19 @@ export const useField = ({ [availableFields, labels, onChange] ); + const handleCreateCustomOption = useCallback( + (val: string) => { + const normalizedSearchValue = val.trim().toLowerCase(); + + if (!normalizedSearchValue) { + return; + } + setCustomOption({ name: val, type: 'text' }); + onChange([{ name: val, type: 'text' }]); + }, + [onChange] + ); + const handleTouch = useCallback((): void => { setIsTouched(true); }, [setIsTouched]); @@ -161,5 +179,6 @@ export const useField = ({ renderFields, handleTouch, handleValuesChange, + handleCreateCustomOption, }; }; diff --git a/packages/kbn-securitysolution-exception-list-components/README.md b/packages/kbn-securitysolution-exception-list-components/README.md index e23b85e4099609..fe23c66e8988e6 100644 --- a/packages/kbn-securitysolution-exception-list-components/README.md +++ b/packages/kbn-securitysolution-exception-list-components/README.md @@ -1,11 +1,11 @@ # @kbn/securitysolution-exception-list-components -This is where the building UI components of the Exception-List live -Most of the components here are imported from `x-pack/plugins/security_solutions/public/detection_engine` +Common exceptions' components # Aim -TODO +- To have most of the Exceptions' components in one place, to be shared accross multiple pages and used for different logic. +- This `package` holds the presetational part of the components only as the API or the logic part should reside under the consumer page # Pattern used @@ -14,9 +14,19 @@ component index.tsx index.styles.ts <-- to hold styles if the component has many custom styles use_component.ts <-- for logic if the Presentational Component has logic - index.test.tsx + component.test.tsx use_component.test.tsx + ``` +# Testing + +In order to unify our testing tools, we configured only two libraries, the `React-Testing-Library` to test the component UI part and the `Reat-Testing-Hooks` to test the component's UI interactions + +# Styling + +In order to follow the `KBN-Packages's` recommendations, to define a custom CSS we can only use the `@emotion/react` or `@emotion/css` libraries + + # Next diff --git a/packages/kbn-securitysolution-exception-list-components/index.ts b/packages/kbn-securitysolution-exception-list-components/index.ts index f5001ff35fd334..6b11782b574dc9 100644 --- a/packages/kbn-securitysolution-exception-list-components/index.ts +++ b/packages/kbn-securitysolution-exception-list-components/index.ts @@ -6,11 +6,13 @@ * Side Public License, v 1. */ -export * from './src/search_bar/search_bar'; -export * from './src/empty_viewer_state/empty_viewer_state'; +export * from './src/search_bar'; +export * from './src/empty_viewer_state'; export * from './src/pagination/pagination'; // export * from './src/exceptions_utility/exceptions_utility'; -export * from './src/exception_items/exception_items'; +export * from './src/exception_items'; export * from './src/exception_item_card'; export * from './src/value_with_space_warning'; export * from './src/types'; +export * from './src/list_header'; +export * from './src/header_menu'; diff --git a/packages/kbn-securitysolution-exception-list-components/jest.config.js b/packages/kbn-securitysolution-exception-list-components/jest.config.js index 37a11c23c75baa..00f407dce42fb7 100644 --- a/packages/kbn-securitysolution-exception-list-components/jest.config.js +++ b/packages/kbn-securitysolution-exception-list-components/jest.config.js @@ -14,6 +14,13 @@ module.exports = { collectCoverageFrom: [ '/packages/kbn-securitysolution-exception-list-components/**/*.{ts,tsx}', '!/packages/kbn-securitysolution-exception-list-components/**/*.test', + '!/packages/kbn-securitysolution-exception-list-components/**/types/*', + '!/packages/kbn-securitysolution-exception-list-components/**/*.type', + '!/packages/kbn-securitysolution-exception-list-components/**/*.styles', + '!/packages/kbn-securitysolution-exception-list-components/**/mocks/*', + '!/packages/kbn-securitysolution-exception-list-components/**/*.config', + '!/packages/kbn-securitysolution-exception-list-components/**/translations', + '!/packages/kbn-securitysolution-exception-list-components/**/types/*', ], setupFilesAfterEnv: [ '/packages/kbn-securitysolution-exception-list-components/setup_test.ts', diff --git a/packages/kbn-securitysolution-exception-list-components/src/empty_viewer_state/empty_viewer_state.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/empty_viewer_state/empty_viewer_state.test.tsx index 43943e0e8fb973..3e883a0162b6f1 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/empty_viewer_state/empty_viewer_state.test.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/empty_viewer_state/empty_viewer_state.test.tsx @@ -9,8 +9,9 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { EmptyViewerState } from './empty_viewer_state'; +import { EmptyViewerState } from '.'; import { ListTypeText, ViewerStatus } from '../types'; +import * as i18n from '../translations'; describe('EmptyViewerState', () => { it('it should render "error" with the default title and body', () => { @@ -23,10 +24,10 @@ describe('EmptyViewerState', () => { ); expect(wrapper.getByTestId('errorViewerState')).toBeTruthy(); - expect(wrapper.getByTestId('errorTitle')).toHaveTextContent('Unable to load exception items'); - expect(wrapper.getByTestId('errorBody')).toHaveTextContent( - 'There was an error loading the exception items. Contact your administrator for help.' + expect(wrapper.getByTestId('errorTitle')).toHaveTextContent( + i18n.EMPTY_VIEWER_STATE_ERROR_TITLE ); + expect(wrapper.getByTestId('errorBody')).toHaveTextContent(i18n.EMPTY_VIEWER_STATE_ERROR_BODY); }); it('it should render "error" when sending the title and body props', () => { const wrapper = render( @@ -65,9 +66,11 @@ describe('EmptyViewerState', () => { expect(wrapper.getByTestId('emptySearchViewerState')).toBeTruthy(); expect(wrapper.getByTestId('emptySearchTitle')).toHaveTextContent( - 'No results match your search criteria' + i18n.EMPTY_VIEWER_STATE_EMPTY_SEARCH_TITLE + ); + expect(wrapper.getByTestId('emptySearchBody')).toHaveTextContent( + i18n.EMPTY_VIEWER_STATE_EMPTY_SEARCH_BODY ); - expect(wrapper.getByTestId('emptySearchBody')).toHaveTextContent('Try modifying your search'); }); it('it should render empty search when sending title and body props', () => { const wrapper = render( @@ -111,11 +114,11 @@ describe('EmptyViewerState', () => { const { getByTestId } = wrapper; expect(getByTestId('emptyViewerState')).toBeTruthy(); - expect(getByTestId('emptyTitle')).toHaveTextContent('Add exceptions to this rule'); - expect(getByTestId('emptyBody')).toHaveTextContent( - 'There is no exception in your rule. Create your first rule exception.' + expect(getByTestId('emptyTitle')).toHaveTextContent(i18n.EMPTY_VIEWER_STATE_EMPTY_TITLE); + expect(getByTestId('emptyBody')).toHaveTextContent(i18n.EMPTY_VIEWER_STATE_EMPTY_BODY); + expect(getByTestId('emptyStateButton')).toHaveTextContent( + i18n.EMPTY_VIEWER_STATE_EMPTY_VIEWER_BUTTON('rule') ); - expect(getByTestId('emptyStateButton')).toHaveTextContent('Create rule exception'); }); it('it should render no items screen with default title and body props and listType endPoint', () => { const wrapper = render( @@ -129,10 +132,29 @@ describe('EmptyViewerState', () => { const { getByTestId } = wrapper; expect(getByTestId('emptyViewerState')).toBeTruthy(); - expect(getByTestId('emptyTitle')).toHaveTextContent('Add exceptions to this rule'); - expect(getByTestId('emptyBody')).toHaveTextContent( - 'There is no exception in your rule. Create your first rule exception.' + expect(getByTestId('emptyTitle')).toHaveTextContent(i18n.EMPTY_VIEWER_STATE_EMPTY_TITLE); + expect(getByTestId('emptyBody')).toHaveTextContent(i18n.EMPTY_VIEWER_STATE_EMPTY_BODY); + expect(getByTestId('emptyStateButton')).toHaveTextContent( + i18n.EMPTY_VIEWER_STATE_EMPTY_VIEWER_BUTTON(ListTypeText.ENDPOINT) + ); + }); + it('it should render no items screen and disable the Create exception button if isReadOnly true', () => { + const wrapper = render( + + ); + + const { getByTestId } = wrapper; + expect(getByTestId('emptyViewerState')).toBeTruthy(); + expect(getByTestId('emptyTitle')).toHaveTextContent(i18n.EMPTY_VIEWER_STATE_EMPTY_TITLE); + expect(getByTestId('emptyBody')).toHaveTextContent(i18n.EMPTY_VIEWER_STATE_EMPTY_BODY); + expect(getByTestId('emptyStateButton')).toHaveTextContent( + i18n.EMPTY_VIEWER_STATE_EMPTY_VIEWER_BUTTON(ListTypeText.ENDPOINT) ); - expect(getByTestId('emptyStateButton')).toHaveTextContent('Create endpoint exception'); + expect(getByTestId('emptyStateButton')).toBeDisabled(); }); }); diff --git a/packages/kbn-securitysolution-exception-list-components/src/empty_viewer_state/empty_viewer_state.tsx b/packages/kbn-securitysolution-exception-list-components/src/empty_viewer_state/index.tsx similarity index 100% rename from packages/kbn-securitysolution-exception-list-components/src/empty_viewer_state/empty_viewer_state.tsx rename to packages/kbn-securitysolution-exception-list-components/src/empty_viewer_state/index.tsx diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/comments/__snapshots__/comments.test.tsx.snap b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/comments/__snapshots__/comments.test.tsx.snap new file mode 100644 index 00000000000000..5c0dba4573e068 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/comments/__snapshots__/comments.test.tsx.snap @@ -0,0 +1,667 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ExceptionItemCardComments should render comments panel closed 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ +
+
+
+
+
+
    +
  1. +
    +
    + +
    +
    +
    +
    +
    +

    + some old comment +

    +
    +
    +
    +
  2. +
  3. +
    +
    + +
    +
    +
    +
    +
    +

    + some old comment +

    +
    +
    +
    +
  4. +
+
+
+
+
+
+
+
+ , + "container":
+
+
+
+ +
+
+
+
+
+
    +
  1. +
    +
    + +
    +
    +
    +
    +
    +

    + some old comment +

    +
    +
    +
    +
  2. +
  3. +
    +
    + +
    +
    +
    +
    +
    +

    + some old comment +

    +
    +
    +
    +
  4. +
+
+
+
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`ExceptionItemCardComments should render comments panel opened when accordion is clicked 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ +
+
+
+
+
+
    +
  1. +
    +
    + +
    +
    +
    +
    +
    +

    + some old comment +

    +
    +
    +
    +
  2. +
  3. +
    +
    + +
    +
    +
    +
    +
    +

    + some old comment +

    +
    +
    +
    +
  4. +
+
+
+
+
+
+
+
+ , + "container":
+
+
+
+ +
+
+
+
+
+
    +
  1. +
    +
    + +
    +
    +
    +
    +
    +

    + some old comment +

    +
    +
    +
    +
  2. +
  3. +
    +
    + +
    +
    +
    +
    +
    +

    + some old comment +

    +
    +
    +
    +
  4. +
+
+
+
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/comments/comments.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/comments/comments.test.tsx new file mode 100644 index 00000000000000..64b2d1c9e3a83d --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/comments/comments.test.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { mockGetFormattedComments } from '../../mocks/comments.mock'; +import { ExceptionItemCardComments } from '.'; +import * as i18n from '../translations'; + +const comments = mockGetFormattedComments(); +describe('ExceptionItemCardComments', () => { + it('should render comments panel closed', () => { + const wrapper = render( + + ); + expect(wrapper).toMatchSnapshot(); + + expect(wrapper.getByTestId('ExceptionItemCardCommentsContainer')).toHaveTextContent( + i18n.exceptionItemCardCommentsAccordion(comments.length) + ); + expect(wrapper.getByTestId('accordionContentPanel')).not.toBeVisible(); + }); + + it('should render comments panel opened when accordion is clicked', () => { + const wrapper = render( + + ); + + const container = wrapper.getByTestId('ExceptionItemCardCommentsContainerTextButton'); + fireEvent.click(container); + expect(wrapper.getByTestId('accordionContentPanel')).toBeVisible(); + expect(wrapper.getByTestId('accordionCommentList')).toBeVisible(); + expect(wrapper.getByTestId('accordionCommentList')).toHaveTextContent('some old comment'); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/comments/comments.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/comments/index.tsx similarity index 54% rename from packages/kbn-securitysolution-exception-list-components/src/exception_item_card/comments/comments.tsx rename to packages/kbn-securitysolution-exception-list-components/src/exception_item_card/comments/index.tsx index ca08d10f0a049a..8d697fd6e1f8a2 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/comments/comments.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/comments/index.tsx @@ -19,27 +19,30 @@ const accordionCss = css` export interface ExceptionItemCardCommentsProps { comments: EuiCommentProps[]; + dataTestSubj?: string; } -export const ExceptionItemCardComments = memo(({ comments }) => { - return ( - - - {i18n.exceptionItemCardCommentsAccordion(comments.length)} - - } - arrowDisplay="none" - data-test-subj="exceptionsViewerCommentAccordion" - > - - - - - - ); -}); +export const ExceptionItemCardComments = memo( + ({ comments, dataTestSubj }) => { + return ( + + + {i18n.exceptionItemCardCommentsAccordion(comments.length)} + + } + arrowDisplay="none" + data-test-subj="exceptionItemCardComments" + > + + + + + + ); + } +); ExceptionItemCardComments.displayName = 'ExceptionItemCardComments'; diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/conditions.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/conditions.test.tsx index ae4b76a4a7dc0b..73eb10fce68d83 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/conditions.test.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/conditions.test.tsx @@ -9,7 +9,7 @@ import { render } from '@testing-library/react'; import React from 'react'; -import { ExceptionItemCardConditions } from './conditions'; +import { ExceptionItemCardConditions } from '.'; interface TestEntry { field: string; diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/__snapshots__/entry_content.test.tsx.snap b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/__snapshots__/entry_content.test.tsx.snap new file mode 100644 index 00000000000000..4f35694645fde0 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/__snapshots__/entry_content.test.tsx.snap @@ -0,0 +1,164 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EntryContent should render a nested value 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ + + +
+ + + + + + + + included in + + + + list_id + + +
+
+
+
+
+ , + "container":
+
+
+
+ + + +
+ + + + + + + + included in + + + + list_id + + +
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/entry_content.helper.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/entry_content.helper.test.tsx new file mode 100644 index 00000000000000..d30cf9fa2f1d24 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/entry_content.helper.test.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { ListOperatorTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; +import { OPERATOR_TYPE_LABELS_EXCLUDED, OPERATOR_TYPE_LABELS_INCLUDED } from '../conditions.config'; +import { getEntryOperator, getValue, getValueExpression } from './entry_content.helper'; +import { render } from '@testing-library/react'; +import { + includedExistsTypeEntry, + includedListTypeEntry, + includedMatchTypeEntry, +} from '../../../mocks/entry.mock'; + +describe('entry_content.helper', () => { + describe('getEntryOperator', () => { + it('should return empty if type is nested', () => { + const result = getEntryOperator(ListOperatorTypeEnum.NESTED, 'included'); + expect(result).toBeFalsy(); + expect(result).toEqual(''); + }); + it('should return the correct labels for OPERATOR_TYPE_LABELS_INCLUDED when operator is included', () => { + const allKeys = Object.keys(OPERATOR_TYPE_LABELS_INCLUDED); + const [, ...withoutNested] = allKeys; + withoutNested.forEach((key) => { + const result = getEntryOperator(key as ListOperatorTypeEnum, 'included'); + const expectedLabel = OPERATOR_TYPE_LABELS_INCLUDED[key as ListOperatorTypeEnum]; + expect(result).toEqual(expectedLabel); + }); + }); + it('should return the correct labels for OPERATOR_TYPE_LABELS_EXCLUDED when operator is excluded', () => { + const allKeys = Object.keys(OPERATOR_TYPE_LABELS_EXCLUDED); + const [, ...withoutNested] = allKeys; + withoutNested.forEach((key) => { + const result = getEntryOperator(key as ListOperatorTypeEnum, 'excluded'); + const expectedLabel = + OPERATOR_TYPE_LABELS_EXCLUDED[ + key as Exclude + ]; + expect(result).toEqual(expectedLabel); + }); + }); + it('should return the type when it is neither OPERATOR_TYPE_LABELS_INCLUDED nor OPERATOR_TYPE_LABELS_EXCLUDED', () => { + const result = getEntryOperator('test' as ListOperatorTypeEnum, 'included'); + expect(result).toEqual('test'); + }); + }); + describe('getValue', () => { + it('should return list.id when entry type is "list"', () => { + expect(getValue(includedListTypeEntry)).toEqual('list_id'); + }); + it('should return value when entry type is not "list"', () => { + expect(getValue(includedMatchTypeEntry)).toEqual('matches value'); + }); + it('should return empty string when type does not have value', () => { + expect(getValue(includedExistsTypeEntry)).toEqual(''); + }); + }); + describe('getValueExpression', () => { + it('should render multiple values in badges when operator type is match_any and values is Array', () => { + const wrapper = render( + getValueExpression(ListOperatorTypeEnum.MATCH_ANY, 'included', ['value 1', 'value 2']) + ); + expect(wrapper.getByTestId('matchAnyBadge0')).toHaveTextContent('value 1'); + expect(wrapper.getByTestId('matchAnyBadge1')).toHaveTextContent('value 2'); + }); + it('should return one value when operator type is match_any and values is not Array', () => { + const wrapper = render( + getValueExpression(ListOperatorTypeEnum.MATCH_ANY, 'included', 'value 1') + ); + expect(wrapper.getByTestId('entryValueExpression')).toHaveTextContent('value 1'); + }); + it('should return one value when operator type is a single value', () => { + const wrapper = render( + getValueExpression(ListOperatorTypeEnum.EXISTS, 'included', 'value 1') + ); + expect(wrapper.getByTestId('entryValueExpression')).toHaveTextContent('value 1'); + }); + it('should return value with warning icon when the value contains a leading or trailing space', () => { + const wrapper = render( + getValueExpression(ListOperatorTypeEnum.EXISTS, 'included', ' value 1') + ); + expect(wrapper.getByTestId('entryValueExpression')).toHaveTextContent(' value 1'); + expect(wrapper.getByTestId('valueWithSpaceWarningTooltip')).toBeInTheDocument(); + }); + it('should return value without warning icon when the value does not contain a leading or trailing space', () => { + const wrapper = render( + getValueExpression(ListOperatorTypeEnum.EXISTS, 'included', 'value 1') + ); + expect(wrapper.getByTestId('entryValueExpression')).toHaveTextContent(' value 1'); + expect(wrapper.queryByTestId('valueWithSpaceWarningTooltip')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/entry_content.helper.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/entry_content.helper.tsx index 6a64bcc810c083..e8d02dace3a815 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/entry_content.helper.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/entry_content.helper.tsx @@ -15,7 +15,11 @@ import type { Entry } from '../types'; const getEntryValue = (type: string, value?: string | string[]) => { if (type === 'match_any' && Array.isArray(value)) { - return value.map((currentValue) => {currentValue}); + return value.map((currentValue, index) => ( + + {currentValue} + + )); } return value ?? ''; }; @@ -42,6 +46,7 @@ export const getValueExpression = ( diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/entry_content.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/entry_content.test.tsx new file mode 100644 index 00000000000000..7e02e19d618449 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/entry_content.test.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { render } from '@testing-library/react'; +import { includedListTypeEntry } from '../../../mocks/entry.mock'; +import * as i18n from '../../translations'; +import { EntryContent } from '.'; + +describe('EntryContent', () => { + it('should render a single value without AND when index is 0', () => { + const wrapper = render( + + ); + expect(wrapper.getByTestId('EntryContentSingleEntry')).toBeInTheDocument(); + expect(wrapper.getByTestId('entryValueExpression')).toHaveTextContent('list_id'); + }); + it('should render a single value with AND when index is 1', () => { + const wrapper = render( + + ); + expect(wrapper.getByTestId('EntryContentSingleEntry')).toBeInTheDocument(); + expect(wrapper.getByTestId('entryValueExpression')).toHaveTextContent('list_id'); + expect(wrapper.getByText(i18n.CONDITION_AND)).toBeInTheDocument(); + }); + it('should render a nested value', () => { + const wrapper = render( + + ); + expect(wrapper.getByTestId('EntryContentNestedEntry')).toBeInTheDocument(); + expect(wrapper.getByTestId('nstedEntryIcon')).toBeInTheDocument(); + expect(wrapper.getByTestId('entryValueExpression')).toHaveTextContent('list_id'); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/entry_content.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/index.tsx similarity index 79% rename from packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/entry_content.tsx rename to packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/index.tsx index 6c321a6d0ce041..8a5fe0b998fa48 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/entry_content.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/entry_content/index.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { memo } from 'react'; +import React, { FC, memo } from 'react'; import { EuiExpression, EuiToken, EuiFlexGroup } from '@elastic/eui'; import { ListOperatorTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import { @@ -18,18 +18,15 @@ import type { Entry } from '../types'; import * as i18n from '../../translations'; import { getValue, getValueExpression } from './entry_content.helper'; -export const EntryContent = memo( - ({ - entry, - index, - isNestedEntry = false, - dataTestSubj, - }: { - entry: Entry; - index: number; - isNestedEntry?: boolean; - dataTestSubj?: string; - }) => { +interface EntryContentProps { + entry: Entry; + index: number; + isNestedEntry?: boolean; + dataTestSubj?: string; +} + +export const EntryContent: FC = memo( + ({ entry, index, isNestedEntry = false, dataTestSubj }) => { const { field, type } = entry; const value = getValue(entry); const operator = 'operator' in entry ? entry.operator : ''; @@ -40,12 +37,14 @@ export const EntryContent = memo(
{isNestedEntry ? ( - +
@@ -58,6 +57,7 @@ export const EntryContent = memo( description={index === 0 ? '' : i18n.CONDITION_AND} value={field} color={index === 0 ? 'primary' : 'subdued'} + data-test-subj={`${dataTestSubj || ''}SingleEntry`} /> {getValueExpression(type as ListOperatorTypeEnum, operator, value)} diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/conditions.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/index.tsx similarity index 94% rename from packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/conditions.tsx rename to packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/index.tsx index 8b85a7343afc18..28d9b1d9b09d10 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/conditions.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/index.tsx @@ -10,8 +10,8 @@ import React, { memo } from 'react'; import { EuiPanel } from '@elastic/eui'; import { borderCss } from './conditions.styles'; -import { EntryContent } from './entry_content/entry_content'; -import { OsCondition } from './os_conditions/os_conditions'; +import { EntryContent } from './entry_content'; +import { OsCondition } from './os_conditions'; import type { CriteriaConditionsProps, Entry } from './types'; export const ExceptionItemCardConditions = memo( diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/os_conditions/__snapshots__/os_conditions.test.tsx.snap b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/os_conditions/__snapshots__/os_conditions.test.tsx.snap new file mode 100644 index 00000000000000..1ace7211d5e5f0 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/os_conditions/__snapshots__/os_conditions.test.tsx.snap @@ -0,0 +1,467 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`OsCondition should render one OS_LABELS 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+ + + + + + OS + + + + + IS + + + + Mac + + + +
+
+ , + "container":
+
+ + + + + + OS + + + + + IS + + + + Mac + + + +
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`OsCondition should render two OS_LABELS 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+ + + + + + OS + + + + + IS + + + + Mac, Windows + + + +
+
+ , + "container":
+
+ + + + + + OS + + + + + IS + + + + Mac, Windows + + + +
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`OsCondition should return any os sent 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+ + + + + + OS + + + + + IS + + + + MacPro + + + +
+
+ , + "container":
+
+ + + + + + OS + + + + + IS + + + + MacPro + + + +
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`OsCondition should return empty body 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+ , + "container":
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/os_conditions/os_conditions.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/os_conditions/index.tsx similarity index 79% rename from packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/os_conditions/os_conditions.tsx rename to packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/os_conditions/index.tsx index 701529ae6717d3..ccd829d045bc67 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/os_conditions/os_conditions.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/os_conditions/index.tsx @@ -14,7 +14,7 @@ import { OS_LABELS } from '../conditions.config'; import * as i18n from '../../translations'; export interface OsConditionsProps { - dataTestSubj: string; + dataTestSubj?: string; os: ExceptionListItemSchema['os_types']; } @@ -25,8 +25,12 @@ export const OsCondition = memo(({ os, dataTestSubj }) => { return osLabel ? (
- - + +
) : null; diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/os_conditions/os_conditions.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/os_conditions/os_conditions.test.tsx new file mode 100644 index 00000000000000..99a6f2ce0ecad0 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/conditions/os_conditions/os_conditions.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { render } from '@testing-library/react'; +import * as i18n from '../../translations'; +import { OS_LABELS } from '../conditions.config'; +import { OsCondition } from '.'; + +describe('OsCondition', () => { + it('should render one OS_LABELS', () => { + const wrapper = render(); + expect(wrapper.getByTestId('osLabel')).toHaveTextContent(i18n.CONDITION_OS); + expect(wrapper.getByTestId('osValue')).toHaveTextContent( + `${i18n.CONDITION_OPERATOR_TYPE_MATCH} ${OS_LABELS.macos}` + ); + expect(wrapper).toMatchSnapshot(); + }); + it('should render two OS_LABELS', () => { + const wrapper = render(); + expect(wrapper.getByTestId('osLabel')).toHaveTextContent(i18n.CONDITION_OS); + expect(wrapper.getByTestId('osValue')).toHaveTextContent( + `${i18n.CONDITION_OPERATOR_TYPE_MATCH} ${OS_LABELS.macos}, ${OS_LABELS.windows}` + ); + expect(wrapper).toMatchSnapshot(); + }); + it('should return empty body', () => { + const wrapper = render(); + expect(wrapper).toMatchSnapshot(); + }); + it('should return any os sent', () => { + const wrapper = render(); + expect(wrapper.getByTestId('osLabel')).toHaveTextContent(i18n.CONDITION_OS); + expect(wrapper.getByTestId('osValue')).toHaveTextContent( + `${i18n.CONDITION_OPERATOR_TYPE_MATCH} MacPro` + ); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/index.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/exception_item_card.test.tsx similarity index 65% rename from packages/kbn-securitysolution-exception-list-components/src/exception_item_card/index.test.tsx rename to packages/kbn-securitysolution-exception-list-components/src/exception_item_card/exception_item_card.test.tsx index 87a8a3bd3b527b..649748f593034b 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/index.test.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/exception_item_card.test.tsx @@ -10,31 +10,11 @@ import React from 'react'; import { fireEvent, render } from '@testing-library/react'; import { ExceptionItemCard } from '.'; -import { getExceptionListItemSchemaMock } from '../test_helpers/exception_list_item_schema.mock'; -import { getCommentsArrayMock } from '../test_helpers/comments.mock'; +import { getExceptionListItemSchemaMock } from '../mocks/exception_list_item_schema.mock'; +import { getCommentsArrayMock, mockGetFormattedComments } from '../mocks/comments.mock'; import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; +import { rules } from '../mocks/rule_references.mock'; -const ruleReferences: unknown[] = [ - { - exception_lists: [ - { - id: '123', - list_id: 'i_exist', - namespace_type: 'single', - type: 'detection', - }, - { - id: '456', - list_id: 'i_exist_2', - namespace_type: 'single', - type: 'detection', - }, - ], - id: '1a2b3c', - name: 'Simple Rule Query', - rule_id: 'rule-2', - }, -]; describe('ExceptionItemCard', () => { it('it renders header, item meta information and conditions', () => { const exceptionItem = { ...getExceptionListItemSchemaMock(), comments: [] }; @@ -43,7 +23,7 @@ describe('ExceptionItemCard', () => { { ); expect(wrapper.getByTestId('exceptionItemCardHeaderContainer')).toBeInTheDocument(); - // expect(wrapper.getByTestId('exceptionItemCardMetaInfo')).toBeInTheDocument(); + expect(wrapper.getByTestId('exceptionItemCardMetaInfo')).toBeInTheDocument(); expect(wrapper.getByTestId('exceptionItemCardConditions')).toBeInTheDocument(); - // expect(wrapper.queryByTestId('exceptionsViewerCommentAccordion')).not.toBeInTheDocument(); + expect(wrapper.queryByTestId('exceptionsViewerCommentAccordion')).not.toBeInTheDocument(); }); - it('it renders header, item meta information, conditions, and comments if any exist', () => { + it('it should render the header, item meta information, conditions, and the comments', () => { const exceptionItem = { ...getExceptionListItemSchemaMock(), comments: getCommentsArrayMock() }; const wrapper = render( @@ -67,22 +47,22 @@ describe('ExceptionItemCard', () => { exceptionItem={exceptionItem} dataTestSubj="item" listType={ExceptionListTypeEnum.DETECTION} - ruleReferences={ruleReferences} + ruleReferences={rules} onDeleteException={jest.fn()} onEditException={jest.fn()} securityLinkAnchorComponent={() => null} formattedDateComponent={() => null} - getFormattedComments={() => []} + getFormattedComments={mockGetFormattedComments} /> ); expect(wrapper.getByTestId('exceptionItemCardHeaderContainer')).toBeInTheDocument(); - // expect(wrapper.getByTestId('exceptionItemCardMetaInfo')).toBeInTheDocument(); + expect(wrapper.getByTestId('exceptionItemCardMetaInfo')).toBeInTheDocument(); expect(wrapper.getByTestId('exceptionItemCardConditions')).toBeInTheDocument(); - // expect(wrapper.getByTestId('exceptionsViewerCommentAccordion')).toBeInTheDocument(); + expect(wrapper.getByTestId('exceptionsItemCommentAccordion')).toBeInTheDocument(); }); - it('it does not render edit or delete action buttons when "disableActions" is "true"', () => { + it('it should not render edit or delete action buttons when "disableActions" is "true"', () => { const exceptionItem = getExceptionListItemSchemaMock(); const wrapper = render( @@ -93,7 +73,7 @@ describe('ExceptionItemCard', () => { exceptionItem={exceptionItem} dataTestSubj="item" listType={ExceptionListTypeEnum.DETECTION} - ruleReferences={ruleReferences} + ruleReferences={rules} securityLinkAnchorComponent={() => null} formattedDateComponent={() => null} getFormattedComments={() => []} @@ -102,7 +82,7 @@ describe('ExceptionItemCard', () => { expect(wrapper.queryByTestId('itemActionButton')).not.toBeInTheDocument(); }); - it('it invokes "onEditException" when edit button clicked', () => { + it('it should invoke the "onEditException" when edit button clicked', () => { const mockOnEditException = jest.fn(); const exceptionItem = getExceptionListItemSchemaMock(); @@ -111,7 +91,7 @@ describe('ExceptionItemCard', () => { exceptionItem={exceptionItem} dataTestSubj="exceptionItemCardHeader" listType={ExceptionListTypeEnum.DETECTION} - ruleReferences={ruleReferences} + ruleReferences={rules} onDeleteException={jest.fn()} onEditException={mockOnEditException} securityLinkAnchorComponent={() => null} @@ -120,12 +100,12 @@ describe('ExceptionItemCard', () => { /> ); - fireEvent.click(wrapper.getByTestId('exceptionItemCardHeaderActionButton')); + fireEvent.click(wrapper.getByTestId('exceptionItemCardHeaderButtonIcon')); fireEvent.click(wrapper.getByTestId('exceptionItemCardHeaderActionItemedit')); expect(mockOnEditException).toHaveBeenCalledWith(getExceptionListItemSchemaMock()); }); - it('it invokes "onDeleteException" when delete button clicked', () => { + it('it should invoke the "onDeleteException" when delete button clicked', () => { const mockOnDeleteException = jest.fn(); const exceptionItem = getExceptionListItemSchemaMock(); @@ -134,7 +114,7 @@ describe('ExceptionItemCard', () => { exceptionItem={exceptionItem} dataTestSubj="exceptionItemCardHeader" listType={ExceptionListTypeEnum.DETECTION} - ruleReferences={ruleReferences} + ruleReferences={rules} onEditException={jest.fn()} onDeleteException={mockOnDeleteException} securityLinkAnchorComponent={() => null} @@ -142,7 +122,7 @@ describe('ExceptionItemCard', () => { getFormattedComments={() => []} /> ); - fireEvent.click(wrapper.getByTestId('exceptionItemCardHeaderActionButton')); + fireEvent.click(wrapper.getByTestId('exceptionItemCardHeaderButtonIcon')); fireEvent.click(wrapper.getByTestId('exceptionItemCardHeaderActionItemdelete')); expect(mockOnDeleteException).toHaveBeenCalledWith({ @@ -152,21 +132,25 @@ describe('ExceptionItemCard', () => { }); }); - // TODO Fix this Test - // it('it renders comment accordion closed to begin with', () => { - // const exceptionItem = getExceptionListItemSchemaMock(); - // exceptionItem.comments = getCommentsArrayMock(); - // const wrapper = render( - // - // ); - - // expect(wrapper.queryByTestId('accordion-comment-list')).not.toBeVisible(); - // }); + it('it should render comment accordion closed to begin with', () => { + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.comments = getCommentsArrayMock(); + const wrapper = render( + null} + formattedDateComponent={() => null} + getFormattedComments={mockGetFormattedComments} + /> + ); + + expect(wrapper.getByTestId('exceptionsItemCommentAccordion')).toBeInTheDocument(); + expect(wrapper.getByTestId('exceptionItemCardComments')).toBeVisible(); + expect(wrapper.getByTestId('accordionContentPanel')).not.toBeVisible(); + }); }); diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/exception_item_card.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/exception_item_card.tsx index c3705750d015dd..a9aa5c7dedd870 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/exception_item_card.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/exception_item_card.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useMemo, useCallback, FC } from 'react'; +import React, { FC, ElementType } from 'react'; import { EuiPanel, EuiFlexGroup, EuiFlexItem, EuiCommentProps } from '@elastic/eui'; import type { CommentsArray, @@ -14,7 +14,6 @@ import type { ExceptionListTypeEnum, } from '@kbn/securitysolution-io-ts-list-types'; -import * as i18n from './translations'; import { ExceptionItemCardHeader, ExceptionItemCardConditions, @@ -22,18 +21,19 @@ import { ExceptionItemCardComments, } from '.'; -import type { ExceptionListItemIdentifiers } from '../types'; +import type { ExceptionListItemIdentifiers, Rule } from '../types'; +import { useExceptionItemCard } from './use_exception_item_card'; export interface ExceptionItemProps { dataTestSubj?: string; disableActions?: boolean; exceptionItem: ExceptionListItemSchema; listType: ExceptionListTypeEnum; - ruleReferences: any[]; // rulereferences + ruleReferences: Rule[]; editActionLabel?: string; deleteActionLabel?: string; - securityLinkAnchorComponent: React.ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common - formattedDateComponent: React.ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common + securityLinkAnchorComponent: ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common + formattedDateComponent: ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common getFormattedComments: (comments: CommentsArray) => EuiCommentProps[]; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common onDeleteException: (arg: ExceptionListItemIdentifiers) => void; onEditException: (item: ExceptionListItemSchema) => void; @@ -53,47 +53,24 @@ const ExceptionItemCardComponent: FC = ({ onDeleteException, onEditException, }) => { - const handleDelete = useCallback((): void => { - onDeleteException({ - id: exceptionItem.id, - name: exceptionItem.name, - namespaceType: exceptionItem.namespace_type, - }); - }, [onDeleteException, exceptionItem.id, exceptionItem.name, exceptionItem.namespace_type]); - - const handleEdit = useCallback((): void => { - onEditException(exceptionItem); - }, [onEditException, exceptionItem]); - - const formattedComments = useMemo((): EuiCommentProps[] => { - return getFormattedComments(exceptionItem.comments); - }, [exceptionItem.comments, getFormattedComments]); - - const actions: Array<{ - key: string; - icon: string; - label: string | boolean; - onClick: () => void; - }> = useMemo( - () => [ - { - key: 'edit', - icon: 'controlsHorizontal', - label: editActionLabel || i18n.exceptionItemCardEditButton(listType), - onClick: handleEdit, - }, - { - key: 'delete', - icon: 'trash', - label: deleteActionLabel || listType === i18n.exceptionItemCardDeleteButton(listType), - onClick: handleDelete, - }, - ], - [editActionLabel, listType, deleteActionLabel, handleDelete, handleEdit] - ); + const { actions, formattedComments } = useExceptionItemCard({ + listType, + editActionLabel, + deleteActionLabel, + exceptionItem, + getFormattedComments, + onEditException, + onDeleteException, + }); return ( - - + + = ({ = ({ {formattedComments.length > 0 && ( )} diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/header/header.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/header/header.test.tsx index 7f7f20dfc234d8..71bc57e98a2741 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/header/header.test.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/header/header.test.tsx @@ -8,30 +8,13 @@ import React from 'react'; -import { getExceptionListItemSchemaMock } from '../../test_helpers/exception_list_item_schema.mock'; -import * as i18n from '../translations'; -import { ExceptionItemCardHeader } from './header'; +import { getExceptionListItemSchemaMock } from '../../mocks/exception_list_item_schema.mock'; +import { ExceptionItemCardHeader } from '.'; import { fireEvent, render } from '@testing-library/react'; -import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; +import { actions, handleDelete, handleEdit } from '../../mocks/header.mock'; -const handleEdit = jest.fn(); -const handleDelete = jest.fn(); -const actions = [ - { - key: 'edit', - icon: 'pencil', - label: i18n.exceptionItemCardEditButton(ExceptionListTypeEnum.DETECTION), - onClick: handleEdit, - }, - { - key: 'delete', - icon: 'trash', - label: i18n.exceptionItemCardDeleteButton(ExceptionListTypeEnum.DETECTION), - onClick: handleDelete, - }, -]; describe('ExceptionItemCardHeader', () => { - it('it renders item name', () => { + it('it should render item name', () => { const wrapper = render( { expect(wrapper.getByTestId('exceptionItemHeaderTitle')).toHaveTextContent('some name'); }); - it('it displays actions', () => { + it('it should display actions', () => { const wrapper = render( { /> ); - // click on popover - fireEvent.click(wrapper.getByTestId('exceptionItemHeaderActionButton')); + fireEvent.click(wrapper.getByTestId('exceptionItemHeaderButtonIcon')); fireEvent.click(wrapper.getByTestId('exceptionItemHeaderActionItemedit')); expect(handleEdit).toHaveBeenCalled(); @@ -61,7 +43,7 @@ describe('ExceptionItemCardHeader', () => { expect(handleDelete).toHaveBeenCalled(); }); - it('it disables actions if disableActions is true', () => { + it('it should disable actions if disableActions is true', () => { const wrapper = render( { /> ); - expect(wrapper.getByTestId('exceptionItemHeaderActionButton')).toBeDisabled(); + expect(wrapper.getByTestId('exceptionItemHeaderButtonIcon')).toBeDisabled(); }); }); diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/header/header.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/header/header.tsx deleted file mode 100644 index d58cb8d99b7a19..00000000000000 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/header/header.tsx +++ /dev/null @@ -1,83 +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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React, { memo, useMemo, useState } from 'react'; -import type { EuiContextMenuPanelProps } from '@elastic/eui'; -import { - EuiButtonIcon, - EuiContextMenuPanel, - EuiFlexGroup, - EuiFlexItem, - EuiPopover, - EuiTitle, - EuiContextMenuItem, -} from '@elastic/eui'; -import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; - -export interface ExceptionItemCardHeaderProps { - item: ExceptionListItemSchema; - actions: Array<{ key: string; icon: string; label: string | boolean; onClick: () => void }>; - disableActions?: boolean; - dataTestSubj: string; -} - -export const ExceptionItemCardHeader = memo( - ({ item, actions, disableActions = false, dataTestSubj }) => { - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - - const onItemActionsClick = () => setIsPopoverOpen((isOpen) => !isOpen); - const onClosePopover = () => setIsPopoverOpen(false); - - const itemActions = useMemo((): EuiContextMenuPanelProps['items'] => { - return actions.map((action) => ( - { - onClosePopover(); - action.onClick(); - }} - > - {action.label} - - )); - }, [dataTestSubj, actions]); - - return ( - - - -

{item.name}

-
-
- - - } - panelPaddingSize="none" - isOpen={isPopoverOpen} - closePopover={onClosePopover} - data-test-subj={`${dataTestSubj}Items`} - > - - - -
- ); - } -); - -ExceptionItemCardHeader.displayName = 'ExceptionItemCardHeader'; diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/header/index.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/header/index.tsx new file mode 100644 index 00000000000000..27b53db53673cd --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/header/index.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { memo } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; +import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { HeaderMenu } from '../../header_menu'; + +export interface ExceptionItemCardHeaderProps { + item: ExceptionListItemSchema; + actions: Array<{ key: string; icon: string; label: string | boolean; onClick: () => void }>; + disableActions?: boolean; + dataTestSubj: string; +} + +export const ExceptionItemCardHeader = memo( + ({ item, actions, disableActions = false, dataTestSubj }) => { + return ( + + + +

{item.name}

+
+
+ + + +
+ ); + } +); + +ExceptionItemCardHeader.displayName = 'ExceptionItemCardHeader'; diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/index.ts b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/index.ts index c0fd3fafc86d5b..37a4d045619796 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/index.ts +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/index.ts @@ -6,8 +6,8 @@ * Side Public License, v 1. */ -export * from './conditions/conditions'; -export * from './header/header'; -export * from './meta/meta'; -export * from './comments/comments'; +export * from './conditions'; +export * from './header'; +export * from './meta'; +export * from './comments'; export * from './exception_item_card'; diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/__snapshots__/details_info.test.tsx.snap b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/__snapshots__/details_info.test.tsx.snap new file mode 100644 index 00000000000000..0575fac4f345b7 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/__snapshots__/details_info.test.tsx.snap @@ -0,0 +1,411 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MetaInfoDetails should render lastUpdate as JSX Element 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ created_by +
+
+
+ + + +

+ Last update value +

+
+
+
+
+
+
+ by +
+
+
+
+
+ + + + value + + + +
+
+
+
+
+ , + "container":
+
+
+
+ created_by +
+
+
+ + + +

+ Last update value +

+
+
+
+
+
+
+ by +
+
+
+
+
+ + + + value + + + +
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`MetaInfoDetails should render lastUpdate as string 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ created_by +
+
+
+ + + + last update + + + +
+
+
+ by +
+
+
+
+
+ + + + value + + + +
+
+
+
+
+ , + "container":
+
+
+
+ created_by +
+
+
+ + + + last update + + + +
+
+
+ by +
+
+
+
+
+ + + + value + + + +
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/details_info.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/details_info.test.tsx new file mode 100644 index 00000000000000..7c994e0cce6b26 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/details_info.test.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { render } from '@testing-library/react'; +import { MetaInfoDetails } from '.'; + +describe('MetaInfoDetails', () => { + it('should render lastUpdate as string', () => { + const wrapper = render( + + ); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.getByTestId('MetaInfoDetailslastUpdate')).toHaveTextContent('last update'); + }); + it('should render lastUpdate as JSX Element', () => { + const wrapper = render( + Last update value

} + lastUpdateValue="value" + /> + ); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.getByTestId('MetaInfoDetailslastUpdate')).toHaveTextContent('Last update value'); + }); +}); diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/details_info.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/index.tsx similarity index 85% rename from packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/details_info.tsx rename to packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/index.tsx index 3d075f50096d07..f18b5c0dd31dff 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/details_info.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/details_info/index.tsx @@ -13,11 +13,10 @@ import { euiThemeVars } from '@kbn/ui-theme'; import * as i18n from '../../translations'; interface MetaInfoDetailsProps { - fieldName: string; label: string; lastUpdate: JSX.Element | string; lastUpdateValue: string; - dataTestSubj: string; + dataTestSubj?: string; } const euiBadgeFontFamily = css` @@ -26,13 +25,19 @@ const euiBadgeFontFamily = css` export const MetaInfoDetails = memo( ({ label, lastUpdate, lastUpdateValue, dataTestSubj }) => { return ( - + {label} - + {lastUpdate} @@ -42,8 +47,8 @@ export const MetaInfoDetails = memo( {i18n.EXCEPTION_ITEM_CARD_META_BY} - - + + {lastUpdateValue} diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/index.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/index.tsx new file mode 100644 index 00000000000000..1d7d6a35683803 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/index.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { memo, useMemo } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; + +import { css } from '@emotion/react'; +import { euiThemeVars } from '@kbn/ui-theme'; +import * as i18n from '../translations'; +import type { Rule } from '../../types'; +import { MetaInfoDetails } from './details_info'; +import { HeaderMenu } from '../../header_menu'; +import { generateLinkedRulesMenuItems } from '../../generate_linked_rules_menu_item'; + +const itemCss = css` + border-right: 1px solid #d3dae6; + padding: ${euiThemeVars.euiSizeS} ${euiThemeVars.euiSizeM} ${euiThemeVars.euiSizeS} 0; +`; + +export interface ExceptionItemCardMetaInfoProps { + item: ExceptionListItemSchema; + rules: Rule[]; + dataTestSubj: string; + formattedDateComponent: React.ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common + securityLinkAnchorComponent: React.ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common +} + +export const ExceptionItemCardMetaInfo = memo( + ({ item, rules, dataTestSubj, securityLinkAnchorComponent, formattedDateComponent }) => { + const FormattedDateComponent = formattedDateComponent; + + const referencedLinks = useMemo( + () => + generateLinkedRulesMenuItems({ + dataTestSubj, + linkedRules: rules, + securityLinkAnchorComponent, + }), + [dataTestSubj, rules, securityLinkAnchorComponent] + ); + return ( + + {FormattedDateComponent !== null && ( + <> + + + } + lastUpdateValue={item.created_by} + dataTestSubj={`${dataTestSubj || ''}CreatedBy`} + /> + + + + + } + lastUpdateValue={item.updated_by} + dataTestSubj={`${dataTestSubj || ''}UpdatedBy`} + /> + + + )} + + + + + ); + } +); +ExceptionItemCardMetaInfo.displayName = 'ExceptionItemCardMetaInfo'; diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/meta.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/meta.test.tsx index c5ad9bd7af3132..3a42760afaba8c 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/meta.test.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/meta.test.tsx @@ -8,38 +8,17 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { getExceptionListItemSchemaMock } from '../../test_helpers/exception_list_item_schema.mock'; +import { getExceptionListItemSchemaMock } from '../../mocks/exception_list_item_schema.mock'; -import { ExceptionItemCardMetaInfo } from './meta'; -import { RuleReference } from '../../types'; +import { ExceptionItemCardMetaInfo } from '.'; +import { rules } from '../../mocks/rule_references.mock'; -const ruleReferences = [ - { - exception_lists: [ - { - id: '123', - list_id: 'i_exist', - namespace_type: 'single', - type: 'detection', - }, - { - id: '456', - list_id: 'i_exist_2', - namespace_type: 'single', - type: 'detection', - }, - ], - id: '1a2b3c', - name: 'Simple Rule Query', - rule_id: 'rule-2', - }, -]; describe('ExceptionItemCardMetaInfo', () => { it('it should render creation info with sending custom formattedDateComponent', () => { const wrapper = render( null} formattedDateComponent={({ fieldName, value }) => ( @@ -62,7 +41,7 @@ describe('ExceptionItemCardMetaInfo', () => { const wrapper = render( null} formattedDateComponent={({ fieldName, value }) => ( @@ -84,71 +63,67 @@ describe('ExceptionItemCardMetaInfo', () => { const wrapper = render( null} formattedDateComponent={() => null} /> ); - expect(wrapper.getByTestId('exceptionItemMetaAffectedRulesButton')).toHaveTextContent( - 'Affects 1 rule' - ); + expect(wrapper.getByTestId('exceptionItemMetaEmptyButton')).toHaveTextContent('Affects 1 rule'); }); - it('it renders references info when multiple references exist', () => { + it('it should render references info when multiple references exist', () => { const wrapper = render( null} formattedDateComponent={() => null} /> ); - expect(wrapper.getByTestId('exceptionItemMetaAffectedRulesButton')).toHaveTextContent( + expect(wrapper.getByTestId('exceptionItemMetaEmptyButton')).toHaveTextContent( 'Affects 2 rules' ); }); diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/meta.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/meta.tsx deleted file mode 100644 index 91e0a9cdd19b80..00000000000000 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/meta/meta.tsx +++ /dev/null @@ -1,123 +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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React, { memo, useMemo, useState } from 'react'; -import type { EuiContextMenuPanelProps } from '@elastic/eui'; -import { - EuiContextMenuItem, - EuiContextMenuPanel, - EuiFlexGroup, - EuiFlexItem, - EuiToolTip, - EuiButtonEmpty, - EuiPopover, -} from '@elastic/eui'; -import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; - -import { css } from '@emotion/react'; -import * as i18n from '../translations'; -import type { RuleReference } from '../../types'; -import { MetaInfoDetails } from './details_info/details_info'; - -const itemCss = css` - border-right: 1px solid #d3dae6; - padding: 4px 12px 4px 0; -`; - -export interface ExceptionItemCardMetaInfoProps { - item: ExceptionListItemSchema; - references: RuleReference[]; - dataTestSubj: string; - formattedDateComponent: React.ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common - securityLinkAnchorComponent: React.ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common -} - -export const ExceptionItemCardMetaInfo = memo( - ({ item, references, dataTestSubj, securityLinkAnchorComponent, formattedDateComponent }) => { - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - - const onAffectedRulesClick = () => setIsPopoverOpen((isOpen) => !isOpen); - const onClosePopover = () => setIsPopoverOpen(false); - - const FormattedDateComponent = formattedDateComponent; - const itemActions = useMemo((): EuiContextMenuPanelProps['items'] => { - if (references == null || securityLinkAnchorComponent === null) { - return []; - } - - const SecurityLinkAnchor = securityLinkAnchorComponent; - return references.map((reference) => ( - - - - - - )); - }, [references, securityLinkAnchorComponent, dataTestSubj]); - - return ( - - {FormattedDateComponent !== null && ( - <> - - - } - lastUpdateValue={item.created_by} - dataTestSubj={`${dataTestSubj}CreatedBy`} - /> - - - - - } - lastUpdateValue={item.updated_by} - dataTestSubj={`${dataTestSubj}UpdatedBy`} - /> - - - )} - - - {i18n.AFFECTED_RULES(references.length)} - - } - panelPaddingSize="none" - isOpen={isPopoverOpen} - closePopover={onClosePopover} - data-test-subj={`${dataTestSubj}Items`} - > - - - - - ); - } -); -ExceptionItemCardMetaInfo.displayName = 'ExceptionItemCardMetaInfo'; diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/use_exception_item_card.test.ts b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/use_exception_item_card.test.ts new file mode 100644 index 00000000000000..b8bbfaa70b6127 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/use_exception_item_card.test.ts @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { getExceptionListItemSchemaMock } from '../mocks/exception_list_item_schema.mock'; +import { useExceptionItemCard } from './use_exception_item_card'; +import * as i18n from './translations'; +import { mockGetFormattedComments } from '../mocks/comments.mock'; + +const onEditException = jest.fn(); +const onDeleteException = jest.fn(); +const getFormattedComments = jest.fn(); +const exceptionItem = getExceptionListItemSchemaMock(); +describe('useExceptionItemCard', () => { + it('should call onEditException with the correct params', () => { + const { + result: { current }, + } = renderHook(() => + useExceptionItemCard({ + listType: ExceptionListTypeEnum.DETECTION, + exceptionItem, + onEditException, + onDeleteException, + getFormattedComments, + }) + ); + const { actions } = current; + + act(() => { + actions[0].onClick(); + }); + expect(onEditException).toHaveBeenCalledWith(exceptionItem); + }); + it('should call onDeleteException with the correct params', () => { + const { + result: { current }, + } = renderHook(() => + useExceptionItemCard({ + listType: ExceptionListTypeEnum.DETECTION, + exceptionItem, + onEditException, + onDeleteException, + getFormattedComments, + }) + ); + const { actions } = current; + + act(() => { + actions[1].onClick(); + }); + expect(onDeleteException).toHaveBeenCalledWith({ + id: exceptionItem.id, + name: exceptionItem.name, + namespaceType: exceptionItem.namespace_type, + }); + }); + it('should return the default actions labels', () => { + const { + result: { current }, + } = renderHook(() => + useExceptionItemCard({ + listType: ExceptionListTypeEnum.DETECTION, + exceptionItem, + onEditException, + onDeleteException, + getFormattedComments, + }) + ); + const { actions } = current; + const [editAction, deleteAction] = actions; + + expect(editAction.label).toEqual( + i18n.exceptionItemCardEditButton(ExceptionListTypeEnum.DETECTION) + ); + expect(deleteAction.label).toEqual( + i18n.exceptionItemCardDeleteButton(ExceptionListTypeEnum.DETECTION) + ); + }); + it('should return the default sent labels props', () => { + const { + result: { current }, + } = renderHook(() => + useExceptionItemCard({ + listType: ExceptionListTypeEnum.DETECTION, + exceptionItem, + editActionLabel: 'Edit', + deleteActionLabel: 'Delete', + onEditException, + onDeleteException, + getFormattedComments, + }) + ); + const { actions } = current; + const [editAction, deleteAction] = actions; + + expect(editAction.label).toEqual('Edit'); + expect(deleteAction.label).toEqual('Delete'); + }); + it('should return formattedComments', () => { + const { + result: { current }, + } = renderHook(() => + useExceptionItemCard({ + listType: ExceptionListTypeEnum.DETECTION, + exceptionItem, + editActionLabel: 'Edit', + deleteActionLabel: 'Delete', + onEditException, + onDeleteException, + getFormattedComments: mockGetFormattedComments, + }) + ); + const { formattedComments } = current; + expect(formattedComments[0].username).toEqual('some user'); + }); +}); diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/use_exception_item_card.ts b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/use_exception_item_card.ts new file mode 100644 index 00000000000000..dda7ae7d7aa045 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_item_card/use_exception_item_card.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { useCallback, useMemo } from 'react'; +import { EuiCommentProps } from '@elastic/eui'; + +import { + CommentsArray, + ExceptionListItemSchema, + ExceptionListTypeEnum, +} from '@kbn/securitysolution-io-ts-list-types'; +import * as i18n from './translations'; +import { ExceptionListItemIdentifiers } from '../types'; + +interface UseExceptionItemCardProps { + exceptionItem: ExceptionListItemSchema; + listType: ExceptionListTypeEnum; + editActionLabel?: string; + deleteActionLabel?: string; + getFormattedComments: (comments: CommentsArray) => EuiCommentProps[]; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common + onDeleteException: (arg: ExceptionListItemIdentifiers) => void; + onEditException: (item: ExceptionListItemSchema) => void; +} + +export const useExceptionItemCard = ({ + listType, + editActionLabel, + deleteActionLabel, + exceptionItem, + getFormattedComments, + onEditException, + onDeleteException, +}: UseExceptionItemCardProps) => { + const handleDelete = useCallback((): void => { + onDeleteException({ + id: exceptionItem.id, + name: exceptionItem.name, + namespaceType: exceptionItem.namespace_type, + }); + }, [onDeleteException, exceptionItem.id, exceptionItem.name, exceptionItem.namespace_type]); + + const handleEdit = useCallback((): void => { + onEditException(exceptionItem); + }, [onEditException, exceptionItem]); + + const formattedComments = useMemo((): EuiCommentProps[] => { + return getFormattedComments(exceptionItem.comments); + }, [exceptionItem.comments, getFormattedComments]); + + const actions: Array<{ + key: string; + icon: string; + label: string | boolean; + onClick: () => void; + }> = useMemo( + () => [ + { + key: 'edit', + icon: 'controlsHorizontal', + label: editActionLabel || i18n.exceptionItemCardEditButton(listType), + onClick: handleEdit, + }, + { + key: 'delete', + icon: 'trash', + label: deleteActionLabel || i18n.exceptionItemCardDeleteButton(listType), + onClick: handleDelete, + }, + ], + [editActionLabel, listType, deleteActionLabel, handleDelete, handleEdit] + ); + return { + actions, + formattedComments, + }; +}; diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_items/exception_items.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_items/exception_items.test.tsx index 39c429dd1f1dde..da5df529969525 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_items/exception_items.test.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_items/exception_items.test.tsx @@ -8,13 +8,17 @@ import React from 'react'; -import { getExceptionListItemSchemaMock } from '../test_helpers/exception_list_item_schema.mock'; +import { getExceptionListItemSchemaMock } from '../mocks/exception_list_item_schema.mock'; import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; -import { ExceptionItems } from './exception_items'; +import { ExceptionItems } from '.'; import { ViewerStatus } from '../types'; -import { render } from '@testing-library/react'; +import { fireEvent, render } from '@testing-library/react'; +import { ruleReferences } from '../mocks/rule_references.mock'; +import { Pagination } from '@elastic/eui'; +import { mockGetFormattedComments } from '../mocks/comments.mock'; +import { securityLinkAnchorComponentMock } from '../mocks/security_link_component.mock'; const onCreateExceptionListItem = jest.fn(); const onDeleteException = jest.fn(); @@ -25,7 +29,7 @@ const pagination = { pageIndex: 0, pageSize: 0, totalItemCount: 0 }; describe('ExceptionsViewerItems', () => { describe('Viewing EmptyViewerState', () => { - it('it renders empty prompt if "viewerStatus" is "empty"', () => { + it('it should render empty prompt if "viewerStatus" is "empty"', () => { const wrapper = render( { getFormattedComments={() => []} /> ); - // expect(wrapper).toMatchSnapshot(); expect(wrapper.getByTestId('emptyViewerState')).toBeInTheDocument(); expect(wrapper.queryByTestId('exceptionsContainer')).not.toBeInTheDocument(); }); - it('it renders no search results found prompt if "viewerStatus" is "empty_search"', () => { + it('it should render no search results found prompt if "viewerStatus" is "empty_search"', () => { const wrapper = render( { getFormattedComments={() => []} /> ); - // expect(wrapper).toMatchSnapshot(); expect(wrapper.getByTestId('emptySearchViewerState')).toBeInTheDocument(); expect(wrapper.queryByTestId('exceptionsContainer')).not.toBeInTheDocument(); }); - - it('it renders exceptions if "viewerStatus" and "null"', () => { + }); + describe('Exception Items and Pagination', () => { + it('it should render exceptions if exception array is not empty', () => { const wrapper = render( { getFormattedComments={() => []} /> ); - // expect(wrapper).toMatchSnapshot(); + expect(wrapper.getByTestId('exceptionsContainer')).toBeInTheDocument(); + expect(wrapper.getByTestId('exceptionItemCard')).toBeInTheDocument(); + expect(wrapper.getAllByTestId('exceptionItemCard')).toHaveLength(1); + }); + it('it should render pagination section', () => { + const exceptions = [ + getExceptionListItemSchemaMock(), + { ...getExceptionListItemSchemaMock(), id: '2' }, + ]; + const wrapper = render( + null} + formattedDateComponent={() => null} + exceptionsUtilityComponent={() => null} + getFormattedComments={() => []} + /> + ); expect(wrapper.getByTestId('exceptionsContainer')).toBeTruthy(); + expect(wrapper.getAllByTestId('exceptionItemCard')).toHaveLength(2); + expect(wrapper.getByTestId('pagination')).toBeInTheDocument(); + }); + }); + + describe('securityLinkAnchorComponent, formattedDateComponent, exceptionsUtilityComponent and getFormattedComments', () => { + it('it should render sent securityLinkAnchorComponent', () => { + const wrapper = render( + null} + exceptionsUtilityComponent={() => null} + getFormattedComments={() => []} + /> + ); + expect(wrapper.getByTestId('exceptionsContainer')).toBeInTheDocument(); + fireEvent.click(wrapper.getByTestId('exceptionItemCardMetaInfoEmptyButton')); + expect(wrapper.getByTestId('securityLinkAnchorComponent')).toBeInTheDocument(); + }); + it('it should render sent exceptionsUtilityComponent', () => { + const exceptionsUtilityComponent = ({ + pagination: utilityPagination, + lastUpdated, + }: { + pagination: Pagination; + lastUpdated: string; + }) => ( +
+ {lastUpdated} + {utilityPagination.pageIndex} +
+ ); + const wrapper = render( + null} + formattedDateComponent={() => null} + exceptionsUtilityComponent={exceptionsUtilityComponent} + getFormattedComments={() => []} + /> + ); + expect(wrapper.getByTestId('exceptionsContainer')).toBeInTheDocument(); + expect(wrapper.getByTestId('exceptionsUtilityComponent')).toBeInTheDocument(); + expect(wrapper.getByTestId('lastUpdateTestUtility')).toHaveTextContent('1666003695578'); + expect(wrapper.getByTestId('paginationTestUtility')).toHaveTextContent('0'); + }); + it('it should render sent formattedDateComponent', () => { + const formattedDateComponent = ({ + fieldName, + value, + }: { + fieldName: string; + value: string; + }) => ( +
+ {fieldName} + {value} +
+ ); + const wrapper = render( + null} + formattedDateComponent={formattedDateComponent} + exceptionsUtilityComponent={() => null} + getFormattedComments={() => []} + /> + ); + expect(wrapper.getByTestId('exceptionsContainer')).toBeInTheDocument(); + expect(wrapper.getAllByTestId('formattedDateComponent')).toHaveLength(2); + expect(wrapper.getAllByTestId('fieldNameTestFormatted')[0]).toHaveTextContent('created_at'); + expect(wrapper.getAllByTestId('fieldNameTestFormatted')[1]).toHaveTextContent('updated_at'); + expect(wrapper.getAllByTestId('valueTestFormatted')[0]).toHaveTextContent( + '2020-04-20T15:25:31.830Z' + ); + expect(wrapper.getAllByTestId('valueTestFormatted')[0]).toHaveTextContent( + '2020-04-20T15:25:31.830Z' + ); + }); + it('it should use getFormattedComments to extract comments', () => { + const wrapper = render( + null} + formattedDateComponent={() => null} + exceptionsUtilityComponent={() => null} + getFormattedComments={mockGetFormattedComments} + /> + ); + expect(wrapper.getByTestId('exceptionsContainer')).toBeInTheDocument(); + expect(wrapper.getByTestId('exceptionsItemCommentAccordion')).toBeInTheDocument(); }); }); - // TODO Add Exception Items and Pagination interactions }); diff --git a/packages/kbn-securitysolution-exception-list-components/src/exception_items/exception_items.tsx b/packages/kbn-securitysolution-exception-list-components/src/exception_items/index.tsx similarity index 75% rename from packages/kbn-securitysolution-exception-list-components/src/exception_items/exception_items.tsx rename to packages/kbn-securitysolution-exception-list-components/src/exception_items/index.tsx index 80ab3d99f6eb84..647ff3a14458ab 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/exception_items/exception_items.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/exception_items/index.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { ElementType } from 'react'; import { css } from '@emotion/react'; import type { FC } from 'react'; import { EuiCommentProps, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; @@ -47,9 +47,12 @@ interface ExceptionItemsProps { listType: ExceptionListTypeEnum; ruleReferences: RuleReferences; pagination: PaginationType; - securityLinkAnchorComponent: React.ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common - formattedDateComponent: React.ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common - exceptionsUtilityComponent: React.ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common + editActionLabel?: string; + deleteActionLabel?: string; + dataTestSubj?: string; + securityLinkAnchorComponent: ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common + formattedDateComponent: ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common + exceptionsUtilityComponent: ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common getFormattedComments: (comments: CommentsArray) => EuiCommentProps[]; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common onCreateExceptionListItem?: () => void; onDeleteException: (arg: ExceptionListItemIdentifiers) => void; @@ -68,6 +71,9 @@ const ExceptionItemsComponent: FC = ({ emptyViewerBody, emptyViewerButtonText, pagination, + dataTestSubj, + editActionLabel, + deleteActionLabel, securityLinkAnchorComponent, exceptionsUtilityComponent, formattedDateComponent, @@ -96,24 +102,31 @@ const ExceptionItemsComponent: FC = ({ {exceptions.map((exception) => ( - + = ({ diff --git a/packages/kbn-securitysolution-exception-list-components/src/generate_linked_rules_menu_item/__snapshots__/generate_linked_rules_menu_item.test.tsx.snap b/packages/kbn-securitysolution-exception-list-components/src/generate_linked_rules_menu_item/__snapshots__/generate_linked_rules_menu_item.test.tsx.snap new file mode 100644 index 00000000000000..7c2e71a7ffe934 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/generate_linked_rules_menu_item/__snapshots__/generate_linked_rules_menu_item.test.tsx.snap @@ -0,0 +1,267 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`generateLinedRulesMenuItems should render the first linked rules with left icon and does not apply the css if the length is 1 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+ +
+ , + "container":
+ +
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`generateLinedRulesMenuItems should render the second linked rule and apply the css when the length is > 1 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+ +
+ , + "container":
+ +
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/packages/kbn-securitysolution-exception-list-components/src/generate_linked_rules_menu_item/generate_linked_rules_menu_item.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/generate_linked_rules_menu_item/generate_linked_rules_menu_item.test.tsx new file mode 100644 index 00000000000000..2158f05f635346 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/generate_linked_rules_menu_item/generate_linked_rules_menu_item.test.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { render } from '@testing-library/react'; +import { ReactElement } from 'react'; +import { ElementType } from 'react'; +import { generateLinkedRulesMenuItems } from '.'; +import { rules } from '../mocks/rule_references.mock'; +import { + getSecurityLinkAction, + securityLinkAnchorComponentMock, +} from '../mocks/security_link_component.mock'; + +const dataTestSubj = 'generateLinedRulesMenuItemsTest'; +const linkedRules = rules; + +describe('generateLinedRulesMenuItems', () => { + it('should not render if the linkedRules length is falsy', () => { + const result = generateLinkedRulesMenuItems({ + dataTestSubj, + linkedRules: [], + securityLinkAnchorComponent: securityLinkAnchorComponentMock, + }); + expect(result).toBeNull(); + }); + it('should not render if the securityLinkAnchorComponent length is falsy', () => { + const result = generateLinkedRulesMenuItems({ + dataTestSubj, + linkedRules, + securityLinkAnchorComponent: null as unknown as ElementType, + }); + expect(result).toBeNull(); + }); + it('should render the first linked rules with left icon and does not apply the css if the length is 1', () => { + const result: ReactElement[] = generateLinkedRulesMenuItems({ + dataTestSubj, + linkedRules, + securityLinkAnchorComponent: securityLinkAnchorComponentMock, + leftIcon: 'check', + }) as ReactElement[]; + + result.map((link) => { + const wrapper = render(link); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.getByTestId('generateLinedRulesMenuItemsTestActionItem1a2b3c')); + expect(wrapper.getByTestId('generateLinedRulesMenuItemsTestLeftIcon')); + }); + }); + it('should render the second linked rule and apply the css when the length is > 1', () => { + const result: ReactElement[] = getSecurityLinkAction(dataTestSubj); + + const wrapper = render(result[1]); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.getByTestId('generateLinedRulesMenuItemsTestActionItem2a2b3c')); + }); +}); diff --git a/packages/kbn-securitysolution-exception-list-components/src/generate_linked_rules_menu_item/index.tsx b/packages/kbn-securitysolution-exception-list-components/src/generate_linked_rules_menu_item/index.tsx new file mode 100644 index 00000000000000..a78a76319cbb5f --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/generate_linked_rules_menu_item/index.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React, { ElementType, ReactElement } from 'react'; +import { EuiContextMenuItem, EuiFlexGroup, EuiFlexItem, EuiIcon, IconType } from '@elastic/eui'; +import { Rule } from '../types'; +import { itemContentCss, containerCss } from './menu_link.styles'; + +interface MenuItemLinkedRulesProps { + leftIcon?: IconType; + dataTestSubj?: string; + linkedRules: Rule[]; + securityLinkAnchorComponent: ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common +} + +export const generateLinkedRulesMenuItems = ({ + dataTestSubj, + linkedRules, + securityLinkAnchorComponent, + leftIcon = '', +}: MenuItemLinkedRulesProps): ReactElement[] | null => { + if (!linkedRules.length || securityLinkAnchorComponent === null) return null; + + const SecurityLinkAnchor = securityLinkAnchorComponent; + return linkedRules.map((rule) => { + return ( + 1 ? containerCss : ''} + data-test-subj={`${dataTestSubj || ''}ActionItem${rule.id}`} + key={rule.id} + > + + {leftIcon ? ( + + + + ) : null} + + + + + + ); + }); +}; diff --git a/packages/kbn-securitysolution-exception-list-components/src/generate_linked_rules_menu_item/menu_link.styles.ts b/packages/kbn-securitysolution-exception-list-components/src/generate_linked_rules_menu_item/menu_link.styles.ts new file mode 100644 index 00000000000000..0cfbcf522bc24b --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/generate_linked_rules_menu_item/menu_link.styles.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { css } from '@emotion/react'; +import { euiThemeVars } from '@kbn/ui-theme'; + +export const containerCss = css` + border-bottom: 1px solid ${euiThemeVars.euiColorLightShade}; +`; + +export const itemContentCss = css` + color: ${euiThemeVars.euiColorPrimary}; + flex-basis: content; +`; diff --git a/packages/kbn-securitysolution-exception-list-components/src/header_menu/__snapshots__/header_menu.test.tsx.snap b/packages/kbn-securitysolution-exception-list-components/src/header_menu/__snapshots__/header_menu.test.tsx.snap new file mode 100644 index 00000000000000..dd808cfd1889ca --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/header_menu/__snapshots__/header_menu.test.tsx.snap @@ -0,0 +1,626 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HeaderMenu should render button icon with default settings 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ +
+
+
+
+ , + "container":
+
+
+
+ +
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`HeaderMenu should render custom Actions 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ +
+
+
+
+ , + "container":
+
+
+
+ +
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`HeaderMenu should render empty button icon with actions and open the popover when clicked 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ +
+
+
+
+ , + "container":
+
+
+
+ +
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`HeaderMenu should render empty button icon with actions and should not open the popover when clicked if disableActions 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ +
+
+
+
+ , + "container":
+
+
+
+ +
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`HeaderMenu should render empty button icon with different icon settings 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ +
+
+
+
+ , + "container":
+
+
+
+ +
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/packages/kbn-securitysolution-exception-list-components/src/header_menu/header_menu.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/header_menu/header_menu.test.tsx new file mode 100644 index 00000000000000..b07079c721ff73 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/header_menu/header_menu.test.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { HeaderMenu } from '.'; +import { actions } from '../mocks/header.mock'; +import { getSecurityLinkAction } from '../mocks/security_link_component.mock'; + +describe('HeaderMenu', () => { + it('should render button icon with default settings', () => { + const wrapper = render(); + + expect(wrapper).toMatchSnapshot(); + + expect(wrapper.getByTestId('ButtonIcon')).toBeInTheDocument(); + expect(wrapper.queryByTestId('EmptyButton')).not.toBeInTheDocument(); + expect(wrapper.queryByTestId('MenuPanel')).not.toBeInTheDocument(); + }); + + it('should render empty button icon with different icon settings', () => { + const wrapper = render( + + ); + + expect(wrapper).toMatchSnapshot(); + + expect(wrapper.getByTestId('EmptyButton')).toBeInTheDocument(); + expect(wrapper.queryByTestId('ButtonIcon')).not.toBeInTheDocument(); + expect(wrapper.queryByTestId('MenuPanel')).not.toBeInTheDocument(); + }); + + it('should render empty button icon with actions and open the popover when clicked', () => { + const wrapper = render( + + ); + + expect(wrapper).toMatchSnapshot(); + + expect(wrapper.getByTestId('EmptyButton')).toBeInTheDocument(); + expect(wrapper.queryByTestId('ButtonIcon')).not.toBeInTheDocument(); + fireEvent.click(wrapper.getByTestId('EmptyButton')); + expect(wrapper.getByTestId('ActionItemedit')).toBeInTheDocument(); + expect(wrapper.getByTestId('MenuPanel')).toBeInTheDocument(); + }); + it('should render empty button icon with actions and should not open the popover when clicked if disableActions', () => { + const wrapper = render( + + ); + + expect(wrapper).toMatchSnapshot(); + + expect(wrapper.getByTestId('EmptyButton')).toBeInTheDocument(); + expect(wrapper.queryByTestId('ButtonIcon')).not.toBeInTheDocument(); + fireEvent.click(wrapper.getByTestId('EmptyButton')); + expect(wrapper.queryByTestId('ActionItemedit')).not.toBeInTheDocument(); + expect(wrapper.queryByTestId('MenuPanel')).not.toBeInTheDocument(); + }); + + it('should call onEdit if action has onClick', () => { + const onEdit = jest.fn(); + const customAction = [...actions]; + customAction[0].onClick = onEdit; + const wrapper = render(); + fireEvent.click(wrapper.getByTestId('ButtonIcon')); + fireEvent.click(wrapper.getByTestId('ActionItemedit')); + expect(onEdit).toBeCalled(); + }); + + it('should render custom Actions', () => { + const customActions = getSecurityLinkAction('headerMenuTest'); + const wrapper = render( + + ); + + expect(wrapper).toMatchSnapshot(); + + expect(wrapper.getByTestId('EmptyButton')).toBeInTheDocument(); + fireEvent.click(wrapper.getByTestId('EmptyButton')); + expect(wrapper.queryByTestId('MenuPanel')).toBeInTheDocument(); + }); +}); diff --git a/packages/kbn-securitysolution-exception-list-components/src/header_menu/index.tsx b/packages/kbn-securitysolution-exception-list-components/src/header_menu/index.tsx new file mode 100644 index 00000000000000..43154a865a436f --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/header_menu/index.tsx @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC, ReactElement, useMemo, useState } from 'react'; +import { + EuiButtonEmpty, + EuiButtonIcon, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiFlexGroup, + EuiPopover, + IconType, + PanelPaddingSize, + PopoverAnchorPosition, +} from '@elastic/eui'; +import { ButtonContentIconSide } from '@elastic/eui/src/components/button/_button_content_deprecated'; + +interface Action { + key: string; + icon: string; + label: string | boolean; + onClick: () => void; +} +interface HeaderMenuComponentProps { + disableActions: boolean; + actions: Action[] | ReactElement[] | null; + text?: string; + iconType?: IconType; + iconSide?: ButtonContentIconSide; + dataTestSubj?: string; + emptyButton?: boolean; + useCustomActions?: boolean; + anchorPosition?: PopoverAnchorPosition; + panelPaddingSize?: PanelPaddingSize; +} + +const HeaderMenuComponent: FC = ({ + text, + dataTestSubj, + actions, + disableActions, + emptyButton, + useCustomActions, + iconType = 'boxesHorizontal', + iconSide = 'left', + anchorPosition = 'downCenter', + panelPaddingSize = 's', +}) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const onAffectedRulesClick = () => setIsPopoverOpen((isOpen) => !isOpen); + const onClosePopover = () => setIsPopoverOpen(false); + + const itemActions = useMemo(() => { + if (useCustomActions || actions === null) return actions; + return (actions as Action[]).map((action) => ( + { + onClosePopover(); + if (typeof action.onClick === 'function') action.onClick(); + }} + > + {action.label} + + )); + }, [actions, dataTestSubj, useCustomActions]); + + return ( + + + {text} + + ) : ( + + {text} + + ) + } + panelPaddingSize={panelPaddingSize} + isOpen={isPopoverOpen} + closePopover={onClosePopover} + anchorPosition={anchorPosition} + data-test-subj={`${dataTestSubj || ''}Items`} + > + {!itemActions ? null : ( + + )} + + + ); +}; +HeaderMenuComponent.displayName = 'HeaderMenuComponent'; + +export const HeaderMenu = React.memo(HeaderMenuComponent); + +HeaderMenu.displayName = 'HeaderMenu'; diff --git a/packages/kbn-securitysolution-exception-list-components/src/list_header/__snapshots__/list_header.test.tsx.snap b/packages/kbn-securitysolution-exception-list-components/src/list_header/__snapshots__/list_header.test.tsx.snap new file mode 100644 index 00000000000000..98f026e4ce947d --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/list_header/__snapshots__/list_header.test.tsx.snap @@ -0,0 +1,1711 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ExceptionListHeader should render edit modal 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ +
+
+
+

+
+ + List Name + + +
+

+
+
+

+

+
+ + List description + + +
+
+
+ List ID + : +
+
+ List_Id +
+
+
+

+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+

+ Edit List Name +

+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+ + +
+
+
+
+
+ , + "container":
+
+
+
+ +
+
+
+

+
+ + List Name + + +
+

+
+
+

+

+
+ + List description + + +
+
+
+ List ID + : +
+
+ List_Id +
+
+
+

+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`ExceptionListHeader should render the List Header with name, default description and actions 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ +
+
+
+

+
+ + List Name + + +
+

+
+
+

+

+
+ + Add a description + + +
+
+
+ List ID + : +
+
+ List_Id +
+
+
+

+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ , + "container":
+
+
+
+ +
+
+
+

+
+ + List Name + + +
+

+
+
+

+

+
+ + Add a description + + +
+
+
+ List ID + : +
+
+ List_Id +
+
+
+

+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`ExceptionListHeader should render the List Header with name, default description and disabled actions because of the ReadOnly mode 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ +
+
+
+

+
+ + List Name + +
+

+
+
+

+

+
+ + Add a description + +
+
+
+ List ID + : +
+
+ List_Id +
+
+
+

+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ , + "container":
+
+
+
+ +
+
+
+

+
+ + List Name + +
+

+
+
+

+

+
+ + Add a description + +
+
+
+ List ID + : +
+
+ List_Id +
+
+
+

+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/packages/kbn-securitysolution-exception-list-components/src/list_header/edit_modal/__snapshots__/edit_modal.test.tsx.snap b/packages/kbn-securitysolution-exception-list-components/src/list_header/edit_modal/__snapshots__/edit_modal.test.tsx.snap new file mode 100644 index 00000000000000..8de3d7e099f402 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/list_header/edit_modal/__snapshots__/edit_modal.test.tsx.snap @@ -0,0 +1,227 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EditModal should render the title and description from listDetails 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ +
+
+
+

+ Edit list name +

+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+ + +
+
+
+
+
+ , + "container":
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/packages/kbn-securitysolution-exception-list-components/src/list_header/edit_modal/edit_modal.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/list_header/edit_modal/edit_modal.test.tsx new file mode 100644 index 00000000000000..39786c1723b4f0 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/list_header/edit_modal/edit_modal.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { EditModal } from '.'; + +const onSave = jest.fn(); +const onCancel = jest.fn(); + +describe('EditModal', () => { + it('should render the title and description from listDetails', () => { + const wrapper = render( + + ); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.getByTestId('editModalTitle')).toHaveTextContent('list name'); + }); + it('should call onSave', () => { + const wrapper = render( + + ); + fireEvent.submit(wrapper.getByTestId('editModalForm')); + expect(onSave).toBeCalled(); + }); + it('should call onCancel', () => { + const wrapper = render( + + ); + fireEvent.click(wrapper.getByTestId('editModalCancelBtn')); + expect(onCancel).toBeCalled(); + }); + + it('should call change title, description and call onSave with the new props', () => { + const wrapper = render( + + ); + fireEvent.change(wrapper.getByTestId('editModalNameTextField'), { + target: { value: 'New list name' }, + }); + fireEvent.change(wrapper.getByTestId('editModalDescriptionTextField'), { + target: { value: 'New description name' }, + }); + fireEvent.submit(wrapper.getByTestId('editModalForm')); + + expect(onSave).toBeCalledWith({ + name: 'New list name', + description: 'New description name', + }); + }); +}); diff --git a/packages/kbn-securitysolution-exception-list-components/src/list_header/edit_modal/index.tsx b/packages/kbn-securitysolution-exception-list-components/src/list_header/edit_modal/index.tsx new file mode 100644 index 00000000000000..8ef4174128436f --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/list_header/edit_modal/index.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React, { ChangeEvent, FC, useState } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiFieldText, + EuiForm, + EuiFormRow, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + useGeneratedHtmlId, +} from '@elastic/eui'; +import * as i18n from '../../translations'; +import { ListDetails } from '../../types'; + +interface EditModalProps { + listDetails: ListDetails; + onSave: (newListDetails: ListDetails) => void; + onCancel: () => void; +} + +const EditModalComponent: FC = ({ listDetails, onSave, onCancel }) => { + const modalFormId = useGeneratedHtmlId({ prefix: 'modalForm' }); + const [newListDetails, setNewListDetails] = useState(listDetails); + + const onChange = ({ target }: ChangeEvent) => { + const { name, value } = target; + setNewListDetails({ ...newListDetails, [name]: value }); + }; + const onSubmit = () => { + onSave(newListDetails); + }; + return ( + + + +

{i18n.EXCEPTION_LIST_HEADER_EDIT_MODAL_TITLE(listDetails.name)}

+
+
+ + + + + + + + + + + + + + + + {i18n.EXCEPTION_LIST_HEADER_EDIT_MODAL_CANCEL_BUTTON} + + + + {i18n.EXCEPTION_LIST_HEADER_EDIT_MODAL_SAVE_BUTTON} + + +
+ ); +}; +EditModalComponent.displayName = 'EditModalComponent'; + +export const EditModal = React.memo(EditModalComponent); + +EditModal.displayName = 'EditModal'; diff --git a/packages/kbn-securitysolution-exception-list-components/src/list_header/index.tsx b/packages/kbn-securitysolution-exception-list-components/src/list_header/index.tsx new file mode 100644 index 00000000000000..570be26e2e84c0 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/list_header/index.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import type { FC } from 'react'; +import { EuiIcon, EuiPageHeader, EuiText } from '@elastic/eui'; +import * as i18n from '../translations'; +import { + textWithEditContainerCss, + textCss, + descriptionContainerCss, + headerCss, +} from './list_header.styles'; +import { MenuItems } from './menu_items'; +import { TextWithEdit } from '../text_with_edit'; +import { EditModal } from './edit_modal'; +import { ListDetails, Rule } from '../types'; +import { useExceptionListHeader } from './use_list_header'; + +interface ExceptionListHeaderComponentProps { + name: string; + description?: string; + listId: string; + isReadonly: boolean; + linkedRules: Rule[]; + dataTestSubj?: string; + breadcrumbLink?: string; + securityLinkAnchorComponent: React.ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common + onEditListDetails: (listDetails: ListDetails) => void; + onExportList: () => void; + onDeleteList: () => void; + onManageRules: () => void; +} + +const ExceptionListHeaderComponent: FC = ({ + name, + description, + listId, + linkedRules, + isReadonly, + dataTestSubj, + securityLinkAnchorComponent, + breadcrumbLink, + onEditListDetails, + onExportList, + onDeleteList, + onManageRules, +}) => { + const { isModalVisible, listDetails, onEdit, onSave, onCancel } = useExceptionListHeader({ + name, + description, + onEditListDetails, + }); + return ( +
+ + } + responsive + data-test-subj={`${dataTestSubj || ''}PageHeader`} + description={ +
+ +
+ {i18n.EXCEPTION_LIST_HEADER_LIST_ID}: + {listId} +
+
+ } + rightSideItems={[ + , + ]} + breadcrumbs={[ + { + text: ( +
+ + {i18n.EXCEPTION_LIST_HEADER_BREADCRUMB} +
+ ), + color: 'primary', + 'aria-current': false, + href: breadcrumbLink, + onClick: (e) => e.preventDefault(), + }, + ]} + /> + {isModalVisible && ( + + )} +
+ ); +}; + +ExceptionListHeaderComponent.displayName = 'ExceptionListHeaderComponent'; + +export const ExceptionListHeader = React.memo(ExceptionListHeaderComponent); + +ExceptionListHeader.displayName = 'ExceptionListHeader'; diff --git a/packages/kbn-securitysolution-exception-list-components/src/list_header/list_header.styles.ts b/packages/kbn-securitysolution-exception-list-components/src/list_header/list_header.styles.ts new file mode 100644 index 00000000000000..ab9d6a5c79d532 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/list_header/list_header.styles.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { css } from '@emotion/react'; +import { euiThemeVars } from '@kbn/ui-theme'; + +export const headerCss = css` + margin: ${euiThemeVars.euiSize}; +`; + +export const headerMenuCss = css` + border-right: 1px solid #d3dae6; + padding: ${euiThemeVars.euiSizeXS} ${euiThemeVars.euiSizeL} ${euiThemeVars.euiSizeXS} 0; +`; +export const textWithEditContainerCss = css` + display: flex; + width: fit-content; + align-items: baseline; + margin-bottom: ${euiThemeVars.euiSizeS}; + h1 { + margin-bottom: 0; + } +`; +export const textCss = css` + font-size: ${euiThemeVars.euiFontSize}; + color: ${euiThemeVars.euiTextSubduedColor}; + margin-left: ${euiThemeVars.euiSizeXS}; +`; +export const descriptionContainerCss = css` + margin-top: -${euiThemeVars.euiSizeL}; + margin-bottom: -${euiThemeVars.euiSizeL}; +`; diff --git a/packages/kbn-securitysolution-exception-list-components/src/list_header/list_header.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/list_header/list_header.test.tsx new file mode 100644 index 00000000000000..df56194ce88e2c --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/list_header/list_header.test.tsx @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { ExceptionListHeader } from '.'; +import * as i18n from '../translations'; +import { securityLinkAnchorComponentMock } from '../mocks/security_link_component.mock'; + +import { useExceptionListHeader as useExceptionListHeaderMock } from './use_list_header'; +const onEditListDetails = jest.fn(); +const onExportList = jest.fn(); +const onDeleteList = jest.fn(); +const onManageRules = jest.fn(); +jest.mock('./use_list_header'); + +describe('ExceptionListHeader', () => { + beforeAll(() => { + (useExceptionListHeaderMock as jest.Mock).mockReturnValue({ + isModalVisible: false, + listDetails: { name: 'List Name', description: '' }, + onSave: jest.fn(), + onCancel: jest.fn(), + }); + }); + it('should render the List Header with name, default description and disabled actions because of the ReadOnly mode', () => { + const wrapper = render( + + ); + expect(wrapper).toMatchSnapshot(); + fireEvent.click(wrapper.getByTestId('RightSideMenuItemsContainer')); + expect(wrapper.queryByTestId('MenuActions')).not.toBeInTheDocument(); + expect(wrapper.getByTestId('DescriptionText')).toHaveTextContent( + i18n.EXCEPTION_LIST_HEADER_DESCRIPTION + ); + expect(wrapper.queryByTestId('EditTitleIcon')).not.toBeInTheDocument(); + expect(wrapper.getByTestId('ListID')).toHaveTextContent( + `${i18n.EXCEPTION_LIST_HEADER_LIST_ID}:List_Id` + ); + expect(wrapper.getByTestId('Breadcrumb')).toHaveTextContent( + i18n.EXCEPTION_LIST_HEADER_BREADCRUMB + ); + }); + it('should render the List Header with name, default description and actions', () => { + const wrapper = render( + + ); + expect(wrapper).toMatchSnapshot(); + fireEvent.click(wrapper.getByTestId('RightSideMenuItemsContainer')); + expect(wrapper.getByTestId('DescriptionText')).toHaveTextContent( + i18n.EXCEPTION_LIST_HEADER_DESCRIPTION + ); + expect(wrapper.queryByTestId('TitleEditIcon')).toBeInTheDocument(); + expect(wrapper.queryByTestId('DescriptionEditIcon')).toBeInTheDocument(); + }); + it('should render edit modal', () => { + (useExceptionListHeaderMock as jest.Mock).mockReturnValue({ + isModalVisible: true, + listDetails: { name: 'List Name', description: 'List description' }, + onSave: jest.fn(), + onCancel: jest.fn(), + }); + const wrapper = render( + + ); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.getByTestId('EditModal')).toBeInTheDocument(); + }); +}); diff --git a/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/__snapshots__/menu_items.test.tsx.snap b/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/__snapshots__/menu_items.test.tsx.snap new file mode 100644 index 00000000000000..ab3ad9df8aa818 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/__snapshots__/menu_items.test.tsx.snap @@ -0,0 +1,248 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MenuItems should render linkedRules, manageRules and menuActions 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ , + "container":
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/index.tsx b/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/index.tsx new file mode 100644 index 00000000000000..1e13a8ac3d0f2b --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/index.tsx @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { FC, useMemo } from 'react'; +import { HeaderMenu } from '../../header_menu'; +import { headerMenuCss } from '../list_header.styles'; +import * as i18n from '../../translations'; +import { Rule } from '../../types'; +import { generateLinkedRulesMenuItems } from '../../generate_linked_rules_menu_item'; +interface MenuItemsProps { + isReadonly: boolean; + dataTestSubj?: string; + linkedRules: Rule[]; + securityLinkAnchorComponent: React.ElementType; // This property needs to be removed to avoid the Prop Drilling, once we move all the common components from x-pack/security-solution/common + onExportList: () => void; + onDeleteList: () => void; + onManageRules: () => void; +} + +const MenuItemsComponent: FC = ({ + dataTestSubj, + linkedRules, + securityLinkAnchorComponent, + isReadonly, + onExportList, + onDeleteList, + onManageRules, +}) => { + const referencedLinks = useMemo( + () => + generateLinkedRulesMenuItems({ + leftIcon: 'check', + dataTestSubj, + linkedRules, + securityLinkAnchorComponent, + }), + [dataTestSubj, linkedRules, securityLinkAnchorComponent] + ); + return ( + + + + + + + { + if (typeof onExportList === 'function') onManageRules(); + }} + > + {i18n.EXCEPTION_LIST_HEADER_MANAGE_RULES_BUTTON} + + + + + { + if (typeof onExportList === 'function') onExportList(); + }, + }, + { + key: '2', + icon: 'trash', + label: i18n.EXCEPTION_LIST_HEADER_DELETE_ACTION, + onClick: () => { + if (typeof onDeleteList === 'function') onDeleteList(); + }, + }, + ]} + disableActions={isReadonly} + anchorPosition="downCenter" + /> + + + ); +}; + +MenuItemsComponent.displayName = 'MenuItemsComponent'; + +export const MenuItems = React.memo(MenuItemsComponent); + +MenuItems.displayName = 'MenuItems'; diff --git a/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/menu_items.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/menu_items.test.tsx new file mode 100644 index 00000000000000..95d03a5b4678e5 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/menu_items.test.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { MenuItems } from '.'; +import { rules } from '../../mocks/rule_references.mock'; +import { securityLinkAnchorComponentMock } from '../../mocks/security_link_component.mock'; + +const onExportList = jest.fn(); +const onDeleteList = jest.fn(); +const onManageRules = jest.fn(); +describe('MenuItems', () => { + it('should render linkedRules, manageRules and menuActions', () => { + const wrapper = render( + + ); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.getByTestId('LinkedRulesMenuItems')).toHaveTextContent('Linked to 1 rules'); + expect(wrapper.getByTestId('ManageRulesButton')).toBeInTheDocument(); + expect(wrapper.getByTestId('MenuActionsButtonIcon')).toBeInTheDocument(); + }); + it('should call onManageRules', () => { + const wrapper = render( + + ); + fireEvent.click(wrapper.getByTestId('ManageRulesButton')); + expect(onManageRules).toHaveBeenCalled(); + }); + it('should call onExportList', () => { + const wrapper = render( + + ); + fireEvent.click(wrapper.getByTestId('MenuActionsButtonIcon')); + fireEvent.click(wrapper.getByTestId('MenuActionsActionItem1')); + + expect(onExportList).toHaveBeenCalled(); + }); + it('should call onDeleteList', () => { + const wrapper = render( + + ); + fireEvent.click(wrapper.getByTestId('MenuActionsButtonIcon')); + fireEvent.click(wrapper.getByTestId('MenuActionsActionItem2')); + + expect(onDeleteList).toHaveBeenCalled(); + }); +}); diff --git a/packages/kbn-securitysolution-exception-list-components/src/list_header/use_list_header.test.ts b/packages/kbn-securitysolution-exception-list-components/src/list_header/use_list_header.test.ts new file mode 100644 index 00000000000000..9ddd782e132cdf --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/list_header/use_list_header.test.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { waitFor } from '@testing-library/dom'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { useExceptionListHeader } from './use_list_header'; + +describe('useExceptionListHeader', () => { + const onEditListDetails = jest.fn(); + it('should return the default values', () => { + const { + result: { current }, + } = renderHook(() => + useExceptionListHeader({ name: 'list name', description: '', onEditListDetails }) + ); + const { isModalVisible, listDetails } = current; + expect(isModalVisible).toBeFalsy(); + expect(listDetails).toStrictEqual({ name: 'list name', description: '' }); + }); + it('should change the isModalVisible to be true when onEdit is called', () => { + const { + result: { current }, + } = renderHook(() => + useExceptionListHeader({ name: 'list name', description: '', onEditListDetails }) + ); + const { isModalVisible, onEdit } = current; + act(() => { + onEdit(); + }); + + waitFor(() => { + expect(isModalVisible).toBeTruthy(); + }); + }); + + it('should call onEditListDetails with the new details after editing', () => { + const { + result: { current }, + } = renderHook(() => + useExceptionListHeader({ name: 'list name', description: '', onEditListDetails }) + ); + const { isModalVisible, onEdit } = current; + act(() => { + onEdit(); + }); + + waitFor(() => { + expect(isModalVisible).toBeTruthy(); + }); + + const { onSave } = current; + act(() => { + onSave({ name: 'New name', description: 'New Description' }); + }); + + waitFor(() => { + expect(isModalVisible).toBeFalsy(); + expect(onEditListDetails).toBeCalledWith({ + name: 'New name', + description: 'New Description', + }); + }); + }); + it('should close the Modal when the cancel is called', () => { + const { + result: { current }, + } = renderHook(() => + useExceptionListHeader({ name: 'list name', description: '', onEditListDetails }) + ); + const { isModalVisible, onEdit } = current; + act(() => { + onEdit(); + }); + + waitFor(() => { + expect(isModalVisible).toBeTruthy(); + }); + + const { onCancel } = current; + act(() => { + onCancel(); + }); + + waitFor(() => { + expect(isModalVisible).toBeFalsy(); + }); + }); +}); diff --git a/packages/kbn-securitysolution-exception-list-components/src/list_header/use_list_header.ts b/packages/kbn-securitysolution-exception-list-components/src/list_header/use_list_header.ts new file mode 100644 index 00000000000000..01ddbf1ac68c63 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/list_header/use_list_header.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { useState } from 'react'; +import { ListDetails } from '../types'; + +interface UseExceptionListHeaderProps { + name: string; + description?: string; + onEditListDetails: (listDetails: ListDetails) => void; +} +export const useExceptionListHeader = ({ + name, + description, + onEditListDetails, +}: UseExceptionListHeaderProps) => { + const [isModalVisible, setIsModalVisible] = useState(false); + const [listDetails, setListDetails] = useState({ name, description }); + const onEdit = () => { + setIsModalVisible(true); + }; + const onSave = (newListDetails: ListDetails) => { + setIsModalVisible(false); + setListDetails(newListDetails); + if (typeof onEditListDetails === 'function') onEditListDetails(newListDetails); + }; + const onCancel = () => { + setIsModalVisible(false); + }; + + return { + isModalVisible, + listDetails, + onEdit, + onSave, + onCancel, + }; +}; diff --git a/packages/kbn-securitysolution-exception-list-components/src/test_helpers/comments.mock.ts b/packages/kbn-securitysolution-exception-list-components/src/mocks/comments.mock.tsx similarity index 78% rename from packages/kbn-securitysolution-exception-list-components/src/test_helpers/comments.mock.ts rename to packages/kbn-securitysolution-exception-list-components/src/mocks/comments.mock.tsx index 3e83aa53f0f23a..3d562f1aa316a4 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/test_helpers/comments.mock.ts +++ b/packages/kbn-securitysolution-exception-list-components/src/mocks/comments.mock.tsx @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import React from 'react'; import type { Comment, CommentsArray } from '@kbn/securitysolution-io-ts-list-types'; export const getCommentsMock = (): Comment => ({ @@ -16,3 +17,9 @@ export const getCommentsMock = (): Comment => ({ }); export const getCommentsArrayMock = (): CommentsArray => [getCommentsMock(), getCommentsMock()]; + +export const mockGetFormattedComments = () => + getCommentsArrayMock().map((comment) => ({ + username: comment.created_by, + children:

{comment.comment}

, + })); diff --git a/packages/kbn-securitysolution-exception-list-components/src/mocks/entry.mock.ts b/packages/kbn-securitysolution-exception-list-components/src/mocks/entry.mock.ts new file mode 100644 index 00000000000000..7b82d120a94703 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/mocks/entry.mock.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Entry } from '../exception_item_card/conditions/types'; + +export const includedListTypeEntry: Entry = { + field: '', + operator: 'included', + type: 'list', + list: { id: 'list_id', type: 'boolean' }, +}; + +export const includedMatchTypeEntry: Entry = { + field: '', + operator: 'included', + type: 'match', + value: 'matches value', +}; + +export const includedExistsTypeEntry: Entry = { + field: '', + operator: 'included', + type: 'exists', +}; diff --git a/packages/kbn-securitysolution-exception-list-components/src/test_helpers/exception_list_item_schema.mock.ts b/packages/kbn-securitysolution-exception-list-components/src/mocks/exception_list_item_schema.mock.ts similarity index 100% rename from packages/kbn-securitysolution-exception-list-components/src/test_helpers/exception_list_item_schema.mock.ts rename to packages/kbn-securitysolution-exception-list-components/src/mocks/exception_list_item_schema.mock.ts diff --git a/packages/kbn-securitysolution-exception-list-components/src/mocks/header.mock.ts b/packages/kbn-securitysolution-exception-list-components/src/mocks/header.mock.ts new file mode 100644 index 00000000000000..55e3199e0a99ed --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/mocks/header.mock.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +export const handleEdit = jest.fn(); +export const handleDelete = jest.fn(); +export const actions = [ + { + key: 'edit', + icon: 'pencil', + label: 'Edit detection exception', + onClick: handleEdit, + }, + { + key: 'delete', + icon: 'trash', + label: 'Delete detection exception', + onClick: handleDelete, + }, +]; diff --git a/packages/kbn-securitysolution-exception-list-components/src/mocks/rule_references.mock.ts b/packages/kbn-securitysolution-exception-list-components/src/mocks/rule_references.mock.ts new file mode 100644 index 00000000000000..d2a5e0ab0a756f --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/mocks/rule_references.mock.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Rule, RuleReference } from '../types'; + +export const rules: Rule[] = [ + { + exception_lists: [ + { + id: '123', + list_id: 'i_exist', + namespace_type: 'single', + type: 'detection', + }, + { + id: '456', + list_id: 'i_exist_2', + namespace_type: 'single', + type: 'detection', + }, + ], + id: '1a2b3c', + name: 'Simple Rule Query', + rule_id: 'rule-2', + }, +]; + +export const ruleReference: RuleReference = { + name: 'endpoint list', + id: 'endpoint_list', + referenced_rules: rules, + listId: 'endpoint_list_id', +}; + +export const ruleReferences = { + endpoint_list_id: ruleReference, +}; diff --git a/packages/kbn-securitysolution-exception-list-components/src/mocks/security_link_component.mock.tsx b/packages/kbn-securitysolution-exception-list-components/src/mocks/security_link_component.mock.tsx new file mode 100644 index 00000000000000..db0a64affc1823 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/mocks/security_link_component.mock.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React, { ReactElement } from 'react'; +import { generateLinkedRulesMenuItems } from '../generate_linked_rules_menu_item'; +import { rules } from './rule_references.mock'; +export const securityLinkAnchorComponentMock = ({ + referenceName, + referenceId, +}: { + referenceName: string; + referenceId: string; +}) => ( + +); + +export const getSecurityLinkAction = (dataTestSubj: string) => + generateLinkedRulesMenuItems({ + dataTestSubj, + linkedRules: [ + ...rules, + { + exception_lists: [], + id: '2a2b3c', + name: 'Simple Rule Query 2', + rule_id: 'rule-2', + }, + ], + securityLinkAnchorComponent: securityLinkAnchorComponentMock, + }) as ReactElement[]; diff --git a/packages/kbn-securitysolution-exception-list-components/src/search_bar/search_bar.tsx b/packages/kbn-securitysolution-exception-list-components/src/search_bar/index.tsx similarity index 92% rename from packages/kbn-securitysolution-exception-list-components/src/search_bar/search_bar.tsx rename to packages/kbn-securitysolution-exception-list-components/src/search_bar/index.tsx index bb8dc6ee625591..a40393bac8fcce 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/search_bar/search_bar.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/search_bar/index.tsx @@ -9,7 +9,7 @@ import React, { useCallback } from 'react'; import type { FC } from 'react'; -import type { SearchFilterConfig } from '@elastic/eui'; +import type { IconType, SearchFilterConfig } from '@elastic/eui'; import { EuiFlexGroup, EuiFlexItem, EuiButton, EuiSearchBar } from '@elastic/eui'; import type { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import type { GetExceptionItemProps } from '../types'; @@ -55,6 +55,8 @@ interface SearchBarProps { isSearching?: boolean; dataTestSubj?: string; filters?: SearchFilterConfig[]; // TODO about filters + isButtonFilled?: boolean; + buttonIconType?: IconType; onSearch: (arg: GetExceptionItemProps) => void; onAddExceptionClick: (type: ExceptionListTypeEnum) => void; } @@ -66,6 +68,8 @@ const SearchBarComponent: FC = ({ isSearching, dataTestSubj, filters = [], + isButtonFilled = true, + buttonIconType, onSearch, onAddExceptionClick, }) => { @@ -101,7 +105,8 @@ const SearchBarComponent: FC = ({ data-test-subj={`${dataTestSubj || ''}Button`} onClick={handleAddException} isDisabled={isSearching} - fill + fill={isButtonFilled} + iconType={buttonIconType} > {addExceptionButtonText} diff --git a/packages/kbn-securitysolution-exception-list-components/src/search_bar/search_bar.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/search_bar/search_bar.test.tsx index ac82bb3b6e8503..e6efe4eefb29b6 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/search_bar/search_bar.test.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/search_bar/search_bar.test.tsx @@ -11,7 +11,7 @@ import { fireEvent, render } from '@testing-library/react'; import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; -import { SearchBar } from './search_bar'; +import { SearchBar } from '.'; describe('SearchBar', () => { it('it does not display add exception button if user is read only', () => { diff --git a/packages/kbn-securitysolution-exception-list-components/src/text_with_edit/__snapshots__/text_with_edit.test.tsx.snap b/packages/kbn-securitysolution-exception-list-components/src/text_with_edit/__snapshots__/text_with_edit.test.tsx.snap new file mode 100644 index 00000000000000..4543f84553ae66 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/text_with_edit/__snapshots__/text_with_edit.test.tsx.snap @@ -0,0 +1,189 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TextWithEdit should not render the edit icon when isReadonly is true 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+ + Test + +
+
+ , + "container":
+
+ + Test + +
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`TextWithEdit should render the edit icon when isReadonly is false 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+ + Test + + +
+
+ , + "container":
+
+ + Test + + +
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/packages/kbn-securitysolution-exception-list-components/src/text_with_edit/index.tsx b/packages/kbn-securitysolution-exception-list-components/src/text_with_edit/index.tsx new file mode 100644 index 00000000000000..5b56b27053396e --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/text_with_edit/index.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC } from 'react'; +import { EuiButtonIcon } from '@elastic/eui'; +import { Interpolation, Theme } from '@emotion/react'; +import { textWithEditContainerCss } from '../list_header/list_header.styles'; + +interface TextWithEditProps { + isReadonly: boolean; + dataTestSubj?: string; + text: string; + textCss?: Interpolation; + onEdit?: () => void; +} + +const TextWithEditComponent: FC = ({ + isReadonly, + dataTestSubj, + text, + onEdit, + textCss, +}) => { + return ( +
+ + {text} + + {isReadonly ? null : ( + (typeof onEdit === 'function' ? onEdit() : null)} + /> + )} +
+ ); +}; +TextWithEditComponent.displayName = 'TextWithEditComponent'; + +export const TextWithEdit = React.memo(TextWithEditComponent); + +TextWithEdit.displayName = 'TextWithEdit'; diff --git a/packages/kbn-securitysolution-exception-list-components/src/text_with_edit/text_with_edit.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/text_with_edit/text_with_edit.test.tsx new file mode 100644 index 00000000000000..a6973830e1d4ed --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/text_with_edit/text_with_edit.test.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { TextWithEdit } from '.'; + +describe('TextWithEdit', () => { + it('should not render the edit icon when isReadonly is true', () => { + const wrapper = render( + + ); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.getByTestId('TextWithEditTestText')).toHaveTextContent('Test'); + expect(wrapper.queryByTestId('TextWithEditTestEditIcon')).not.toBeInTheDocument(); + }); + it('should render the edit icon when isReadonly is false', () => { + const wrapper = render( + + ); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.getByTestId('TextWithEditTestText')).toHaveTextContent('Test'); + expect(wrapper.getByTestId('TextWithEditTestEditIcon')).toBeInTheDocument(); + }); + it('should not call onEdit', () => { + const onEdit = ''; + const wrapper = render( + + ); + const editIcon = wrapper.getByTestId('TextWithEditTestEditIcon'); + expect(wrapper.getByTestId('TextWithEditTestText')).toHaveTextContent('Test'); + expect(editIcon).toBeInTheDocument(); + fireEvent.click(editIcon); + }); + it('should call onEdit', () => { + const onEdit = jest.fn(); + + const wrapper = render( + + ); + expect(wrapper.getByTestId('TextWithEditTestText')).toHaveTextContent('Test'); + expect(wrapper.queryByTestId('TextWithEditTestEditIcon')).toBeInTheDocument(); + fireEvent.click(wrapper.getByTestId('TextWithEditTestEditIcon')); + expect(onEdit).toBeCalled(); + }); +}); diff --git a/packages/kbn-securitysolution-exception-list-components/src/translations.ts b/packages/kbn-securitysolution-exception-list-components/src/translations.ts index c919ef423c5456..c5740958abcf4a 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/translations.ts +++ b/packages/kbn-securitysolution-exception-list-components/src/translations.ts @@ -55,3 +55,88 @@ export const EMPTY_VIEWER_STATE_ERROR_BODY = i18n.translate( 'There was an error loading the exception items. Contact your administrator for help.', } ); +export const EXCEPTION_LIST_HEADER_EXPORT_ACTION = i18n.translate( + 'exceptionList-components.exception_list_header_export_action', + { + defaultMessage: 'Export exception list', + } +); +export const EXCEPTION_LIST_HEADER_DELETE_ACTION = i18n.translate( + 'exceptionList-components.exception_list_header_delete_action', + { + defaultMessage: 'Delete exception list', + } +); +export const EXCEPTION_LIST_HEADER_MANAGE_RULES_BUTTON = i18n.translate( + 'exceptionList-components.exception_list_header_manage_rules_button', + { + defaultMessage: 'Manage rules', + } +); + +export const EXCEPTION_LIST_HEADER_LINKED_RULES = (noOfRules: number) => + i18n.translate('exceptionList-components.exception_list_header_linked_rules', { + values: { noOfRules }, + defaultMessage: 'Linked to {noOfRules} rules', + }); + +export const EXCEPTION_LIST_HEADER_BREADCRUMB = i18n.translate( + 'exceptionList-components.exception_list_header_breadcrumb', + { + defaultMessage: 'Rule exceptions', + } +); + +export const EXCEPTION_LIST_HEADER_LIST_ID = i18n.translate( + 'exceptionList-components.exception_list_header_list_id', + { + defaultMessage: 'List ID', + } +); + +export const EXCEPTION_LIST_HEADER_NAME = i18n.translate( + 'exceptionList-components.exception_list_header_name', + { + defaultMessage: 'Add a name', + } +); + +export const EXCEPTION_LIST_HEADER_DESCRIPTION = i18n.translate( + 'exceptionList-components.exception_list_header_description', + { + defaultMessage: 'Add a description', + } +); + +export const EXCEPTION_LIST_HEADER_EDIT_MODAL_TITLE = (listName: string) => + i18n.translate('exceptionList-components.exception_list_header_edit_modal_name', { + defaultMessage: 'Edit {listName}', + values: { listName }, + }); + +export const EXCEPTION_LIST_HEADER_EDIT_MODAL_SAVE_BUTTON = i18n.translate( + 'exceptionList-components.exception_list_header_edit_modal_save_button', + { + defaultMessage: 'Save', + } +); + +export const EXCEPTION_LIST_HEADER_EDIT_MODAL_CANCEL_BUTTON = i18n.translate( + 'exceptionList-components.exception_list_header_edit_modal_cancel_button', + { + defaultMessage: 'Cancel', + } +); +export const EXCEPTION_LIST_HEADER_NAME_TEXTBOX = i18n.translate( + 'exceptionList-components.exception_list_header_Name_textbox', + { + defaultMessage: 'Name', + } +); + +export const EXCEPTION_LIST_HEADER_DESCRIPTION_TEXTBOX = i18n.translate( + 'exceptionList-components.exception_list_header_description_textbox', + { + defaultMessage: 'Description', + } +); diff --git a/packages/kbn-securitysolution-exception-list-components/src/types/index.ts b/packages/kbn-securitysolution-exception-list-components/src/types/index.ts index dbb402ca784515..d799879916dee1 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/types/index.ts +++ b/packages/kbn-securitysolution-exception-list-components/src/types/index.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { ExceptionListSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { ListArray } from '@kbn/securitysolution-io-ts-list-types'; import type { Pagination } from '@elastic/eui'; import type { NamespaceType } from '@kbn/securitysolution-io-ts-list-types'; @@ -42,7 +42,7 @@ export interface ExceptionListSummaryProps { export type ViewerFlyoutName = 'addException' | 'editException' | null; export interface RuleReferences { - [key: string]: any[]; // TODO fix + [key: string]: RuleReference; } export interface ExceptionListItemIdentifiers { @@ -56,10 +56,21 @@ export enum ListTypeText { DETECTION = 'empty', RULE_DEFAULT = 'empty_search', } +export interface Rule { + name: string; + id: string; + rule_id: string; + exception_lists: ListArray; +} export interface RuleReference { name: string; id: string; - ruleId: string; - exceptionLists: ExceptionListSchema[]; + referenced_rules: Rule[]; + listId?: string; +} + +export interface ListDetails { + name: string; + description?: string; } diff --git a/packages/kbn-securitysolution-list-utils/src/helpers/index.ts b/packages/kbn-securitysolution-list-utils/src/helpers/index.ts index 6f4bc7d51052fe..945afbbc8604e1 100644 --- a/packages/kbn-securitysolution-list-utils/src/helpers/index.ts +++ b/packages/kbn-securitysolution-list-utils/src/helpers/index.ts @@ -14,7 +14,6 @@ import { EntriesArray, Entry, EntryNested, - ExceptionListItemSchema, ExceptionListType, ListSchema, NamespaceType, @@ -27,6 +26,8 @@ import { entry, exceptionListItemSchema, nestedEntryItem, + CreateRuleExceptionListItemSchema, + createRuleExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; import { DataViewBase, @@ -55,6 +56,7 @@ import { EmptyEntry, EmptyNestedEntry, ExceptionsBuilderExceptionItem, + ExceptionsBuilderReturnExceptionItem, FormattedBuilderEntry, OperatorOption, } from '../types'; @@ -65,59 +67,60 @@ export const isEntryNested = (item: BuilderEntry): item is EntryNested => { export const filterExceptionItems = ( exceptions: ExceptionsBuilderExceptionItem[] -): Array => { - return exceptions.reduce>( - (acc, exception) => { - const entries = exception.entries.reduce((nestedAcc, singleEntry) => { - const strippedSingleEntry = removeIdFromItem(singleEntry); - - if (entriesNested.is(strippedSingleEntry)) { - const nestedEntriesArray = strippedSingleEntry.entries.filter((singleNestedEntry) => { - const noIdSingleNestedEntry = removeIdFromItem(singleNestedEntry); - const [validatedNestedEntry] = validate(noIdSingleNestedEntry, nestedEntryItem); - return validatedNestedEntry != null; - }); - const noIdNestedEntries = nestedEntriesArray.map((singleNestedEntry) => - removeIdFromItem(singleNestedEntry) - ); - - const [validatedNestedEntry] = validate( - { ...strippedSingleEntry, entries: noIdNestedEntries }, - entriesNested - ); - - if (validatedNestedEntry != null) { - return [...nestedAcc, { ...singleEntry, entries: nestedEntriesArray }]; - } - return nestedAcc; - } else { - const [validatedEntry] = validate(strippedSingleEntry, entry); - - if (validatedEntry != null) { - return [...nestedAcc, singleEntry]; - } - return nestedAcc; +): ExceptionsBuilderReturnExceptionItem[] => { + return exceptions.reduce((acc, exception) => { + const entries = exception.entries.reduce((nestedAcc, singleEntry) => { + const strippedSingleEntry = removeIdFromItem(singleEntry); + if (entriesNested.is(strippedSingleEntry)) { + const nestedEntriesArray = strippedSingleEntry.entries.filter((singleNestedEntry) => { + const noIdSingleNestedEntry = removeIdFromItem(singleNestedEntry); + const [validatedNestedEntry] = validate(noIdSingleNestedEntry, nestedEntryItem); + return validatedNestedEntry != null; + }); + const noIdNestedEntries = nestedEntriesArray.map((singleNestedEntry) => + removeIdFromItem(singleNestedEntry) + ); + + const [validatedNestedEntry] = validate( + { ...strippedSingleEntry, entries: noIdNestedEntries }, + entriesNested + ); + + if (validatedNestedEntry != null) { + return [...nestedAcc, { ...singleEntry, entries: nestedEntriesArray }]; } - }, []); - - if (entries.length === 0) { - return acc; + return nestedAcc; + } else { + const [validatedEntry] = validate(strippedSingleEntry, entry); + if (validatedEntry != null) { + return [...nestedAcc, singleEntry]; + } + return nestedAcc; } + }, []); - const item = { ...exception, entries }; + if (entries.length === 0) { + return acc; + } - if (exceptionListItemSchema.is(item)) { - return [...acc, item]; - } else if (createExceptionListItemSchema.is(item)) { - const { meta, ...rest } = item; - const itemSansMetaId: CreateExceptionListItemSchema = { ...rest, meta: undefined }; - return [...acc, itemSansMetaId]; - } else { - return acc; - } - }, - [] - ); + const item = { ...exception, entries }; + + if (exceptionListItemSchema.is(item)) { + return [...acc, item]; + } else if ( + createExceptionListItemSchema.is(item) || + createRuleExceptionListItemSchema.is(item) + ) { + const { meta, ...rest } = item; + const itemSansMetaId: CreateExceptionListItemSchema | CreateRuleExceptionListItemSchema = { + ...rest, + meta: undefined, + }; + return [...acc, itemSansMetaId]; + } else { + return acc; + } + }, []); }; export const addIdToEntries = (entries: EntriesArray): EntriesArray => { @@ -136,15 +139,15 @@ export const addIdToEntries = (entries: EntriesArray): EntriesArray => { export const getNewExceptionItem = ({ listId, namespaceType, - ruleName, + name, }: { listId: string | undefined; namespaceType: NamespaceType | undefined; - ruleName: string; + name: string; }): CreateExceptionListItemBuilderSchema => { return { comments: [], - description: 'Exception list item', + description: `Exception list item`, entries: addIdToEntries([ { field: '', @@ -158,7 +161,7 @@ export const getNewExceptionItem = ({ meta: { temporaryUuid: uuid.v4(), }, - name: `${ruleName} - exception list item`, + name, namespace_type: namespaceType, tags: [], type: 'simple', @@ -769,13 +772,15 @@ export const getCorrespondingKeywordField = ({ * @param parent nested entries hold copy of their parent for use in various logic * @param parentIndex corresponds to the entry index, this might seem obvious, but * was added to ensure that nested items could be identified with their parent entry + * @param allowCustomFieldOptions determines if field must be found to match in indexPattern or not */ export const getFormattedBuilderEntry = ( indexPattern: DataViewBase, item: BuilderEntry, itemIndex: number, parent: EntryNested | undefined, - parentIndex: number | undefined + parentIndex: number | undefined, + allowCustomFieldOptions: boolean ): FormattedBuilderEntry => { const { fields } = indexPattern; const field = parent != null ? `${parent.field}.${item.field}` : item.field; @@ -800,10 +805,14 @@ export const getFormattedBuilderEntry = ( value: getEntryValue(item), }; } else { + const fieldToUse = allowCustomFieldOptions + ? foundField ?? { name: item.field, type: 'keyword' } + : foundField; + return { correspondingKeywordField, entryIndex: itemIndex, - field: foundField, + field: fieldToUse, id: item.id != null ? item.id : `${itemIndex}`, nested: undefined, operator: getExceptionOperatorSelect(item), @@ -819,8 +828,7 @@ export const getFormattedBuilderEntry = ( * * @param patterns DataViewBase containing available fields on rule index * @param entries exception item entries - * @param addNested boolean noting whether or not UI is currently - * set to add a nested field + * @param allowCustomFieldOptions determines if field must be found to match in indexPattern or not * @param parent nested entries hold copy of their parent for use in various logic * @param parentIndex corresponds to the entry index, this might seem obvious, but * was added to ensure that nested items could be identified with their parent entry @@ -828,6 +836,7 @@ export const getFormattedBuilderEntry = ( export const getFormattedBuilderEntries = ( indexPattern: DataViewBase, entries: BuilderEntry[], + allowCustomFieldOptions: boolean, parent?: EntryNested, parentIndex?: number ): FormattedBuilderEntry[] => { @@ -839,7 +848,8 @@ export const getFormattedBuilderEntries = ( item, index, parent, - parentIndex + parentIndex, + allowCustomFieldOptions ); return [...acc, newItemEntry]; } else { @@ -869,7 +879,13 @@ export const getFormattedBuilderEntries = ( } if (isEntryNested(item)) { - const nestedItems = getFormattedBuilderEntries(indexPattern, item.entries, item, index); + const nestedItems = getFormattedBuilderEntries( + indexPattern, + item.entries, + allowCustomFieldOptions, + item, + index + ); return [...acc, parentEntry, ...nestedItems]; } diff --git a/src/plugins/controls/public/control_group/control_group_renderer.tsx b/src/plugins/controls/public/control_group/control_group_renderer.tsx new file mode 100644 index 00000000000000..a1560a02568c08 --- /dev/null +++ b/src/plugins/controls/public/control_group/control_group_renderer.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import uuid from 'uuid'; +import useLifecycles from 'react-use/lib/useLifecycles'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; + +import { IEmbeddable } from '@kbn/embeddable-plugin/public'; + +import { pluginServices } from '../services'; +import { getDefaultControlGroupInput } from '../../common'; +import { ControlGroupInput, ControlGroupOutput, CONTROL_GROUP_TYPE } from './types'; +import { ControlGroupContainer } from './embeddable/control_group_container'; + +export interface ControlGroupRendererProps { + input?: Partial>; + onEmbeddableLoad: (controlGroupContainer: ControlGroupContainer) => void; +} + +export const ControlGroupRenderer = ({ input, onEmbeddableLoad }: ControlGroupRendererProps) => { + const controlsRoot = useRef(null); + const [controlGroupContainer, setControlGroupContainer] = useState(); + + const id = useMemo(() => uuid.v4(), []); + + /** + * Use Lifecycles to load initial control group container + */ + useLifecycles( + () => { + const { embeddable } = pluginServices.getServices(); + + (async () => { + const container = (await embeddable + .getEmbeddableFactory< + ControlGroupInput, + ControlGroupOutput, + IEmbeddable + >(CONTROL_GROUP_TYPE) + ?.create({ id, ...getDefaultControlGroupInput(), ...input })) as ControlGroupContainer; + + if (controlsRoot.current) { + container.render(controlsRoot.current); + } + setControlGroupContainer(container); + onEmbeddableLoad(container); + })(); + }, + () => { + controlGroupContainer?.destroy(); + } + ); + + /** + * Update embeddable input when props input changes + */ + useEffect(() => { + let updateCanceled = false; + (async () => { + // check if applying input from props would result in any changes to the embeddable input + const isInputEqual = await controlGroupContainer?.getExplicitInputIsEqual({ + ...controlGroupContainer?.getInput(), + ...input, + }); + if (!controlGroupContainer || isInputEqual || updateCanceled) return; + controlGroupContainer.updateInput({ ...input }); + })(); + + return () => { + updateCanceled = true; + }; + }, [controlGroupContainer, input]); + + return
; +}; + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default ControlGroupRenderer; diff --git a/src/plugins/controls/public/control_group/control_group_strings.ts b/src/plugins/controls/public/control_group/control_group_strings.ts index 238a3b73f75763..381321c876c3ec 100644 --- a/src/plugins/controls/public/control_group/control_group_strings.ts +++ b/src/plugins/controls/public/control_group/control_group_strings.ts @@ -9,10 +9,6 @@ import { i18n } from '@kbn/i18n'; export const ControlGroupStrings = { - getEmbeddableTitle: () => - i18n.translate('controls.controlGroup.title', { - defaultMessage: 'Control group', - }), getControlButtonTitle: () => i18n.translate('controls.controlGroup.toolbarButtonTitle', { defaultMessage: 'Controls', diff --git a/src/plugins/controls/public/control_group/editor/control_editor.tsx b/src/plugins/controls/public/control_group/editor/control_editor.tsx index fdc54dd3cad626..1e9c42ff420dea 100644 --- a/src/plugins/controls/public/control_group/editor/control_editor.tsx +++ b/src/plugins/controls/public/control_group/editor/control_editor.tsx @@ -14,7 +14,7 @@ * Side Public License, v 1. */ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import useMount from 'react-use/lib/useMount'; import { @@ -36,7 +36,6 @@ import { EuiTextColor, } from '@elastic/eui'; import { DataViewListItem, DataView, DataViewField } from '@kbn/data-views-plugin/common'; -import { IFieldSubTypeMulti } from '@kbn/es-query'; import { LazyDataViewPicker, LazyFieldPicker, @@ -53,6 +52,7 @@ import { } from '../../types'; import { CONTROL_WIDTH_OPTIONS } from './editor_constants'; import { pluginServices } from '../../services'; +import { loadFieldRegistryFromDataViewId } from './data_control_editor_tools'; interface EditControlProps { embeddable?: ControlEmbeddable; isCreate: boolean; @@ -97,7 +97,7 @@ export const ControlEditor = ({ }: EditControlProps) => { const { dataViews: { getIdsWithTitle, getDefaultId, get }, - controls: { getControlTypes, getControlFactory }, + controls: { getControlFactory }, } = pluginServices.getServices(); const [state, setState] = useState({ dataViewListItems: [], @@ -112,49 +112,14 @@ export const ControlEditor = ({ embeddable ? embeddable.getInput().fieldName : undefined ); - const doubleLinkFields = (dataView: DataView) => { - // double link the parent-child relationship specifically for case-sensitivity support for options lists - const fieldRegistry: DataControlFieldRegistry = {}; - - for (const field of dataView.fields.getAll()) { - if (!fieldRegistry[field.name]) { - fieldRegistry[field.name] = { field, compatibleControlTypes: [] }; - } - const parentFieldName = (field.subType as IFieldSubTypeMulti)?.multi?.parent; - if (parentFieldName) { - fieldRegistry[field.name].parentFieldName = parentFieldName; - - const parentField = dataView.getFieldByName(parentFieldName); - if (!fieldRegistry[parentFieldName] && parentField) { - fieldRegistry[parentFieldName] = { field: parentField, compatibleControlTypes: [] }; - } - fieldRegistry[parentFieldName].childFieldName = field.name; - } - } - return fieldRegistry; - }; - - const fieldRegistry = useMemo(() => { - if (!state.selectedDataView) return; - const newFieldRegistry: DataControlFieldRegistry = doubleLinkFields(state.selectedDataView); - - const controlFactories = getControlTypes().map( - (controlType) => getControlFactory(controlType) as IEditableControlFactory - ); - state.selectedDataView.fields.map((dataViewField) => { - for (const factory of controlFactories) { - if (factory.isFieldCompatible) { - factory.isFieldCompatible(newFieldRegistry[dataViewField.name]); - } - } - - if (newFieldRegistry[dataViewField.name]?.compatibleControlTypes.length === 0) { - delete newFieldRegistry[dataViewField.name]; + const [fieldRegistry, setFieldRegistry] = useState(); + useEffect(() => { + (async () => { + if (state.selectedDataView?.id) { + setFieldRegistry(await loadFieldRegistryFromDataViewId(state.selectedDataView.id)); } - }); - - return newFieldRegistry; - }, [state.selectedDataView, getControlFactory, getControlTypes]); + })(); + }, [state.selectedDataView]); useMount(() => { let mounted = true; diff --git a/src/plugins/controls/public/control_group/editor/data_control_editor_tools.ts b/src/plugins/controls/public/control_group/editor/data_control_editor_tools.ts new file mode 100644 index 00000000000000..cb0d1db5f4a899 --- /dev/null +++ b/src/plugins/controls/public/control_group/editor/data_control_editor_tools.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { IFieldSubTypeMulti } from '@kbn/es-query'; +import { DataView } from '@kbn/data-views-plugin/common'; + +import { pluginServices } from '../../services'; +import { DataControlFieldRegistry, IEditableControlFactory } from '../../types'; + +const dataControlFieldRegistryCache: { [key: string]: DataControlFieldRegistry } = {}; + +const doubleLinkFields = (dataView: DataView) => { + // double link the parent-child relationship specifically for case-sensitivity support for options lists + const fieldRegistry: DataControlFieldRegistry = {}; + + for (const field of dataView.fields.getAll()) { + if (!fieldRegistry[field.name]) { + fieldRegistry[field.name] = { field, compatibleControlTypes: [] }; + } + const parentFieldName = (field.subType as IFieldSubTypeMulti)?.multi?.parent; + if (parentFieldName) { + fieldRegistry[field.name].parentFieldName = parentFieldName; + + const parentField = dataView.getFieldByName(parentFieldName); + if (!fieldRegistry[parentFieldName] && parentField) { + fieldRegistry[parentFieldName] = { field: parentField, compatibleControlTypes: [] }; + } + fieldRegistry[parentFieldName].childFieldName = field.name; + } + } + return fieldRegistry; +}; + +export const loadFieldRegistryFromDataViewId = async ( + dataViewId: string +): Promise => { + if (dataControlFieldRegistryCache[dataViewId]) { + return dataControlFieldRegistryCache[dataViewId]; + } + const { + dataViews, + controls: { getControlTypes, getControlFactory }, + } = pluginServices.getServices(); + const dataView = await dataViews.get(dataViewId); + + const newFieldRegistry: DataControlFieldRegistry = doubleLinkFields(dataView); + + const controlFactories = getControlTypes().map( + (controlType) => getControlFactory(controlType) as IEditableControlFactory + ); + dataView.fields.map((dataViewField) => { + for (const factory of controlFactories) { + if (factory.isFieldCompatible) { + factory.isFieldCompatible(newFieldRegistry[dataViewField.name]); + } + } + + if (newFieldRegistry[dataViewField.name]?.compatibleControlTypes.length === 0) { + delete newFieldRegistry[dataViewField.name]; + } + }); + dataControlFieldRegistryCache[dataViewId] = newFieldRegistry; + + return newFieldRegistry; +}; diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx index a1d8bc06f822a0..35c251a179d097 100644 --- a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx +++ b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx @@ -9,7 +9,7 @@ import { skip, debounceTime, distinctUntilChanged } from 'rxjs/operators'; import React from 'react'; import ReactDOM from 'react-dom'; -import { Filter, uniqFilters } from '@kbn/es-query'; +import { compareFilters, COMPARE_ALL_OPTIONS, Filter, uniqFilters } from '@kbn/es-query'; import { BehaviorSubject, merge, Subject, Subscription } from 'rxjs'; import { EuiContextMenuPanel } from '@elastic/eui'; @@ -39,10 +39,11 @@ import { ControlGroupStrings } from '../control_group_strings'; import { EditControlGroup } from '../editor/edit_control_group'; import { ControlGroup } from '../component/control_group_component'; import { controlGroupReducers } from '../state/control_group_reducers'; -import { ControlEmbeddable, ControlInput, ControlOutput } from '../../types'; +import { ControlEmbeddable, ControlInput, ControlOutput, DataControlInput } from '../../types'; import { CreateControlButton, CreateControlButtonTypes } from '../editor/create_control'; import { CreateTimeSliderControlButton } from '../editor/create_time_slider_control'; import { TIME_SLIDER_CONTROL } from '../../time_slider'; +import { loadFieldRegistryFromDataViewId } from '../editor/data_control_editor_tools'; let flyoutRef: OverlayRef | undefined; export const setFlyoutRef = (newRef: OverlayRef | undefined) => { @@ -70,6 +71,9 @@ export class ControlGroupContainer extends Container< typeof controlGroupReducers >; + public onFiltersPublished$: Subject; + public onControlRemoved$: Subject; + public setLastUsedDataViewId = (lastUsedDataViewId: string) => { this.lastUsedDataViewId = lastUsedDataViewId; }; @@ -87,6 +91,27 @@ export class ControlGroupContainer extends Container< flyoutRef = undefined; } + public async addDataControlFromField({ + uuid, + dataViewId, + fieldName, + title, + }: { + uuid?: string; + dataViewId: string; + fieldName: string; + title?: string; + }) { + const fieldRegistry = await loadFieldRegistryFromDataViewId(dataViewId); + const field = fieldRegistry[fieldName]; + return this.addNewEmbeddable(field.compatibleControlTypes[0], { + id: uuid, + dataViewId, + fieldName, + title: title ?? fieldName, + } as DataControlInput); + } + /** * Returns a button that allows controls to be created externally using the embeddable * @param buttonType Controls the button styling @@ -185,6 +210,8 @@ export class ControlGroupContainer extends Container< ); this.recalculateFilters$ = new Subject(); + this.onFiltersPublished$ = new Subject(); + this.onControlRemoved$ = new Subject(); // build redux embeddable tools this.reduxEmbeddableTools = reduxEmbeddablePackage.createTools< @@ -249,6 +276,10 @@ export class ControlGroupContainer extends Container< return Object.keys(this.getInput().panels).length; }; + public updateFilterContext = (filters: Filter[]) => { + this.updateInput({ filters }); + }; + private recalculateFilters = () => { const allFilters: Filter[] = []; let timeslice; @@ -259,7 +290,11 @@ export class ControlGroupContainer extends Container< timeslice = childOutput.timeslice; } }); - this.updateOutput({ filters: uniqFilters(allFilters), timeslice }); + // if filters are different, publish them + if (!compareFilters(this.output.filters ?? [], allFilters ?? [], COMPARE_ALL_OPTIONS)) { + this.updateOutput({ filters: uniqFilters(allFilters), timeslice }); + this.onFiltersPublished$.next(allFilters); + } }; private recalculateDataViews = () => { @@ -304,6 +339,7 @@ export class ControlGroupContainer extends Container< order: currentOrder - 1, }; } + this.onControlRemoved$.next(idToRemove); return newPanels; } diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_container_factory.ts b/src/plugins/controls/public/control_group/embeddable/control_group_container_factory.ts index bcbd31955f36ed..366ad399baeea4 100644 --- a/src/plugins/controls/public/control_group/embeddable/control_group_container_factory.ts +++ b/src/plugins/controls/public/control_group/embeddable/control_group_container_factory.ts @@ -14,12 +14,12 @@ * Side Public License, v 1. */ +import { i18n } from '@kbn/i18n'; import { Container, EmbeddableFactoryDefinition } from '@kbn/embeddable-plugin/public'; import { lazyLoadReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public'; import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common'; import { ControlGroupInput, CONTROL_GROUP_TYPE } from '../types'; -import { ControlGroupStrings } from '../control_group_strings'; import { createControlGroupExtract, createControlGroupInject, @@ -40,7 +40,9 @@ export class ControlGroupContainerFactory implements EmbeddableFactoryDefinition public isEditable = async () => false; public readonly getDisplayName = () => { - return ControlGroupStrings.getEmbeddableTitle(); + return i18n.translate('controls.controlGroup.title', { + defaultMessage: 'Control group', + }); }; public getDefaultInput(): Partial { diff --git a/src/plugins/controls/public/control_group/index.ts b/src/plugins/controls/public/control_group/index.ts index 60050786d7c115..ded1c29934d6e5 100644 --- a/src/plugins/controls/public/control_group/index.ts +++ b/src/plugins/controls/public/control_group/index.ts @@ -6,8 +6,13 @@ * Side Public License, v 1. */ +import React from 'react'; + export type { ControlGroupContainer } from './embeddable/control_group_container'; export type { ControlGroupInput, ControlGroupOutput } from './types'; export { CONTROL_GROUP_TYPE } from './types'; export { ControlGroupContainerFactory } from './embeddable/control_group_container_factory'; + +export type { ControlGroupRendererProps } from './control_group_renderer'; +export const LazyControlGroupRenderer = React.lazy(() => import('./control_group_renderer')); diff --git a/src/plugins/controls/public/index.ts b/src/plugins/controls/public/index.ts index f55df5fa0f53a8..ac7a2ab23df84b 100644 --- a/src/plugins/controls/public/index.ts +++ b/src/plugins/controls/public/index.ts @@ -51,6 +51,7 @@ export { } from './range_slider'; export { LazyControlsCallout, type CalloutProps } from './controls_callout'; +export { LazyControlGroupRenderer, type ControlGroupRendererProps } from './control_group'; export function plugin() { return new ControlsPlugin(); diff --git a/src/plugins/controls/public/services/embeddable/embeddable.story.ts b/src/plugins/controls/public/services/embeddable/embeddable.story.ts new file mode 100644 index 00000000000000..caeb5c0395fa75 --- /dev/null +++ b/src/plugins/controls/public/services/embeddable/embeddable.story.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PluginServiceFactory } from '@kbn/presentation-util-plugin/public'; +import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks'; +import { ControlsEmbeddableService } from './types'; + +export type EmbeddableServiceFactory = PluginServiceFactory; +export const embeddableServiceFactory: EmbeddableServiceFactory = () => { + const { doStart } = embeddablePluginMock.createInstance(); + const start = doStart(); + return { getEmbeddableFactory: start.getEmbeddableFactory }; +}; diff --git a/src/plugins/controls/public/services/embeddable/embeddable_service.ts b/src/plugins/controls/public/services/embeddable/embeddable_service.ts new file mode 100644 index 00000000000000..06111098e673b6 --- /dev/null +++ b/src/plugins/controls/public/services/embeddable/embeddable_service.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { KibanaPluginServiceFactory } from '@kbn/presentation-util-plugin/public'; +import { ControlsEmbeddableService } from './types'; +import { ControlsPluginStartDeps } from '../../types'; + +export type EmbeddableServiceFactory = KibanaPluginServiceFactory< + ControlsEmbeddableService, + ControlsPluginStartDeps +>; + +export const embeddableServiceFactory: EmbeddableServiceFactory = ({ startPlugins }) => { + return { + getEmbeddableFactory: startPlugins.embeddable.getEmbeddableFactory, + }; +}; diff --git a/src/plugins/controls/public/services/embeddable/types.ts b/src/plugins/controls/public/services/embeddable/types.ts new file mode 100644 index 00000000000000..4ddbecd9dbb30c --- /dev/null +++ b/src/plugins/controls/public/services/embeddable/types.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EmbeddableStart } from '@kbn/embeddable-plugin/public'; + +export interface ControlsEmbeddableService { + getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory']; +} diff --git a/src/plugins/controls/public/services/plugin_services.story.ts b/src/plugins/controls/public/services/plugin_services.story.ts index b464286c7ca3a5..64affcbd9903b4 100644 --- a/src/plugins/controls/public/services/plugin_services.story.ts +++ b/src/plugins/controls/public/services/plugin_services.story.ts @@ -23,6 +23,7 @@ import { themeServiceFactory } from './theme/theme.story'; import { optionsListServiceFactory } from './options_list/options_list.story'; import { controlsServiceFactory } from './controls/controls.story'; +import { embeddableServiceFactory } from './embeddable/embeddable.story'; export const providers: PluginServiceProviders = { dataViews: new PluginServiceProvider(dataViewsServiceFactory), @@ -32,6 +33,7 @@ export const providers: PluginServiceProviders = { overlays: new PluginServiceProvider(overlaysServiceFactory), settings: new PluginServiceProvider(settingsServiceFactory), theme: new PluginServiceProvider(themeServiceFactory), + embeddable: new PluginServiceProvider(embeddableServiceFactory), controls: new PluginServiceProvider(controlsServiceFactory), optionsList: new PluginServiceProvider(optionsListServiceFactory), diff --git a/src/plugins/controls/public/services/plugin_services.stub.ts b/src/plugins/controls/public/services/plugin_services.stub.ts index 08be260ce052c9..a55d68fa4550fd 100644 --- a/src/plugins/controls/public/services/plugin_services.stub.ts +++ b/src/plugins/controls/public/services/plugin_services.stub.ts @@ -25,8 +25,10 @@ import { settingsServiceFactory } from './settings/settings.story'; import { unifiedSearchServiceFactory } from './unified_search/unified_search.story'; import { themeServiceFactory } from './theme/theme.story'; import { registry as stubRegistry } from './plugin_services.story'; +import { embeddableServiceFactory } from './embeddable/embeddable.story'; export const providers: PluginServiceProviders = { + embeddable: new PluginServiceProvider(embeddableServiceFactory), controls: new PluginServiceProvider(controlsServiceFactory), data: new PluginServiceProvider(dataServiceFactory), dataViews: new PluginServiceProvider(dataViewsServiceFactory), diff --git a/src/plugins/controls/public/services/plugin_services.ts b/src/plugins/controls/public/services/plugin_services.ts index f1811063e39a52..20950d42df5168 100644 --- a/src/plugins/controls/public/services/plugin_services.ts +++ b/src/plugins/controls/public/services/plugin_services.ts @@ -25,6 +25,7 @@ import { optionsListServiceFactory } from './options_list/options_list_service'; import { settingsServiceFactory } from './settings/settings_service'; import { unifiedSearchServiceFactory } from './unified_search/unified_search_service'; import { themeServiceFactory } from './theme/theme_service'; +import { embeddableServiceFactory } from './embeddable/embeddable_service'; export const providers: PluginServiceProviders< ControlsServices, @@ -38,6 +39,7 @@ export const providers: PluginServiceProviders< overlays: new PluginServiceProvider(overlaysServiceFactory), settings: new PluginServiceProvider(settingsServiceFactory), theme: new PluginServiceProvider(themeServiceFactory), + embeddable: new PluginServiceProvider(embeddableServiceFactory), unifiedSearch: new PluginServiceProvider(unifiedSearchServiceFactory), }; diff --git a/src/plugins/controls/public/services/types.ts b/src/plugins/controls/public/services/types.ts index 48b5aef7e29ba1..f0785f9991bedb 100644 --- a/src/plugins/controls/public/services/types.ts +++ b/src/plugins/controls/public/services/types.ts @@ -15,11 +15,13 @@ import { ControlsHTTPService } from './http/types'; import { ControlsOptionsListService } from './options_list/types'; import { ControlsSettingsService } from './settings/types'; import { ControlsThemeService } from './theme/types'; +import { ControlsEmbeddableService } from './embeddable/types'; export interface ControlsServices { // dependency services dataViews: ControlsDataViewsService; overlays: ControlsOverlaysService; + embeddable: ControlsEmbeddableService; data: ControlsDataService; unifiedSearch: ControlsUnifiedSearchService; http: ControlsHTTPService; diff --git a/src/plugins/guided_onboarding/public/constants/guides_config/security.ts b/src/plugins/guided_onboarding/public/constants/guides_config/security.ts index 68ebc849f94c06..d2f9b352b9d81c 100644 --- a/src/plugins/guided_onboarding/public/constants/guides_config/security.ts +++ b/src/plugins/guided_onboarding/public/constants/guides_config/security.ts @@ -45,6 +45,10 @@ export const securityConfig: GuideConfig = { description: 'Mark the step complete by opening the panel and clicking the button "Mark done"', }, + location: { + appID: 'securitySolutionUI', + path: '/rules', + }, }, { id: 'alertsCases', @@ -54,6 +58,10 @@ export const securityConfig: GuideConfig = { 'Nullam ligula enim, malesuada a finibus vel, cursus sed risus.', 'Vivamus pretium, elit dictum lacinia aliquet, libero nibh dictum enim, a rhoncus leo magna in sapien.', ], + location: { + appID: 'securitySolutionUI', + path: '/alerts', + }, }, ], }; diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/convert/column.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/convert/column.ts index bd0a9d572f1924..c06cc3e722279d 100644 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/convert/column.ts +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/convert/column.ts @@ -27,6 +27,7 @@ interface ExtraColumnFields { isSplit?: boolean; reducedTimeRange?: string; timeShift?: string; + isAssignTimeScale?: boolean; } const isSupportedFormat = (format: string) => ['bytes', 'number', 'percent'].includes(format); @@ -56,7 +57,13 @@ export const createColumn = ( series: Series, metric: Metric, field?: DataViewField, - { isBucketed = false, isSplit = false, reducedTimeRange, timeShift }: ExtraColumnFields = {} + { + isBucketed = false, + isSplit = false, + reducedTimeRange, + timeShift, + isAssignTimeScale = true, + }: ExtraColumnFields = {} ): GeneralColumnWithMeta => ({ columnId: uuid(), dataType: (field?.type as DataType) ?? undefined, @@ -66,7 +73,7 @@ export const createColumn = ( reducedTimeRange, filter: series.filter, timeShift, - timeScale: getTimeScale(metric), + timeScale: isAssignTimeScale ? getTimeScale(metric) : undefined, meta: { metricId: metric.id }, }); diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/convert/formula.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/convert/formula.ts index 15b35fade92c2d..91d887f6a66802 100644 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/convert/formula.ts +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/convert/formula.ts @@ -38,7 +38,7 @@ export const createFormulaColumn = ( return { operationType: 'formula', references: [], - ...createColumn(series, metric), + ...createColumn(series, metric, undefined, { isAssignTimeScale: false }), params: { ...params, ...getFormat(series) }, }; }; diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/metrics/metrics_helpers.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/metrics/metrics_helpers.ts index 8d970f2f7262dd..4b679d0dd0d15b 100644 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/metrics/metrics_helpers.ts +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/metrics/metrics_helpers.ts @@ -133,10 +133,13 @@ export const getFormulaEquivalent = ( }${addAdditionalArgs({ reducedTimeRange, timeShift })})`; } case 'positive_rate': { - return buildCounterRateFormula(aggFormula, currentMetric.field!, { + const counterRateFormula = buildCounterRateFormula(aggFormula, currentMetric.field!, { reducedTimeRange, timeShift, }); + return currentMetric.unit + ? `normalize_by_unit(${counterRateFormula}, unit='${getTimeScale(currentMetric)}')` + : counterRateFormula; } case 'filter_ratio': { return getFilterRatioFormula(currentMetric, { reducedTimeRange, timeShift }); diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/metrics/pipeline_formula.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/metrics/pipeline_formula.ts index d357d886ff5bdb..bc4c8eea11baad 100644 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/metrics/pipeline_formula.ts +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/metrics/pipeline_formula.ts @@ -9,7 +9,7 @@ import { TSVB_METRIC_TYPES } from '../../../../common/enums'; import type { Metric } from '../../../../common/types'; import { getFormulaFromMetric, SUPPORTED_METRICS } from './supported_metrics'; -import { getFormulaEquivalent } from './metrics_helpers'; +import { getFormulaEquivalent, getTimeScale } from './metrics_helpers'; const getAdditionalArgs = (metric: Metric) => { if (metric.type === TSVB_METRIC_TYPES.POSITIVE_ONLY) { @@ -54,5 +54,6 @@ export const getPipelineSeriesFormula = ( } const additionalArgs = getAdditionalArgs(metric); - return `${aggFormula}(${subFormula}${additionalArgs})`; + const formula = `${aggFormula}(${subFormula}${additionalArgs})`; + return metric.unit ? `normalize_by_unit(${formula}, unit='${getTimeScale(metric)}')` : formula; }; diff --git a/tsconfig.base.json b/tsconfig.base.json index 72b98c7af3c8c5..6d42c567d34226 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -475,8 +475,6 @@ "@kbn/global-search-test-plugin/*": ["x-pack/test/plugin_functional/plugins/global_search_test/*"], "@kbn/resolver-test-plugin": ["x-pack/test/plugin_functional/plugins/resolver_test"], "@kbn/resolver-test-plugin/*": ["x-pack/test/plugin_functional/plugins/resolver_test/*"], - "@kbn/timelines-test-plugin": ["x-pack/test/plugin_functional/plugins/timelines_test"], - "@kbn/timelines-test-plugin/*": ["x-pack/test/plugin_functional/plugins/timelines_test/*"], "@kbn/security-test-endpoints-plugin": ["x-pack/test/security_functional/fixtures/common/test_endpoints"], "@kbn/security-test-endpoints-plugin/*": ["x-pack/test/security_functional/fixtures/common/test_endpoints/*"], "@kbn/application-usage-test-plugin": ["x-pack/test/usage_collection/plugins/application_usage_test"], diff --git a/x-pack/build_chromium/.chromium-commit b/x-pack/build_chromium/.chromium-commit deleted file mode 100644 index c68babc821f443..00000000000000 --- a/x-pack/build_chromium/.chromium-commit +++ /dev/null @@ -1 +0,0 @@ -ef768c94bcb42dca4c27048615d07efadbb1c1c2 diff --git a/x-pack/build_chromium/README.md b/x-pack/build_chromium/README.md index 94ebdf47d286fe..df097e1cbef8b0 100644 --- a/x-pack/build_chromium/README.md +++ b/x-pack/build_chromium/README.md @@ -24,6 +24,11 @@ location](https://commondatastorage.googleapis.com/chromium-browser-snapshots/in ## Build Script Usage +The system OS requires a few setup steps: +1. Required packages: `bzip2`, `git`, `lsb_release`, `python3` +2. The `python` command needs to launch Python 3. +3. Recommended: `tmux`, as your ssh session may get interrupted + These commands show how to set up an environment to build: ```sh # Allow our scripts to use depot_tools commands @@ -36,7 +41,7 @@ mkdir ~/chromium && cd ~/chromium gsutil cp -r gs://headless_shell_staging/build_chromium . # Install the OS packages, configure the environment, download the chromium source (25GB) -python ./build_chromium/init.py [arch_name] +python ./build_chromium/init.py # Run the build script with the path to the chromium src directory, the git commit hash python ./build_chromium/build.py 70f5d88ea95298a18a85c33c98ea00e02358ad75 x64 @@ -67,7 +72,7 @@ A good how-to on building Chromium from source is We have an `linux/args.gn` file that is automatically copied to the build target directory. To get a list of the build arguments that are enabled, install `depot_tools` and run -`gn args out/headless --list`. It prints out all of the flags and their +`gn args out/headless --list` from the `chromium/src` directory. It prints out all of the flags and their settings, including the defaults. Some build flags are documented [here](https://www.chromium.org/developers/gn-build-configuration). @@ -86,10 +91,6 @@ are created in x64 using cross-compiling. CentOS is not supported for building C - 8 CPU - 30GB memory - 80GB free space on disk (Try `ncdu /home` to see where space is used.) - - git - - python2 (`python` must link to `python2`) - - lsb_release - - tmux is recommended in case your ssh session is interrupted - "Cloud API access scopes": must have **read / write** scope for the Storage API 4. Install [Google Cloud SDK](https://cloud.google.com/sdk) locally to ssh into the GCP instance diff --git a/x-pack/build_chromium/build.py b/x-pack/build_chromium/build.py index fd5c1d3f16a656..ec8c1a57217e46 100644 --- a/x-pack/build_chromium/build.py +++ b/x-pack/build_chromium/build.py @@ -66,12 +66,8 @@ os.environ['PATH'] = full_path # configure environment: build dependencies -if platform.system() == 'Linux': - if arch_name: - print('Running sysroot install script...') - runcmd(src_path + '/build/linux/sysroot_scripts/install-sysroot.py --arch=' + arch_name) - print('Running install-build-deps...') - runcmd(src_path + '/build/install-build-deps.sh') +print('Running sysroot install script...') +runcmd(src_path + '/build/linux/sysroot_scripts/install-sysroot.py --arch=' + arch_name) print('Updating all modules') runcmd('gclient sync -D') @@ -95,14 +91,14 @@ # Optimize the output on Linux x64 by stripping inessentials from the binary # ARM must be cross-compiled from Linux and can not read the ARM binary in order to strip -if platform.system() != 'Windows' and arch_name != 'arm64': +if arch_name != 'arm64': print('Optimizing headless_shell') shutil.move('out/headless/headless_shell', 'out/headless/headless_shell_raw') runcmd('strip -o out/headless/headless_shell out/headless/headless_shell_raw') # Create the zip and generate the md5 hash using filenames like: # chromium-4747cc2-linux_x64.zip -base_filename = 'out/headless/chromium-' + base_version + '-' + platform.system().lower() + '_' + arch_name +base_filename = 'out/headless/chromium-' + base_version + '-locales-' + platform.system().lower() + '_' + arch_name zip_filename = base_filename + '.zip' md5_filename = base_filename + '.md5' @@ -115,6 +111,9 @@ archive.write('out/headless/headless_shell', path.join(path_prefix, 'headless_shell')) archive.write('out/headless/libEGL.so', path.join(path_prefix, 'libEGL.so')) archive.write('out/headless/libGLESv2.so', path.join(path_prefix, 'libGLESv2.so')) +archive.write('out/headless/libvk_swiftshader.so', path.join(path_prefix, 'libvk_swiftshader.so')) +archive.write('out/headless/libvulkan.so.1', path.join(path_prefix, 'libvulkan.so.1')) +archive.write('out/headless/vk_swiftshader_icd.json', path.join(path_prefix, 'vk_swiftshader_icd.json')) archive.write(en_us_locale_file_path, path.join(path_prefix, 'locales', en_us_locale_pak_file_name)) archive.close() diff --git a/x-pack/build_chromium/init.py b/x-pack/build_chromium/init.py index e5a8ea3a2b9d94..84d2a06b417343 100644 --- a/x-pack/build_chromium/init.py +++ b/x-pack/build_chromium/init.py @@ -6,13 +6,9 @@ # once per environment. # Set to "arm" to build for ARM -arch_name = sys.argv[1] if len(sys.argv) >= 2 else 'undefined' build_path = path.abspath(os.curdir) src_path = path.abspath(path.join(build_path, 'chromium', 'src')) -if arch_name != 'x64' and arch_name != 'arm64': - raise Exception('Unexpected architecture: ' + arch_name + '. `x64` and `arm64` are supported.') - # Configure git print('Configuring git globals...') runcmd('git config --global core.autocrlf false') @@ -29,8 +25,8 @@ print('Updating depot_tools...') original_dir = os.curdir os.chdir(path.join(build_path, 'depot_tools')) - runcmd('git checkout master') - runcmd('git pull origin master') + runcmd('git checkout main') + runcmd('git pull origin main') os.chdir(original_dir) # Fetch the Chromium source code diff --git a/x-pack/build_chromium/linux/args.gn b/x-pack/build_chromium/linux/args.gn index fa6d4e8bcd15b8..29ec3207c85439 100644 --- a/x-pack/build_chromium/linux/args.gn +++ b/x-pack/build_chromium/linux/args.gn @@ -1,9 +1,27 @@ +# Build args reference: https://www.chromium.org/developers/gn-build-configuration/ import("//build/args/headless.gn") -is_debug = false -symbol_level = 0 + +is_official_build = true is_component_build = false +is_debug = false + enable_nacl = false +enable_stripping = true + +chrome_pgo_phase = 0 +dcheck_always_on = false +blink_symbol_level = 0 +symbol_level = 0 +v8_symbol_level = 0 + +enable_ink = false +rtc_build_examples = false +angle_build_tests = false +enable_screen_ai_service = false +enable_vr = false + # Please, consult @elastic/kibana-security before changing/removing this option. use_kerberos = false -# target_cpu is appended before build: "x64" or "arm64" +target_os = "linux" +# target_cpu is added at build timeure a minimal build diff --git a/x-pack/plugins/apm/server/routes/suggestions/get_suggestions_with_terms_aggregation.ts b/x-pack/plugins/apm/server/routes/suggestions/get_suggestions_with_terms_aggregation.ts index 77a7528fbb1a32..56ed34805c2fb1 100644 --- a/x-pack/plugins/apm/server/routes/suggestions/get_suggestions_with_terms_aggregation.ts +++ b/x-pack/plugins/apm/server/routes/suggestions/get_suggestions_with_terms_aggregation.ts @@ -23,7 +23,7 @@ export async function getSuggestionsWithTermsAggregation({ fieldName: string; fieldValue: string; searchAggregatedTransactions: boolean; - serviceName: string; + serviceName?: string; setup: Setup; size: number; start: number; diff --git a/x-pack/plugins/apm/server/routes/suggestions/get_suggestions.ts b/x-pack/plugins/apm/server/routes/suggestions/get_suggestions_with_terms_enum.ts similarity index 56% rename from x-pack/plugins/apm/server/routes/suggestions/get_suggestions.ts rename to x-pack/plugins/apm/server/routes/suggestions/get_suggestions_with_terms_enum.ts index dcab43ca26abcd..4437a361518952 100644 --- a/x-pack/plugins/apm/server/routes/suggestions/get_suggestions.ts +++ b/x-pack/plugins/apm/server/routes/suggestions/get_suggestions_with_terms_enum.ts @@ -8,7 +8,7 @@ import { ProcessorEvent } from '@kbn/observability-plugin/common'; import { getProcessorEventForTransactions } from '../../lib/helpers/transactions'; import { Setup } from '../../lib/helpers/setup_request'; -export async function getSuggestions({ +export async function getSuggestionsWithTermsEnum({ fieldName, fieldValue, searchAggregatedTransactions, @@ -27,30 +27,33 @@ export async function getSuggestions({ }) { const { apmEventClient } = setup; - const response = await apmEventClient.termsEnum('get_suggestions', { - apm: { - events: [ - getProcessorEventForTransactions(searchAggregatedTransactions), - ProcessorEvent.error, - ProcessorEvent.metric, - ], - }, - body: { - case_insensitive: true, - field: fieldName, - size, - string: fieldValue, - index_filter: { - range: { - ['@timestamp']: { - gte: start, - lte: end, - format: 'epoch_millis', + const response = await apmEventClient.termsEnum( + 'get_suggestions_with_terms_enum', + { + apm: { + events: [ + getProcessorEventForTransactions(searchAggregatedTransactions), + ProcessorEvent.error, + ProcessorEvent.metric, + ], + }, + body: { + case_insensitive: true, + field: fieldName, + size, + string: fieldValue, + index_filter: { + range: { + ['@timestamp']: { + gte: start, + lte: end, + format: 'epoch_millis', + }, }, }, }, - }, - }); + } + ); return { terms: response.terms }; } diff --git a/x-pack/plugins/apm/server/routes/suggestions/route.ts b/x-pack/plugins/apm/server/routes/suggestions/route.ts index 92a64da42eca3e..f0396ac62ca51b 100644 --- a/x-pack/plugins/apm/server/routes/suggestions/route.ts +++ b/x-pack/plugins/apm/server/routes/suggestions/route.ts @@ -7,7 +7,7 @@ import * as t from 'io-ts'; import { maxSuggestions } from '@kbn/observability-plugin/common'; -import { getSuggestions } from './get_suggestions'; +import { getSuggestionsWithTermsEnum } from './get_suggestions_with_terms_enum'; import { getSuggestionsWithTermsAggregation } from './get_suggestions_with_terms_aggregation'; import { getSearchTransactionsEvents } from '../../lib/helpers/transactions'; import { setupRequest } from '../../lib/helpers/setup_request'; @@ -41,28 +41,35 @@ const suggestionsRoute = createApmServerRoute({ maxSuggestions ); - const suggestions = serviceName - ? await getSuggestionsWithTermsAggregation({ - fieldName, - fieldValue, - searchAggregatedTransactions, - serviceName, - setup, - size, - start, - end, - }) - : await getSuggestions({ - fieldName, - fieldValue, - searchAggregatedTransactions, - setup, - size, - start, - end, - }); + if (!serviceName) { + const suggestions = await getSuggestionsWithTermsEnum({ + fieldName, + fieldValue, + searchAggregatedTransactions, + setup, + size, + start, + end, + }); - return suggestions; + // if no terms are found using terms enum it will fall back to using ordinary terms agg search + // This is useful because terms enum can only find terms that start with the search query + // whereas terms agg approach can find terms that contain the search query + if (suggestions.terms.length > 0) { + return suggestions; + } + } + + return getSuggestionsWithTermsAggregation({ + fieldName, + fieldValue, + searchAggregatedTransactions, + serviceName, + setup, + size, + start, + end, + }); }, }); diff --git a/x-pack/plugins/cases/public/components/case_view/components/suggest_users_popover.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/suggest_users_popover.test.tsx index 479b8e39d232da..bf22c764290aa8 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/suggest_users_popover.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/suggest_users_popover.test.tsx @@ -36,7 +36,7 @@ describe('SuggestUsersPopover', () => { }; }); - it('calls onUsersChange when 1 user is selected', async () => { + it.skip('calls onUsersChange when 1 user is selected', async () => { const onUsersChange = jest.fn(); const props = { ...defaultProps, onUsersChange }; appMockRender.render(); @@ -182,7 +182,7 @@ describe('SuggestUsersPopover', () => { expect(screen.getByText('1 assigned')).toBeInTheDocument(); }); - it('shows the 1 assigned total after clicking on a user', async () => { + it.skip('shows the 1 assigned total after clicking on a user', async () => { appMockRender.render(); await waitForEuiPopoverOpen(); diff --git a/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.test.ts b/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.test.ts index 29becfa3e99aed..538d8016a0a735 100644 --- a/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.test.ts +++ b/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.test.ts @@ -5,23 +5,34 @@ * 2.0. */ -import { MlTrainedModelConfig } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { IngestSetProcessor, MlTrainedModelConfig } from '@elastic/elasticsearch/lib/api/types'; import { BUILT_IN_MODEL_TAG } from '@kbn/ml-plugin/common/constants/data_frame_analytics'; +import { SUPPORTED_PYTORCH_TASKS } from '@kbn/ml-plugin/common/constants/trained_models'; -import { getMlModelTypesForModelConfig, BUILT_IN_MODEL_TAG as LOCAL_BUILT_IN_MODEL_TAG } from '.'; +import { MlInferencePipeline } from '../types/pipelines'; + +import { + BUILT_IN_MODEL_TAG as LOCAL_BUILT_IN_MODEL_TAG, + generateMlInferencePipelineBody, + getMlModelTypesForModelConfig, + getSetProcessorForInferenceType, + SUPPORTED_PYTORCH_TASKS as LOCAL_SUPPORTED_PYTORCH_TASKS, +} from '.'; + +const mockModel: MlTrainedModelConfig = { + inference_config: { + ner: {}, + }, + input: { + field_names: [], + }, + model_id: 'test_id', + model_type: 'pytorch', + tags: ['test_tag'], + version: '1', +}; describe('getMlModelTypesForModelConfig lib function', () => { - const mockModel: MlTrainedModelConfig = { - inference_config: { - ner: {}, - }, - input: { - field_names: [], - }, - model_id: 'test_id', - model_type: 'pytorch', - tags: ['test_tag'], - }; const builtInMockModel: MlTrainedModelConfig = { inference_config: { text_classification: {}, @@ -50,3 +61,140 @@ describe('getMlModelTypesForModelConfig lib function', () => { expect(LOCAL_BUILT_IN_MODEL_TAG).toEqual(BUILT_IN_MODEL_TAG); }); }); + +describe('getSetProcessorForInferenceType lib function', () => { + const destinationField = 'dest'; + + it('local LOCAL_SUPPORTED_PYTORCH_TASKS matches ml plugin', () => { + expect(SUPPORTED_PYTORCH_TASKS).toEqual(LOCAL_SUPPORTED_PYTORCH_TASKS); + }); + + it('should return expected value for TEXT_CLASSIFICATION', () => { + const inferenceType = SUPPORTED_PYTORCH_TASKS.TEXT_CLASSIFICATION; + + const expected: IngestSetProcessor = { + copy_from: 'ml.inference.dest.predicted_value', + description: + "Copy the predicted_value to 'dest' if the prediction_probability is greater than 0.5", + field: destinationField, + if: 'ml.inference.dest.prediction_probability > 0.5', + value: undefined, + }; + + expect(getSetProcessorForInferenceType(destinationField, inferenceType)).toEqual(expected); + }); + + it('should return expected value for TEXT_EMBEDDING', () => { + const inferenceType = SUPPORTED_PYTORCH_TASKS.TEXT_EMBEDDING; + + const expected: IngestSetProcessor = { + copy_from: 'ml.inference.dest.predicted_value', + description: "Copy the predicted_value to 'dest'", + field: destinationField, + value: undefined, + }; + + expect(getSetProcessorForInferenceType(destinationField, inferenceType)).toEqual(expected); + }); + + it('should return undefined for unknown inferenceType', () => { + const inferenceType = 'wrongInferenceType'; + + expect(getSetProcessorForInferenceType(destinationField, inferenceType)).toBeUndefined(); + }); +}); + +describe('generateMlInferencePipelineBody lib function', () => { + const expected: MlInferencePipeline = { + description: 'my-description', + processors: [ + { + remove: { + field: 'ml.inference.my-destination-field', + ignore_missing: true, + }, + }, + { + inference: { + field_map: { + 'my-source-field': 'MODEL_INPUT_FIELD', + }, + model_id: 'test_id', + on_failure: [ + { + append: { + field: '_source._ingest.inference_errors', + value: [ + { + message: + "Processor 'inference' in pipeline 'my-pipeline' failed with message '{{ _ingest.on_failure_message }}'", + pipeline: 'my-pipeline', + timestamp: '{{{ _ingest.timestamp }}}', + }, + ], + }, + }, + ], + target_field: 'ml.inference.my-destination-field', + }, + }, + { + append: { + field: '_source._ingest.processors', + value: [ + { + model_version: '1', + pipeline: 'my-pipeline', + processed_timestamp: '{{{ _ingest.timestamp }}}', + types: ['pytorch', 'ner'], + }, + ], + }, + }, + ], + version: 1, + }; + + it('should return something expected', () => { + const actual: MlInferencePipeline = generateMlInferencePipelineBody({ + description: 'my-description', + destinationField: 'my-destination-field', + model: mockModel, + pipelineName: 'my-pipeline', + sourceField: 'my-source-field', + }); + + expect(actual).toEqual(expected); + }); + + it('should return something expected 2', () => { + const mockTextClassificationModel: MlTrainedModelConfig = { + ...mockModel, + ...{ inference_config: { text_classification: {} } }, + }; + const actual: MlInferencePipeline = generateMlInferencePipelineBody({ + description: 'my-description', + destinationField: 'my-destination-field', + model: mockTextClassificationModel, + pipelineName: 'my-pipeline', + sourceField: 'my-source-field', + }); + + expect(actual).toEqual( + expect.objectContaining({ + description: expect.any(String), + processors: expect.arrayContaining([ + expect.objectContaining({ + set: { + copy_from: 'ml.inference.my-destination-field.predicted_value', + description: + "Copy the predicted_value to 'my-destination-field' if the prediction_probability is greater than 0.5", + field: 'my-destination-field', + if: 'ml.inference.my-destination-field.prediction_probability > 0.5', + }, + }), + ]), + }) + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.ts b/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.ts index 00d893ba9abaa0..b5b4526d1723b7 100644 --- a/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.ts +++ b/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { MlTrainedModelConfig } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { IngestSetProcessor, MlTrainedModelConfig } from '@elastic/elasticsearch/lib/api/types'; import { MlInferencePipeline } from '../types/pipelines'; @@ -13,6 +13,17 @@ import { MlInferencePipeline } from '../types/pipelines'; // So defining it locally for now with a test to make sure it matches. export const BUILT_IN_MODEL_TAG = 'prepackaged'; +// Getting an error importing this from @kbn/ml-plugin/common/constants/trained_models' +// So defining it locally for now with a test to make sure it matches. +export const SUPPORTED_PYTORCH_TASKS = { + FILL_MASK: 'fill_mask', + NER: 'ner', + QUESTION_ANSWERING: 'question_answering', + TEXT_CLASSIFICATION: 'text_classification', + TEXT_EMBEDDING: 'text_embedding', + ZERO_SHOT_CLASSIFICATION: 'zero_shot_classification', +} as const; + export interface MlInferencePipelineParams { description?: string; destinationField: string; @@ -36,6 +47,10 @@ export const generateMlInferencePipelineBody = ({ // if model returned no input field, insert a placeholder const modelInputField = model.input?.field_names?.length > 0 ? model.input.field_names[0] : 'MODEL_INPUT_FIELD'; + + const inferenceType = Object.keys(model.inference_config)[0]; + const set = getSetProcessorForInferenceType(destinationField, inferenceType); + return { description: description ?? '', processors: [ @@ -51,21 +66,21 @@ export const generateMlInferencePipelineBody = ({ [sourceField]: modelInputField, }, model_id: model.model_id, - target_field: `ml.inference.${destinationField}`, on_failure: [ { append: { field: '_source._ingest.inference_errors', value: [ { - pipeline: pipelineName, message: `Processor 'inference' in pipeline '${pipelineName}' failed with message '{{ _ingest.on_failure_message }}'`, + pipeline: pipelineName, timestamp: '{{{ _ingest.timestamp }}}', }, ], }, }, ], + target_field: `ml.inference.${destinationField}`, }, }, { @@ -81,11 +96,39 @@ export const generateMlInferencePipelineBody = ({ ], }, }, + ...(set ? [{ set }] : []), ], version: 1, }; }; +export const getSetProcessorForInferenceType = ( + destinationField: string, + inferenceType: string +): IngestSetProcessor | undefined => { + let set: IngestSetProcessor | undefined; + const prefixedDestinationField = `ml.inference.${destinationField}`; + + if (inferenceType === SUPPORTED_PYTORCH_TASKS.TEXT_CLASSIFICATION) { + set = { + copy_from: `${prefixedDestinationField}.predicted_value`, + description: `Copy the predicted_value to '${destinationField}' if the prediction_probability is greater than 0.5`, + field: destinationField, + if: `${prefixedDestinationField}.prediction_probability > 0.5`, + value: undefined, + }; + } else if (inferenceType === SUPPORTED_PYTORCH_TASKS.TEXT_EMBEDDING) { + set = { + copy_from: `${prefixedDestinationField}.predicted_value`, + description: `Copy the predicted_value to '${destinationField}'`, + field: destinationField, + value: undefined, + }; + } + + return set; +}; + /** * Parses model types list from the given configuration of a trained machine learning model * @param trainedModel configuration for a trained machine learning model diff --git a/x-pack/plugins/enterprise_search/common/types/@elastic/elasticsearch/index.d.ts b/x-pack/plugins/enterprise_search/common/types/@elastic/elasticsearch/index.d.ts new file mode 100644 index 00000000000000..05227a54bfb51c --- /dev/null +++ b/x-pack/plugins/enterprise_search/common/types/@elastic/elasticsearch/index.d.ts @@ -0,0 +1,20 @@ +/* + * 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 '@elastic/elasticsearch/lib/api/types'; + +// TODO: Remove once type fixed in elasticsearch-specification +// (add github issue) +declare module '@elastic/elasticsearch/lib/api/types' { + // This workaround adds copy_from and description to the original IngestSetProcess and makes value + // optional. It should be value xor copy_from, but that requires using type unions. This + // workaround requires interface merging (ie, not types), so we cannot get. + export interface IngestSetProcessor { + copy_from?: string; + description?: string; + } +} diff --git a/x-pack/plugins/enterprise_search/common/types/pipelines.ts b/x-pack/plugins/enterprise_search/common/types/pipelines.ts index b103b4a5265b37..75bc44443e207e 100644 --- a/x-pack/plugins/enterprise_search/common/types/pipelines.ts +++ b/x-pack/plugins/enterprise_search/common/types/pipelines.ts @@ -41,3 +41,13 @@ export interface MlInferenceError { doc_count: number; timestamp: string | undefined; // Date string } + +/** + * Response for deleting sub-pipeline from @ml-inference pipeline. + * If sub-pipeline was deleted successfully, 'deleted' field contains its name. + * If parent pipeline was updated successfully, 'updated' field contains its name. + */ +export interface DeleteMlInferencePipelineResponse { + deleted?: string; + updated?: string; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/inference_pipeline_card.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/inference_pipeline_card.tsx index ab81e206daf578..a888364ac8bb3d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/inference_pipeline_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/inference_pipeline_card.tsx @@ -160,15 +160,15 @@ export const InferencePipelineCard: React.FC = (pipeline) => )} - {(modelType.length > 0 ? [modelType] : modelTypes).map((type) => ( - - - - {type} - - - - ))} + + + + + {modelType} + + + + diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ingest_pipelines/custom_pipeline_item.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ingest_pipelines/custom_pipeline_item.tsx index 54471840aff4d1..215983e5f40c4d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ingest_pipelines/custom_pipeline_item.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ingest_pipelines/custom_pipeline_item.tsx @@ -56,15 +56,17 @@ export const CustomPipelineItem: React.FC<{ - - {i18n.translate( - 'xpack.enterpriseSearch.content.indices.pipelines.ingestPipelinesCard.processorsDescription', - { - defaultMessage: '{processorsCount} Processors', - values: { processorsCount }, - } - )} - + + + {i18n.translate( + 'xpack.enterpriseSearch.content.indices.pipelines.ingestPipelinesCard.processorsDescription', + { + defaultMessage: '{processorsCount} Processors', + values: { processorsCount }, + } + )} + + diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ingest_pipelines/default_pipeline_item.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ingest_pipelines/default_pipeline_item.tsx index c22f7910bdc4f8..3e6ad933c3e825 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ingest_pipelines/default_pipeline_item.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ingest_pipelines/default_pipeline_item.tsx @@ -75,12 +75,14 @@ export const DefaultPipelineItem: React.FC<{ )} - - {i18n.translate( - 'xpack.enterpriseSearch.content.indices.pipelines.ingestPipelinesCard.managedBadge.label', - { defaultMessage: 'Managed' } - )} - + + + {i18n.translate( + 'xpack.enterpriseSearch.content.indices.pipelines.ingestPipelinesCard.managedBadge.label', + { defaultMessage: 'Managed' } + )} + + diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/configure_pipeline.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/configure_pipeline.tsx index fed9f8e6c53766..868801116a041f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/configure_pipeline.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/configure_pipeline.tsx @@ -17,6 +17,8 @@ import { EuiFormRow, EuiLink, EuiSelect, + EuiSuperSelect, + EuiSuperSelectOption, EuiSpacer, EuiText, } from '@elastic/eui'; @@ -29,6 +31,9 @@ import { docLinks } from '../../../../../shared/doc_links'; import { IndexViewLogic } from '../../index_view_logic'; import { MLInferenceLogic } from './ml_inference_logic'; +import { MlModelSelectOption } from './model_select_option'; + +const MODEL_SELECT_PLACEHOLDER_VALUE = 'model_placeholder$$'; const NoSourceFieldsError: React.FC = () => ( { const nameError = formErrors.pipelineName !== undefined && pipelineName.length > 0; const emptySourceFields = (sourceFields?.length ?? 0) === 0; + const modelOptions: Array> = [ + { + disabled: true, + inputDisplay: i18n.translate( + 'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.model.placeholder', + { defaultMessage: 'Select a model' } + ), + value: MODEL_SELECT_PLACEHOLDER_VALUE, + }, + ...models.map((model) => ({ + dropdownDisplay: , + inputDisplay: model.model_id, + value: model.model_id, + })), + ]; + return ( <> @@ -134,27 +155,19 @@ export const ConfigurePipeline: React.FC = () => { )} fullWidth > - ({ text: m.model_id, value: m.model_id })), - ]} - onChange={(e) => + hasDividers + itemLayoutAlign="top" + onChange={(value) => setInferencePipelineConfiguration({ ...configuration, - modelID: e.target.value, + modelID: value, }) } + options={modelOptions} + valueOfSelected={modelID === '' ? MODEL_SELECT_PLACEHOLDER_VALUE : modelID} /> diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.ts index 6488ca07231426..5b9bd336434696 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.ts @@ -131,14 +131,14 @@ interface MLInferenceProcessorsValues { mappingData: typeof MappingsApiLogic.values.data; mappingStatus: Status; mlInferencePipeline?: MlInferencePipeline; - mlModelsData: typeof MLModelsApiLogic.values.data; + mlModelsData: TrainedModelConfigResponse[]; mlModelsStatus: Status; simulatePipelineData: typeof SimulateMlInterfacePipelineApiLogic.values.data; simulatePipelineErrors: string[]; simulatePipelineResult: IngestSimulateResponse; simulatePipelineStatus: Status; sourceFields: string[] | undefined; - supportedMLModels: typeof MLModelsApiLogic.values.data; + supportedMLModels: TrainedModelConfigResponse[]; } export const MLInferenceLogic = kea< diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/model_select_option.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/model_select_option.tsx new file mode 100644 index 00000000000000..4529e85d720f83 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/model_select_option.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiTextColor, EuiTitle } from '@elastic/eui'; +import { TrainedModelConfigResponse } from '@kbn/ml-plugin/common/types/trained_models'; + +import { getMlModelTypesForModelConfig } from '../../../../../../../common/ml_inference_pipeline'; +import { getMLType, getModelDisplayTitle } from '../../../shared/ml_inference/utils'; + +export interface MlModelSelectOptionProps { + model: TrainedModelConfigResponse; +} +export const MlModelSelectOption: React.FC = ({ model }) => { + const type = getMLType(getMlModelTypesForModelConfig(model)); + const title = getModelDisplayTitle(type); + return ( + + + +

{title ?? model.model_id}

+
+
+ + + {title && ( + + {model.model_id} + + )} + + + {type} + + + + +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/server/lib/indices/delete_ml_inference_pipeline.ts b/x-pack/plugins/enterprise_search/server/lib/indices/delete_ml_inference_pipeline.ts deleted file mode 100644 index 04a032e3be1023..00000000000000 --- a/x-pack/plugins/enterprise_search/server/lib/indices/delete_ml_inference_pipeline.ts +++ /dev/null @@ -1,74 +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 { IngestPutPipelineRequest } from '@elastic/elasticsearch/lib/api/types'; -import { ElasticsearchClient } from '@kbn/core/server'; - -import { getInferencePipelineNameFromIndexName } from '../../utils/ml_inference_pipeline_utils'; - -/** - * Response for deleting sub-pipeline from @ml-inference pipeline. - * If sub-pipeline was deleted successfully, 'deleted' field contains its name. - * If parent pipeline was updated successfully, 'updated' field contains its name. - */ -export interface DeleteMlInferencePipelineResponse { - deleted?: string; - updated?: string; -} - -export const deleteMlInferencePipeline = async ( - indexName: string, - pipelineName: string, - client: ElasticsearchClient -) => { - const response: DeleteMlInferencePipelineResponse = {}; - const parentPipelineId = getInferencePipelineNameFromIndexName(indexName); - - // find parent pipeline - try { - const pipelineResponse = await client.ingest.getPipeline({ - id: parentPipelineId, - }); - - const parentPipeline = pipelineResponse[parentPipelineId]; - - if (parentPipeline !== undefined) { - // remove sub-pipeline from parent pipeline - if (parentPipeline.processors !== undefined) { - const updatedProcessors = parentPipeline.processors.filter( - (p) => !(p.pipeline !== undefined && p.pipeline.name === pipelineName) - ); - // only update if we changed something - if (updatedProcessors.length !== parentPipeline.processors.length) { - const updatedPipeline: IngestPutPipelineRequest = { - ...parentPipeline, - id: parentPipelineId, - processors: updatedProcessors, - }; - - const updateResponse = await client.ingest.putPipeline(updatedPipeline); - if (updateResponse.acknowledged === true) { - response.updated = parentPipelineId; - } - } - } - } - } catch (error) { - // only suppress Not Found error - if (error.meta?.statusCode !== 404) { - throw error; - } - } - - // finally, delete pipeline - const deleteResponse = await client.ingest.deletePipeline({ id: pipelineName }); - if (deleteResponse.acknowledged === true) { - response.deleted = pipelineName; - } - - return response; -}; diff --git a/x-pack/plugins/enterprise_search/server/lib/indices/fetch_ml_inference_pipeline_processors.test.ts b/x-pack/plugins/enterprise_search/server/lib/indices/fetch_ml_inference_pipeline_processors.test.ts index bc77b2dff78275..8e1caa17e2b78e 100644 --- a/x-pack/plugins/enterprise_search/server/lib/indices/fetch_ml_inference_pipeline_processors.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/indices/fetch_ml_inference_pipeline_processors.test.ts @@ -577,7 +577,7 @@ describe('fetchMlInferencePipelineProcessors lib function', () => { }); describe('when Machine Learning is disabled in the current space', () => { - it('should throw an eror', () => { + it('should throw an error', () => { expect(() => fetchMlInferencePipelineProcessors( mockClient as unknown as ElasticsearchClient, diff --git a/x-pack/plugins/enterprise_search/server/lib/ml_inference_pipeline/get_inference_pipelines.test.ts b/x-pack/plugins/enterprise_search/server/lib/ml_inference_pipeline/get_inference_pipelines.test.ts new file mode 100644 index 00000000000000..45953166667a5c --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/ml_inference_pipeline/get_inference_pipelines.test.ts @@ -0,0 +1,131 @@ +/* + * 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 { IngestProcessorContainer } from '@elastic/elasticsearch/lib/api/types'; +import { ElasticsearchClient } from '@kbn/core/server'; +import { MlTrainedModels } from '@kbn/ml-plugin/server'; + +import { getMlInferencePipelines } from './get_inference_pipelines'; + +jest.mock('../indices/fetch_ml_inference_pipeline_processors', () => ({ + getMlModelConfigsForModelIds: jest.fn(), +})); + +describe('getMlInferencePipelines', () => { + const mockClient = { + ingest: { + getPipeline: jest.fn(), + }, + }; + const mockTrainedModelsProvider = { + getTrainedModels: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should throw an error if Machine Learning is disabled in the current space', () => { + expect(() => + getMlInferencePipelines(mockClient as unknown as ElasticsearchClient, undefined) + ).rejects.toThrowError('Machine Learning is not enabled'); + }); + + it('should fetch inference pipelines and redact inaccessible model IDs', async () => { + function mockInferencePipeline(modelId: string) { + return { + processors: [ + { + append: {}, + }, + { + inference: { + model_id: modelId, + }, + }, + { + remove: {}, + }, + ], + }; + } + + const mockPipelines = { + pipeline1: mockInferencePipeline('model1'), + pipeline2: mockInferencePipeline('model2'), + pipeline3: mockInferencePipeline('redactedModel3'), + pipeline4: { + // Pipeline with multiple inference processors referencing an inaccessible model + processors: [ + { + append: {}, + }, + { + inference: { + model_id: 'redactedModel3', + }, + }, + { + inference: { + model_id: 'model2', + }, + }, + { + inference: { + model_id: 'redactedModel4', + }, + }, + { + remove: {}, + }, + ], + }, + }; + + const mockTrainedModels = { + trained_model_configs: [ + { + model_id: 'model1', + }, + { + model_id: 'model2', + }, + ], + }; + + mockClient.ingest.getPipeline.mockImplementation(() => Promise.resolve(mockPipelines)); + mockTrainedModelsProvider.getTrainedModels.mockImplementation(() => + Promise.resolve(mockTrainedModels) + ); + + const actualPipelines = await getMlInferencePipelines( + mockClient as unknown as ElasticsearchClient, + mockTrainedModelsProvider as unknown as MlTrainedModels + ); + + expect( + (actualPipelines.pipeline1.processors as IngestProcessorContainer[])[1].inference?.model_id + ).toBeDefined(); + expect( + (actualPipelines.pipeline2.processors as IngestProcessorContainer[])[1].inference?.model_id + ).toBeDefined(); + expect( + (actualPipelines.pipeline3.processors as IngestProcessorContainer[])[1].inference?.model_id + ).toEqual(''); // Redacted model ID + expect( + (actualPipelines.pipeline4.processors as IngestProcessorContainer[])[1].inference?.model_id + ).toEqual(''); + expect( + (actualPipelines.pipeline4.processors as IngestProcessorContainer[])[2].inference?.model_id + ).toBeDefined(); + expect( + (actualPipelines.pipeline4.processors as IngestProcessorContainer[])[3].inference?.model_id + ).toEqual(''); + expect(mockClient.ingest.getPipeline).toHaveBeenCalledWith({ id: 'ml-inference-*' }); + expect(mockTrainedModelsProvider.getTrainedModels).toHaveBeenCalledWith({}); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/lib/ml_inference_pipeline/get_inference_pipelines.ts b/x-pack/plugins/enterprise_search/server/lib/ml_inference_pipeline/get_inference_pipelines.ts new file mode 100644 index 00000000000000..4bdf7e95d4a06b --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/ml_inference_pipeline/get_inference_pipelines.ts @@ -0,0 +1,75 @@ +/* + * 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 { IngestPipeline, IngestProcessorContainer } from '@elastic/elasticsearch/lib/api/types'; +import { ElasticsearchClient } from '@kbn/core/server'; +import { MlTrainedModels } from '@kbn/ml-plugin/server'; + +/** + * Gets all ML inference pipelines. Redacts trained model IDs in those pipelines which reference + * a model inaccessible in the current Kibana space. + * @param esClient the Elasticsearch Client to use to fetch the errors. + * @param trainedModelsProvider ML trained models provider. + */ +export const getMlInferencePipelines = async ( + esClient: ElasticsearchClient, + trainedModelsProvider: MlTrainedModels | undefined +): Promise> => { + if (!trainedModelsProvider) { + return Promise.reject(new Error('Machine Learning is not enabled')); + } + + // Fetch all ML inference pipelines and trained models that are accessible in the current + // Kibana space + const [fetchedInferencePipelines, trainedModels] = await Promise.all([ + esClient.ingest.getPipeline({ + id: 'ml-inference-*', + }), + trainedModelsProvider.getTrainedModels({}), + ]); + const accessibleModelIds = Object.values(trainedModels.trained_model_configs).map( + (modelConfig) => modelConfig.model_id + ); + + // Process pipelines: check if the model_id is one of the redacted ones, if so, redact it in the + // result as well + const inferencePipelinesResult: Record = {}; + Object.entries(fetchedInferencePipelines).forEach(([name, inferencePipeline]) => { + inferencePipelinesResult[name] = { + ...inferencePipeline, + processors: inferencePipeline.processors?.map((processor) => + redactModelIdIfInaccessible(processor, accessibleModelIds) + ), + }; + }); + + return Promise.resolve(inferencePipelinesResult); +}; + +/** + * Convenience function to redact the trained model ID in an ML inference processor if the model is + * not accessible in the current Kibana space. In this case `model_id` gets replaced with `''`. + * @param processor the processor to process. + * @param accessibleModelIds array of known accessible model IDs. + * @returns the input processor if unchanged, or a copy of the processor with the model ID redacted. + */ +function redactModelIdIfInaccessible( + processor: IngestProcessorContainer, + accessibleModelIds: string[] +): IngestProcessorContainer { + if (!processor.inference || accessibleModelIds.includes(processor.inference.model_id)) { + return processor; + } + + return { + ...processor, + inference: { + ...processor.inference, + model_id: '', + }, + }; +} diff --git a/x-pack/plugins/enterprise_search/server/lib/pipelines/get_custom_pipelines.ts b/x-pack/plugins/enterprise_search/server/lib/pipelines/get_custom_pipelines.ts index 11127e7e5d236c..d7f2dd2bbab264 100644 --- a/x-pack/plugins/enterprise_search/server/lib/pipelines/get_custom_pipelines.ts +++ b/x-pack/plugins/enterprise_search/server/lib/pipelines/get_custom_pipelines.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { IngestGetPipelineResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { IngestGetPipelineResponse } from '@elastic/elasticsearch/lib/api/types'; import { IScopedClusterClient } from '@kbn/core/server'; export const getCustomPipelines = async ( diff --git a/x-pack/plugins/enterprise_search/server/lib/pipelines/get_pipeline.ts b/x-pack/plugins/enterprise_search/server/lib/pipelines/get_pipeline.ts index a02b4cdd8b19b9..05b83d88a0b1d1 100644 --- a/x-pack/plugins/enterprise_search/server/lib/pipelines/get_pipeline.ts +++ b/x-pack/plugins/enterprise_search/server/lib/pipelines/get_pipeline.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { IngestGetPipelineResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { IngestGetPipelineResponse } from '@elastic/elasticsearch/lib/api/types'; import { IScopedClusterClient } from '@kbn/core/server'; export const getPipeline = async ( diff --git a/x-pack/plugins/enterprise_search/server/lib/indices/delete_ml_inference_pipeline.test.ts b/x-pack/plugins/enterprise_search/server/lib/pipelines/ml_inference/pipeline_processors/delete_ml_inference_pipeline.test.ts similarity index 100% rename from x-pack/plugins/enterprise_search/server/lib/indices/delete_ml_inference_pipeline.test.ts rename to x-pack/plugins/enterprise_search/server/lib/pipelines/ml_inference/pipeline_processors/delete_ml_inference_pipeline.test.ts diff --git a/x-pack/plugins/enterprise_search/server/lib/pipelines/ml_inference/pipeline_processors/delete_ml_inference_pipeline.ts b/x-pack/plugins/enterprise_search/server/lib/pipelines/ml_inference/pipeline_processors/delete_ml_inference_pipeline.ts new file mode 100644 index 00000000000000..19654d0b2e9363 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/pipelines/ml_inference/pipeline_processors/delete_ml_inference_pipeline.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ElasticsearchClient } from '@kbn/core/server'; + +import { DeleteMlInferencePipelineResponse } from '../../../../../common/types/pipelines'; + +import { detachMlInferencePipeline } from './detach_ml_inference_pipeline'; + +export const deleteMlInferencePipeline = async ( + indexName: string, + pipelineName: string, + client: ElasticsearchClient +) => { + let response: DeleteMlInferencePipelineResponse = {}; + + try { + response = await detachMlInferencePipeline(indexName, pipelineName, client); + } catch (error) { + // only suppress Not Found error + if (error.meta?.statusCode !== 404) { + throw error; + } + } + + // finally, delete pipeline + const deleteResponse = await client.ingest.deletePipeline({ id: pipelineName }); + if (deleteResponse.acknowledged === true) { + response.deleted = pipelineName; + } + + return response; +}; diff --git a/x-pack/plugins/enterprise_search/server/lib/pipelines/ml_inference/pipeline_processors/detach_ml_inference_pipeline.test.ts b/x-pack/plugins/enterprise_search/server/lib/pipelines/ml_inference/pipeline_processors/detach_ml_inference_pipeline.test.ts new file mode 100644 index 00000000000000..bcab516ab5b9c5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/pipelines/ml_inference/pipeline_processors/detach_ml_inference_pipeline.test.ts @@ -0,0 +1,139 @@ +/* + * 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 { errors } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '@kbn/core/server'; + +import { detachMlInferencePipeline } from './detach_ml_inference_pipeline'; + +describe('detachMlInferencePipeline', () => { + const mockClient = { + ingest: { + deletePipeline: jest.fn(), + getPipeline: jest.fn(), + putPipeline: jest.fn(), + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const anyObject: any = {}; + const notFoundResponse = { meta: { statusCode: 404 } }; + const notFoundError = new errors.ResponseError({ + body: notFoundResponse, + statusCode: 404, + headers: {}, + meta: anyObject, + warnings: [], + }); + const mockGetPipeline = { + 'my-index@ml-inference': { + id: 'my-index@ml-inference', + processors: [ + { + pipeline: { + name: 'my-ml-pipeline', + }, + }, + ], + }, + }; + + it('should update parent pipeline', async () => { + mockClient.ingest.getPipeline.mockImplementation(() => Promise.resolve(mockGetPipeline)); + mockClient.ingest.putPipeline.mockImplementation(() => Promise.resolve({ acknowledged: true })); + mockClient.ingest.deletePipeline.mockImplementation(() => + Promise.resolve({ acknowledged: true }) + ); + + const expectedResponse = { updated: 'my-index@ml-inference' }; + + const response = await detachMlInferencePipeline( + 'my-index', + 'my-ml-pipeline', + mockClient as unknown as ElasticsearchClient + ); + + expect(response).toEqual(expectedResponse); + + expect(mockClient.ingest.putPipeline).toHaveBeenCalledWith({ + id: 'my-index@ml-inference', + processors: [], + }); + expect(mockClient.ingest.deletePipeline).not.toHaveBeenCalledWith({ + id: 'my-ml-pipeline', + }); + }); + + it('should only remove provided pipeline from parent', async () => { + mockClient.ingest.getPipeline.mockImplementation(() => + Promise.resolve({ + 'my-index@ml-inference': { + id: 'my-index@ml-inference', + processors: [ + { + pipeline: { + name: 'my-ml-pipeline', + }, + }, + { + pipeline: { + name: 'my-ml-other-pipeline', + }, + }, + ], + }, + }) + ); + mockClient.ingest.putPipeline.mockImplementation(() => Promise.resolve({ acknowledged: true })); + mockClient.ingest.deletePipeline.mockImplementation(() => + Promise.resolve({ acknowledged: true }) + ); + + const expectedResponse = { updated: 'my-index@ml-inference' }; + + const response = await detachMlInferencePipeline( + 'my-index', + 'my-ml-pipeline', + mockClient as unknown as ElasticsearchClient + ); + + expect(response).toEqual(expectedResponse); + + expect(mockClient.ingest.putPipeline).toHaveBeenCalledWith({ + id: 'my-index@ml-inference', + processors: [ + { + pipeline: { + name: 'my-ml-other-pipeline', + }, + }, + ], + }); + expect(mockClient.ingest.deletePipeline).not.toHaveBeenCalledWith({ + id: 'my-ml-pipeline', + }); + }); + + it('should fail when parent pipeline is missing', async () => { + mockClient.ingest.getPipeline.mockImplementation(() => Promise.reject(notFoundError)); + + await expect( + detachMlInferencePipeline( + 'my-index', + 'my-ml-pipeline', + mockClient as unknown as ElasticsearchClient + ) + ).rejects.toThrow(Error); + + expect(mockClient.ingest.getPipeline).toHaveBeenCalledWith({ + id: 'my-index@ml-inference', + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/lib/pipelines/ml_inference/pipeline_processors/detach_ml_inference_pipeline.ts b/x-pack/plugins/enterprise_search/server/lib/pipelines/ml_inference/pipeline_processors/detach_ml_inference_pipeline.ts new file mode 100644 index 00000000000000..02d6c328a8e47a --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/pipelines/ml_inference/pipeline_processors/detach_ml_inference_pipeline.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IngestPutPipelineRequest } from '@elastic/elasticsearch/lib/api/types'; +import { ElasticsearchClient } from '@kbn/core/server'; + +import { DeleteMlInferencePipelineResponse } from '../../../../../common/types/pipelines'; + +import { getInferencePipelineNameFromIndexName } from '../../../../utils/ml_inference_pipeline_utils'; + +export const detachMlInferencePipeline = async ( + indexName: string, + pipelineName: string, + client: ElasticsearchClient +) => { + const response: DeleteMlInferencePipelineResponse = {}; + const parentPipelineId = getInferencePipelineNameFromIndexName(indexName); + + // find parent pipeline + const pipelineResponse = await client.ingest.getPipeline({ + id: parentPipelineId, + }); + + const parentPipeline = pipelineResponse[parentPipelineId]; + + if (parentPipeline !== undefined) { + // remove sub-pipeline from parent pipeline + if (parentPipeline.processors !== undefined) { + const updatedProcessors = parentPipeline.processors.filter( + (p) => !(p.pipeline !== undefined && p.pipeline.name === pipelineName) + ); + // only update if we changed something + if (updatedProcessors.length !== parentPipeline.processors.length) { + const updatedPipeline: IngestPutPipelineRequest = { + ...parentPipeline, + id: parentPipelineId, + processors: updatedProcessors, + }; + + const updateResponse = await client.ingest.putPipeline(updatedPipeline); + if (updateResponse.acknowledged === true) { + response.updated = parentPipelineId; + } + } + } + } + + return response; +}; diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.test.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.test.ts index d0a652b12d9c26..c2d23e5d2710a2 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.test.ts @@ -23,21 +23,35 @@ jest.mock('../../lib/indices/fetch_ml_inference_pipeline_processors', () => ({ jest.mock('../../utils/create_ml_inference_pipeline', () => ({ createAndReferenceMlInferencePipeline: jest.fn(), })); -jest.mock('../../lib/indices/delete_ml_inference_pipeline', () => ({ - deleteMlInferencePipeline: jest.fn(), -})); +jest.mock( + '../../lib/pipelines/ml_inference/pipeline_processors/delete_ml_inference_pipeline', + () => ({ + deleteMlInferencePipeline: jest.fn(), + }) +); +jest.mock( + '../../lib/pipelines/ml_inference/pipeline_processors/detach_ml_inference_pipeline', + () => ({ + detachMlInferencePipeline: jest.fn(), + }) +); jest.mock('../../lib/indices/exists_index', () => ({ indexOrAliasExists: jest.fn(), })); jest.mock('../../lib/ml_inference_pipeline/get_inference_errors', () => ({ getMlInferenceErrors: jest.fn(), })); +jest.mock('../../lib/ml_inference_pipeline/get_inference_pipelines', () => ({ + getMlInferencePipelines: jest.fn(), +})); -import { deleteMlInferencePipeline } from '../../lib/indices/delete_ml_inference_pipeline'; import { indexOrAliasExists } from '../../lib/indices/exists_index'; import { fetchMlInferencePipelineHistory } from '../../lib/indices/fetch_ml_inference_pipeline_history'; import { fetchMlInferencePipelineProcessors } from '../../lib/indices/fetch_ml_inference_pipeline_processors'; import { getMlInferenceErrors } from '../../lib/ml_inference_pipeline/get_inference_errors'; +import { getMlInferencePipelines } from '../../lib/ml_inference_pipeline/get_inference_pipelines'; +import { deleteMlInferencePipeline } from '../../lib/pipelines/ml_inference/pipeline_processors/delete_ml_inference_pipeline'; +import { detachMlInferencePipeline } from '../../lib/pipelines/ml_inference/pipeline_processors/detach_ml_inference_pipeline'; import { createAndReferenceMlInferencePipeline } from '../../utils/create_ml_inference_pipeline'; import { ElasticsearchResponseError } from '../../utils/identify_exceptions'; @@ -642,4 +656,140 @@ describe('Enterprise Search Managed Indices', () => { }); }); }); + + describe('DELETE /internal/enterprise_search/indices/{indexName}/ml_inference/pipeline_processors/{pipelineName}/detach', () => { + const indexName = 'my-index'; + const pipelineName = 'my-pipeline'; + + beforeEach(() => { + const context = { + core: Promise.resolve(mockCore), + } as unknown as jest.Mocked; + + mockRouter = new MockRouter({ + context, + method: 'delete', + path: '/internal/enterprise_search/indices/{indexName}/ml_inference/pipeline_processors/{pipelineName}/detach', + }); + + registerIndexRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('fails validation without index_name', () => { + const request = { params: {} }; + mockRouter.shouldThrow(request); + }); + + it('detaches pipeline', async () => { + const mockResponse = { updated: `${indexName}@ml-inference` }; + + (detachMlInferencePipeline as jest.Mock).mockImplementationOnce(() => { + return Promise.resolve(mockResponse); + }); + + await mockRouter.callRoute({ + params: { indexName, pipelineName }, + }); + + expect(detachMlInferencePipeline).toHaveBeenCalledWith( + indexName, + pipelineName, + mockClient.asCurrentUser + ); + + expect(mockRouter.response.ok).toHaveBeenCalledWith({ + body: mockResponse, + headers: { 'content-type': 'application/json' }, + }); + }); + + it('raises error if detaching failed', async () => { + const errorReason = `pipeline is missing: [${pipelineName}]`; + const mockError = new Error(errorReason) as ElasticsearchResponseError; + mockError.meta = { + body: { + error: { + type: 'resource_not_found_exception', + }, + }, + }; + (detachMlInferencePipeline as jest.Mock).mockImplementationOnce(() => { + return Promise.reject(mockError); + }); + + await mockRouter.callRoute({ + params: { indexName, pipelineName }, + }); + + expect(detachMlInferencePipeline).toHaveBeenCalledWith( + indexName, + pipelineName, + mockClient.asCurrentUser + ); + expect(mockRouter.response.customError).toHaveBeenCalledTimes(1); + }); + }); + + describe('GET /internal/enterprise_search/pipelines/ml_inference', () => { + let mockTrainedModelsProvider: MlTrainedModels; + let mockMl: SharedServices; + + beforeEach(() => { + const context = { + core: Promise.resolve(mockCore), + } as unknown as jest.Mocked; + + mockRouter = new MockRouter({ + context, + method: 'get', + path: '/internal/enterprise_search/pipelines/ml_inference', + }); + + mockTrainedModelsProvider = { + getTrainedModels: jest.fn(), + getTrainedModelsStats: jest.fn(), + } as MlTrainedModels; + + mockMl = { + trainedModelsProvider: () => Promise.resolve(mockTrainedModelsProvider), + } as unknown as jest.Mocked; + + registerIndexRoutes({ + ...mockDependencies, + router: mockRouter.router, + ml: mockMl, + }); + }); + + it('fetches ML inference pipelines', async () => { + const pipelinesResult = { + pipeline1: { + processors: [], + }, + pipeline2: { + processors: [], + }, + pipeline3: { + processors: [], + }, + }; + + (getMlInferencePipelines as jest.Mock).mockResolvedValueOnce(pipelinesResult); + + await mockRouter.callRoute({}); + + expect(getMlInferencePipelines).toHaveBeenCalledWith( + mockClient.asCurrentUser, + mockTrainedModelsProvider + ); + + expect(mockRouter.response.ok).toHaveBeenCalledWith({ + body: pipelinesResult, + headers: { 'content-type': 'application/json' }, + }); + }); + }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.ts index e1d2f0238740c8..1ff78028beaacf 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/indices.ts @@ -22,7 +22,6 @@ import { fetchConnectorByIndexName, fetchConnectors } from '../../lib/connectors import { fetchCrawlerByIndexName, fetchCrawlers } from '../../lib/crawler/fetch_crawlers'; import { createIndex } from '../../lib/indices/create_index'; -import { deleteMlInferencePipeline } from '../../lib/indices/delete_ml_inference_pipeline'; import { indexOrAliasExists } from '../../lib/indices/exists_index'; import { fetchIndex } from '../../lib/indices/fetch_index'; import { fetchIndices } from '../../lib/indices/fetch_indices'; @@ -30,9 +29,12 @@ import { fetchMlInferencePipelineHistory } from '../../lib/indices/fetch_ml_infe import { fetchMlInferencePipelineProcessors } from '../../lib/indices/fetch_ml_inference_pipeline_processors'; import { generateApiKey } from '../../lib/indices/generate_api_key'; import { getMlInferenceErrors } from '../../lib/ml_inference_pipeline/get_inference_errors'; +import { getMlInferencePipelines } from '../../lib/ml_inference_pipeline/get_inference_pipelines'; import { createIndexPipelineDefinitions } from '../../lib/pipelines/create_pipeline_definitions'; import { getCustomPipelines } from '../../lib/pipelines/get_custom_pipelines'; import { getPipeline } from '../../lib/pipelines/get_pipeline'; +import { deleteMlInferencePipeline } from '../../lib/pipelines/ml_inference/pipeline_processors/delete_ml_inference_pipeline'; +import { detachMlInferencePipeline } from '../../lib/pipelines/ml_inference/pipeline_processors/detach_ml_inference_pipeline'; import { RouteDependencies } from '../../plugin'; import { createError } from '../../utils/create_error'; import { @@ -680,4 +682,69 @@ export function registerIndexRoutes({ }); }) ); + + router.get( + { + path: '/internal/enterprise_search/pipelines/ml_inference', + validate: {}, + }, + elasticsearchErrorHandler(log, async (context, request, response) => { + const { + elasticsearch: { client }, + savedObjects: { client: savedObjectsClient }, + } = await context.core; + const trainedModelsProvider = ml + ? await ml.trainedModelsProvider(request, savedObjectsClient) + : undefined; + + const pipelines = await getMlInferencePipelines(client.asCurrentUser, trainedModelsProvider); + + return response.ok({ + body: pipelines, + headers: { 'content-type': 'application/json' }, + }); + }) + ); + + router.delete( + { + path: '/internal/enterprise_search/indices/{indexName}/ml_inference/pipeline_processors/{pipelineName}/detach', + validate: { + params: schema.object({ + indexName: schema.string(), + pipelineName: schema.string(), + }), + }, + }, + elasticsearchErrorHandler(log, async (context, request, response) => { + const indexName = decodeURIComponent(request.params.indexName); + const pipelineName = decodeURIComponent(request.params.pipelineName); + const { client } = (await context.core).elasticsearch; + + try { + const detachResult = await detachMlInferencePipeline( + indexName, + pipelineName, + client.asCurrentUser + ); + + return response.ok({ + body: detachResult, + headers: { 'content-type': 'application/json' }, + }); + } catch (error) { + if (isResourceNotFoundException(error)) { + // return specific message if pipeline doesn't exist + return createError({ + errorCode: ErrorCode.RESOURCE_NOT_FOUND, + message: error.meta?.body?.error?.reason, + response, + statusCode: 404, + }); + } + // otherwise, let the default handler wrap it + throw error; + } + }) + ); } diff --git a/x-pack/plugins/enterprise_search/server/utils/create_ml_inference_pipeline.test.ts b/x-pack/plugins/enterprise_search/server/utils/create_ml_inference_pipeline.test.ts index d3aa24560594d2..47a4676e592614 100644 --- a/x-pack/plugins/enterprise_search/server/utils/create_ml_inference_pipeline.test.ts +++ b/x-pack/plugins/enterprise_search/server/utils/create_ml_inference_pipeline.test.ts @@ -37,6 +37,9 @@ describe('createMlInferencePipeline util function', () => { Promise.resolve({ trained_model_configs: [ { + inference_config: { + ner: {}, + }, input: { field_names: ['target-field'], }, diff --git a/x-pack/plugins/enterprise_search/server/utils/create_ml_inference_pipeline.ts b/x-pack/plugins/enterprise_search/server/utils/create_ml_inference_pipeline.ts index da4bce12d71ef6..e0b4d903be2add 100644 --- a/x-pack/plugins/enterprise_search/server/utils/create_ml_inference_pipeline.ts +++ b/x-pack/plugins/enterprise_search/server/utils/create_ml_inference_pipeline.ts @@ -22,9 +22,9 @@ import { * Details of a created pipeline. */ export interface CreatedPipeline { - id: string; - created?: boolean; addedToParentPipeline?: boolean; + created?: boolean; + id: string; } /** @@ -110,8 +110,8 @@ export const createMlInferencePipeline = async ( }); return Promise.resolve({ - id: inferencePipelineGeneratedName, created: true, + id: inferencePipelineGeneratedName, }); }; @@ -143,8 +143,8 @@ export const addSubPipelineToIndexSpecificMlPipeline = async ( // Verify the parent pipeline exists with a processors array if (!parentPipeline?.processors) { return Promise.resolve({ - id: pipelineName, addedToParentPipeline: false, + id: pipelineName, }); } @@ -155,8 +155,8 @@ export const addSubPipelineToIndexSpecificMlPipeline = async ( ); if (existingSubPipeline) { return Promise.resolve({ - id: pipelineName, addedToParentPipeline: false, + id: pipelineName, }); } @@ -173,7 +173,7 @@ export const addSubPipelineToIndexSpecificMlPipeline = async ( }); return Promise.resolve({ - id: pipelineName, addedToParentPipeline: true, + id: pipelineName, }); }; diff --git a/x-pack/plugins/fleet/server/services/fleet_usage_sender.ts b/x-pack/plugins/fleet/server/services/fleet_usage_sender.ts index b7ab806e5f8c17..ada764fcff9278 100644 --- a/x-pack/plugins/fleet/server/services/fleet_usage_sender.ts +++ b/x-pack/plugins/fleet/server/services/fleet_usage_sender.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -/* eslint-disable max-classes-per-file */ import type { ConcreteTaskInstance, TaskManagerStartContract, @@ -12,15 +11,11 @@ import type { } from '@kbn/task-manager-plugin/server'; import type { CoreSetup } from '@kbn/core/server'; -import { ElasticV3ServerShipper } from '@kbn/analytics-shippers-elastic-v3-server'; - import type { Usage } from '../collectors/register'; import { appContextService } from './app_context'; -export class FleetShipper extends ElasticV3ServerShipper { - public static shipperName = 'fleet_shipper'; -} +const EVENT_TYPE = 'fleet_usage'; export class FleetUsageSender { private taskManager?: TaskManagerStartContract; @@ -47,7 +42,7 @@ export class FleetUsageSender { try { const usageData = await fetchUsage(); appContextService.getLogger().debug(JSON.stringify(usageData)); - core.analytics.reportEvent('Fleet Usage', usageData); + core.analytics.reportEvent(EVENT_TYPE, usageData); } catch (error) { appContextService .getLogger() @@ -61,12 +56,6 @@ export class FleetUsageSender { }, }); this.registerTelemetryEventType(core); - - core.analytics.registerShipper(FleetShipper, { - channelName: 'fleet-usages', - version: kibanaVersion, - sendTo: isProductionMode ? 'production' : 'staging', - }); } public async start(taskManager: TaskManagerStartContract) { @@ -90,7 +79,7 @@ export class FleetUsageSender { */ private registerTelemetryEventType(core: CoreSetup): void { core.analytics.registerEventType({ - eventType: 'Fleet Usage', + eventType: EVENT_TYPE, schema: { agents_enabled: { type: 'boolean', _meta: { description: 'agents enabled' } }, agents: { diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.test.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.test.tsx index 6d6fed51818640..b272b5ec3e3628 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.test.tsx @@ -31,6 +31,7 @@ import { ReactWrapper, mount } from 'enzyme'; import { getFoundListsBySizeSchemaMock } from '../../../../common/schemas/response/found_lists_by_size_schema.mock'; import { BuilderEntryItem } from './entry_renderer'; +import * as i18n from './translations'; jest.mock('@kbn/securitysolution-list-hooks'); jest.mock('@kbn/securitysolution-utils'); @@ -81,11 +82,78 @@ describe('BuilderEntryItem', () => { onChange={jest.fn()} setErrorsExist={jest.fn()} setWarningsExist={jest.fn()} - showLabel={true} + showLabel /> ); expect(wrapper.find('[data-test-subj="exceptionBuilderEntryFieldFormRow"]')).not.toEqual(0); + expect(wrapper.find('.euiFormHelpText.euiFormRow__text').exists()).toBeFalsy(); + }); + + test('it renders custom option text if "allowCustomOptions" is "true" and it is not a nested entry', () => { + wrapper = mount( + + ); + + expect(wrapper.find('.euiFormHelpText.euiFormRow__text').at(0).text()).toEqual( + i18n.CUSTOM_COMBOBOX_OPTION_TEXT + ); + }); + + test('it does not render custom option text when "allowCustomOptions" is "true" and it is a nested entry', () => { + wrapper = mount( + + ); + + expect(wrapper.find('.euiFormHelpText.euiFormRow__text').exists()).toBeFalsy(); }); test('it renders field values correctly when operator is "isOperator"', () => { @@ -259,7 +327,7 @@ describe('BuilderEntryItem', () => { onChange={jest.fn()} setErrorsExist={jest.fn()} setWarningsExist={jest.fn()} - showLabel={true} + showLabel /> ); @@ -297,7 +365,7 @@ describe('BuilderEntryItem', () => { onChange={jest.fn()} setErrorsExist={jest.fn()} setWarningsExist={jest.fn()} - showLabel={true} + showLabel /> ); diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx index 8f0bc15bd7da65..2df0b4b41a2f1e 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx @@ -75,6 +75,7 @@ export interface EntryItemProps { setWarningsExist: (arg: boolean) => void; isDisabled?: boolean; operatorsList?: OperatorOption[]; + allowCustomOptions?: boolean; } export const BuilderEntryItem: React.FC = ({ @@ -93,6 +94,7 @@ export const BuilderEntryItem: React.FC = ({ showLabel, isDisabled = false, operatorsList, + allowCustomOptions = false, }): JSX.Element => { const handleError = useCallback( (err: boolean): void => { @@ -163,9 +165,9 @@ export const BuilderEntryItem: React.FC = ({ const isFieldComponentDisabled = useMemo( (): boolean => isDisabled || - indexPattern == null || - (indexPattern != null && indexPattern.fields.length === 0), - [isDisabled, indexPattern] + (!allowCustomOptions && + (indexPattern == null || (indexPattern != null && indexPattern.fields.length === 0))), + [isDisabled, indexPattern, allowCustomOptions] ); const renderFieldInput = useCallback( @@ -190,6 +192,7 @@ export const BuilderEntryItem: React.FC = ({ isLoading={false} isDisabled={isDisabled || indexPattern == null} onChange={handleFieldChange} + acceptsCustomOptions={entry.nested == null} data-test-subj="exceptionBuilderEntryField" /> ); @@ -199,6 +202,11 @@ export const BuilderEntryItem: React.FC = ({ {comboBox} @@ -206,7 +214,16 @@ export const BuilderEntryItem: React.FC = ({ ); } else { return ( - + {comboBox} ); @@ -220,6 +237,7 @@ export const BuilderEntryItem: React.FC = ({ handleFieldChange, osTypes, isDisabled, + allowCustomOptions, ] ); diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx index 84c18baf51569f..d891c1a5eea087 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx @@ -63,6 +63,7 @@ interface BuilderExceptionListItemProps { onlyShowListOperators?: boolean; isDisabled?: boolean; operatorsList?: OperatorOption[]; + allowCustomOptions?: boolean; } export const BuilderExceptionListItemComponent = React.memo( @@ -85,6 +86,7 @@ export const BuilderExceptionListItemComponent = React.memo { const handleEntryChange = useCallback( (entry: BuilderEntry, entryIndex: number): void => { @@ -117,9 +119,9 @@ export const BuilderExceptionListItemComponent = React.memo { const hasIndexPatternAndEntries = indexPattern != null && exceptionItem.entries.length > 0; return hasIndexPatternAndEntries - ? getFormattedBuilderEntries(indexPattern, exceptionItem.entries) + ? getFormattedBuilderEntries(indexPattern, exceptionItem.entries, allowCustomOptions) : []; - }, [exceptionItem.entries, indexPattern]); + }, [exceptionItem.entries, indexPattern, allowCustomOptions]); return ( @@ -157,6 +159,7 @@ export const BuilderExceptionListItemComponent = React.memo DataViewBase; onChange: (arg: OnChangeProps) => void; - exceptionItemName?: string; ruleName?: string; isDisabled?: boolean; operatorsList?: OperatorOption[]; + exceptionItemName?: string; + allowCustomFieldOptions?: boolean; } export const ExceptionBuilderComponent = ({ @@ -118,6 +119,7 @@ export const ExceptionBuilderComponent = ({ isDisabled = false, osTypes, operatorsList, + allowCustomFieldOptions = false, }: ExceptionBuilderProps): JSX.Element => { const [ { @@ -229,7 +231,6 @@ export const ExceptionBuilderComponent = ({ }, ...exceptions.slice(index + 1), ]; - setUpdateExceptions(updatedExceptions); }, [setUpdateExceptions, exceptions] @@ -278,7 +279,6 @@ export const ExceptionBuilderComponent = ({ ...lastException, entries: [...entries, isNested ? getDefaultNestedEmptyEntry() : getDefaultEmptyEntry()], }; - setUpdateExceptions([...exceptions.slice(0, exceptions.length - 1), { ...updatedException }]); }, [setUpdateExceptions, exceptions] @@ -290,11 +290,12 @@ export const ExceptionBuilderComponent = ({ // would then be arbitrary, decided to just create a new exception list item const newException = getNewExceptionItem({ listId, + name: exceptionItemName ?? `${ruleName ?? 'Rule'} - Exception item`, namespaceType: listNamespaceType, - ruleName: exceptionItemName ?? `${ruleName ?? 'Rule'} - Exception item`, }); + setUpdateExceptions([...exceptions, { ...newException }]); - }, [listId, listNamespaceType, exceptionItemName, ruleName, setUpdateExceptions, exceptions]); + }, [setUpdateExceptions, exceptions, listId, listNamespaceType, ruleName, exceptionItemName]); // The builder can have existing exception items, or new exception items that have yet // to be created (and thus lack an id), this was creating some React bugs with relying @@ -334,7 +335,6 @@ export const ExceptionBuilderComponent = ({ }, ], }; - setUpdateExceptions([...exceptions.slice(0, exceptions.length - 1), { ...updatedException }]); } else { setUpdateExceptions(exceptions); @@ -359,19 +359,23 @@ export const ExceptionBuilderComponent = ({ handleAddNewExceptionItemEntry(); }, [handleAddNewExceptionItemEntry, setUpdateOrDisabled, setUpdateAddNested]); + const memoExceptionItems = useMemo(() => { + return filterExceptionItems(exceptions); + }, [exceptions]); + + // useEffect(() => { + // setUpdateExceptions([]); + // }, [osTypes, setUpdateExceptions]); + // Bubble up changes to parent useEffect(() => { onChange({ errorExists: errorExists > 0, - exceptionItems: filterExceptionItems(exceptions), + exceptionItems: memoExceptionItems, exceptionsToDelete, warningExists: warningExists > 0, }); - }, [onChange, exceptionsToDelete, exceptions, errorExists, warningExists]); - - useEffect(() => { - setUpdateExceptions([]); - }, [osTypes, setUpdateExceptions]); + }, [onChange, exceptionsToDelete, memoExceptionItems, errorExists, warningExists]); // Defaults builder to never be sans entry, instead // always falls back to an empty entry if user deletes all @@ -436,6 +440,7 @@ export const ExceptionBuilderComponent = ({ osTypes={osTypes} isDisabled={isDisabled} operatorsList={operatorsList} + allowCustomOptions={allowCustomFieldOptions} /> diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/helpers.test.ts b/x-pack/plugins/lists/public/exceptions/components/builder/helpers.test.ts index 73bf42e767dd6d..38323fcf88cbfb 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/helpers.test.ts +++ b/x-pack/plugins/lists/public/exceptions/components/builder/helpers.test.ts @@ -6,13 +6,11 @@ */ import { - CreateExceptionListItemSchema, EntryExists, EntryList, EntryMatch, EntryMatchAny, EntryNested, - ExceptionListItemSchema, ExceptionListType, ListOperatorEnum as OperatorEnum, ListOperatorTypeEnum as OperatorTypeEnum, @@ -24,6 +22,7 @@ import { EXCEPTION_OPERATORS_SANS_LISTS, EmptyEntry, ExceptionsBuilderExceptionItem, + ExceptionsBuilderReturnExceptionItem, FormattedBuilderEntry, OperatorOption, doesNotExistOperator, @@ -1056,10 +1055,10 @@ describe('Exception builder helpers', () => { }); describe('#getFormattedBuilderEntries', () => { - test('it returns formatted entry with field undefined if it unable to find a matching index pattern field', () => { + test('it returns formatted entry with field undefined if it unable to find a matching index pattern field and "allowCustomFieldOptions" is "false"', () => { const payloadIndexPattern = getMockIndexPattern(); const payloadItems: BuilderEntry[] = [getEntryMatchWithIdMock()]; - const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); + const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems, false); const expected: FormattedBuilderEntry[] = [ { correspondingKeywordField: undefined, @@ -1075,13 +1074,35 @@ describe('Exception builder helpers', () => { expect(output).toEqual(expected); }); + test('it returns formatted entry with field even if it is unable to find a matching index pattern field and "allowCustomFieldOptions" is "true"', () => { + const payloadIndexPattern = getMockIndexPattern(); + const payloadItems: BuilderEntry[] = [getEntryMatchWithIdMock()]; + const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems, true); + const expected: FormattedBuilderEntry[] = [ + { + correspondingKeywordField: undefined, + entryIndex: 0, + field: { + name: 'host.name', + type: 'keyword', + }, + id: '123', + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some host name', + }, + ]; + expect(output).toEqual(expected); + }); + test('it returns formatted entries when no nested entries exist', () => { const payloadIndexPattern = getMockIndexPattern(); const payloadItems: BuilderEntry[] = [ { ...getEntryMatchWithIdMock(), field: 'ip', value: 'some ip' }, { ...getEntryMatchAnyWithIdMock(), field: 'extension', value: ['some extension'] }, ]; - const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); + const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems, false); const field1: FieldSpec = { aggregatable: true, count: 0, @@ -1139,7 +1160,7 @@ describe('Exception builder helpers', () => { { ...payloadParent }, ]; - const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); + const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems, false); const field1: FieldSpec = { aggregatable: true, count: 0, @@ -1313,7 +1334,8 @@ describe('Exception builder helpers', () => { payloadItem, 0, undefined, - undefined + undefined, + false ); const field: FieldSpec = { aggregatable: false, @@ -1338,6 +1360,95 @@ describe('Exception builder helpers', () => { expect(output).toEqual(expected); }); + test('it returns entry with field value undefined if "allowCustomFieldOptions" is "false" and no matching field found', () => { + const payloadIndexPattern: DataViewBase = { + ...getMockIndexPattern(), + fields: [ + ...fields, + { + aggregatable: false, + count: 0, + esTypes: ['text'], + name: 'machine.os.raw.text', + readFromDocValues: true, + scripted: false, + searchable: false, + type: 'string', + }, + ], + }; + const payloadItem: BuilderEntry = { + ...getEntryMatchWithIdMock(), + field: 'custom.text', + value: 'some os', + }; + const output = getFormattedBuilderEntry( + payloadIndexPattern, + payloadItem, + 0, + undefined, + undefined, + false + ); + const expected: FormattedBuilderEntry = { + correspondingKeywordField: undefined, + entryIndex: 0, + field: undefined, + id: '123', + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some os', + }; + expect(output).toEqual(expected); + }); + + test('it returns entry with custom field value if "allowCustomFieldOptions" is "true" and no matching field found', () => { + const payloadIndexPattern: DataViewBase = { + ...getMockIndexPattern(), + fields: [ + ...fields, + { + aggregatable: false, + count: 0, + esTypes: ['text'], + name: 'machine.os.raw.text', + readFromDocValues: true, + scripted: false, + searchable: false, + type: 'string', + }, + ], + }; + const payloadItem: BuilderEntry = { + ...getEntryMatchWithIdMock(), + field: 'custom.text', + value: 'some os', + }; + const output = getFormattedBuilderEntry( + payloadIndexPattern, + payloadItem, + 0, + undefined, + undefined, + true + ); + const expected: FormattedBuilderEntry = { + correspondingKeywordField: undefined, + entryIndex: 0, + field: { + name: 'custom.text', + type: 'keyword', + }, + id: '123', + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some os', + }; + expect(output).toEqual(expected); + }); + test('it returns "FormattedBuilderEntry" with value "nested" of "child" when "parent" and "parentIndex" are defined', () => { const payloadIndexPattern = getMockIndexPattern(); const payloadItem: BuilderEntry = { ...getEntryMatchWithIdMock(), field: 'child' }; @@ -1351,7 +1462,8 @@ describe('Exception builder helpers', () => { payloadItem, 0, payloadParent, - 1 + 1, + false ); const field: FieldSpec = { aggregatable: false, @@ -1401,7 +1513,8 @@ describe('Exception builder helpers', () => { payloadItem, 0, undefined, - undefined + undefined, + false ); const field: FieldSpec = { aggregatable: true, @@ -1577,8 +1690,9 @@ describe('Exception builder helpers', () => { // Please see `x-pack/plugins/lists/public/exceptions/transforms.ts` doc notes // for context around the temporary `id` test('it correctly validates entries that include a temporary `id`', () => { - const output: Array = - filterExceptionItems([{ ...getExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }]); + const output: ExceptionsBuilderReturnExceptionItem[] = filterExceptionItems([ + { ...getExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }, + ]); expect(output).toEqual([{ ...getExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }]); }); @@ -1611,13 +1725,12 @@ describe('Exception builder helpers', () => { type: OperatorTypeEnum.MATCH, value: '', }; - const output: Array = - filterExceptionItems([ - { - ...rest, - entries: [...entries, mockEmptyException], - }, - ]); + const output: ExceptionsBuilderReturnExceptionItem[] = filterExceptionItems([ + { + ...rest, + entries: [...entries, mockEmptyException], + }, + ]); expect(output).toEqual([{ ...getExceptionListItemSchemaMock() }]); }); @@ -1631,13 +1744,12 @@ describe('Exception builder helpers', () => { type: OperatorTypeEnum.MATCH, value: 'some value', }; - const output: Array = - filterExceptionItems([ - { - ...rest, - entries: [...entries, mockEmptyException], - }, - ]); + const output: ExceptionsBuilderReturnExceptionItem[] = filterExceptionItems([ + { + ...rest, + entries: [...entries, mockEmptyException], + }, + ]); expect(output).toEqual([{ ...getExceptionListItemSchemaMock() }]); }); @@ -1651,13 +1763,12 @@ describe('Exception builder helpers', () => { type: OperatorTypeEnum.MATCH_ANY, value: ['some value'], }; - const output: Array = - filterExceptionItems([ - { - ...rest, - entries: [...entries, mockEmptyException], - }, - ]); + const output: ExceptionsBuilderReturnExceptionItem[] = filterExceptionItems([ + { + ...rest, + entries: [...entries, mockEmptyException], + }, + ]); expect(output).toEqual([{ ...getExceptionListItemSchemaMock() }]); }); @@ -1669,13 +1780,12 @@ describe('Exception builder helpers', () => { field: '', type: OperatorTypeEnum.NESTED, }; - const output: Array = - filterExceptionItems([ - { - ...rest, - entries: [...entries, mockEmptyException], - }, - ]); + const output: ExceptionsBuilderReturnExceptionItem[] = filterExceptionItems([ + { + ...rest, + entries: [...entries, mockEmptyException], + }, + ]); expect(output).toEqual([{ ...getExceptionListItemSchemaMock() }]); }); @@ -1687,13 +1797,12 @@ describe('Exception builder helpers', () => { field: 'host.name', type: OperatorTypeEnum.NESTED, }; - const output: Array = - filterExceptionItems([ - { - ...rest, - entries: [...entries, mockEmptyException], - }, - ]); + const output: ExceptionsBuilderReturnExceptionItem[] = filterExceptionItems([ + { + ...rest, + entries: [...entries, mockEmptyException], + }, + ]); expect(output).toEqual([ { @@ -1713,27 +1822,134 @@ describe('Exception builder helpers', () => { field: 'host.name', type: OperatorTypeEnum.NESTED, }; - const output: Array = - filterExceptionItems([ - { - ...rest, - entries: [...entries, mockEmptyException], - }, - ]); + const output: ExceptionsBuilderReturnExceptionItem[] = filterExceptionItems([ + { + ...rest, + entries: [...entries, mockEmptyException], + }, + ]); expect(output).toEqual([{ ...getExceptionListItemSchemaMock() }]); }); - test('it removes `temporaryId` from items', () => { + test('it removes `temporaryId` from "createExceptionListItemSchema" items', () => { const { meta, ...rest } = getNewExceptionItem({ listId: '123', + name: 'rule name', namespaceType: 'single', - ruleName: 'rule name', }); const exceptions = filterExceptionItems([{ ...rest, entries: [getEntryMatchMock()], meta }]); expect(exceptions).toEqual([{ ...rest, entries: [getEntryMatchMock()], meta: undefined }]); }); + + test('it removes `temporaryId` from "createRuleExceptionListItemSchema" items', () => { + const { meta, ...rest } = getNewExceptionItem({ + listId: undefined, + name: 'rule name', + namespaceType: undefined, + }); + const exceptions = filterExceptionItems([{ ...rest, entries: [getEntryMatchMock()], meta }]); + + expect(exceptions).toEqual([{ ...rest, entries: [getEntryMatchMock()], meta: undefined }]); + }); + }); + + describe('#getNewExceptionItem', () => { + it('returns new item with updated name', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { meta, ...rest } = getNewExceptionItem({ + listId: '123', + name: 'My Item Name', + namespaceType: 'single', + }); + + expect(rest.name).toEqual('My Item Name'); + }); + + it('returns new item with list_id if one is passed in', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { meta, ...rest } = getNewExceptionItem({ + listId: '123', + name: 'My Item Name', + namespaceType: 'single', + }); + + expect(rest).toEqual({ + comments: [], + description: 'Exception list item', + entries: [{ field: '', id: '123', operator: 'included', type: 'match', value: '' }], + item_id: undefined, + list_id: '123', + name: 'My Item Name', + namespace_type: 'single', + tags: [], + type: 'simple', + }); + }); + + it('returns new item without list_id if none is passed in', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { meta, ...rest } = getNewExceptionItem({ + listId: undefined, + name: 'My Item Name', + namespaceType: 'single', + }); + + expect(rest).toEqual({ + comments: [], + description: 'Exception list item', + entries: [{ field: '', id: '123', operator: 'included', type: 'match', value: '' }], + item_id: undefined, + list_id: undefined, + name: 'My Item Name', + namespace_type: 'single', + tags: [], + type: 'simple', + }); + }); + + it('returns new item with namespace_type if one is passed in', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { meta, ...rest } = getNewExceptionItem({ + listId: '123', + name: 'My Item Name', + namespaceType: 'single', + }); + + expect(rest).toEqual({ + comments: [], + description: 'Exception list item', + entries: [{ field: '', id: '123', operator: 'included', type: 'match', value: '' }], + item_id: undefined, + list_id: '123', + name: 'My Item Name', + namespace_type: 'single', + tags: [], + type: 'simple', + }); + }); + + it('returns new item without namespace_type if none is passed in', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { meta, ...rest } = getNewExceptionItem({ + listId: '123', + name: 'My Item Name', + namespaceType: undefined, + }); + + expect(rest).toEqual({ + comments: [], + description: 'Exception list item', + entries: [{ field: '', id: '123', operator: 'included', type: 'match', value: '' }], + item_id: undefined, + list_id: '123', + name: 'My Item Name', + namespace_type: undefined, + tags: [], + type: 'simple', + }); + }); }); describe('#getEntryValue', () => { diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/translations.ts b/x-pack/plugins/lists/public/exceptions/components/builder/translations.ts index 291ef7a420f0fd..ee7971e69c83ac 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/translations.ts +++ b/x-pack/plugins/lists/public/exceptions/components/builder/translations.ts @@ -75,3 +75,11 @@ export const AND = i18n.translate('xpack.lists.exceptions.andDescription', { export const OR = i18n.translate('xpack.lists.exceptions.orDescription', { defaultMessage: 'OR', }); + +export const CUSTOM_COMBOBOX_OPTION_TEXT = i18n.translate( + 'xpack.lists.exceptions.comboBoxCustomOptionText', + { + defaultMessage: + 'Select a field from the list. If your field is not available, create a custom one.', + } +); diff --git a/x-pack/plugins/observability/kibana.json b/x-pack/plugins/observability/kibana.json index 365bc50e8abf1a..43e69c1b4d9524 100644 --- a/x-pack/plugins/observability/kibana.json +++ b/x-pack/plugins/observability/kibana.json @@ -27,7 +27,6 @@ "features", "inspector", "ruleRegistry", - "timelines", "triggersActionsUi", "inspector", "unifiedSearch", @@ -44,7 +43,8 @@ "kibanaReact", "kibanaUtils", "lens", - "usageCollection" + "usageCollection", + "visualizations" ], "extraPublicDirs": [ "common" diff --git a/x-pack/plugins/observability/public/application/types.ts b/x-pack/plugins/observability/public/application/types.ts index 7e76a46c03c0cc..4707760c63e398 100644 --- a/x-pack/plugins/observability/public/application/types.ts +++ b/x-pack/plugins/observability/public/application/types.ts @@ -24,8 +24,6 @@ import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { LensPublicStart } from '@kbn/lens-plugin/public'; import { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public'; import { CasesUiStart } from '@kbn/cases-plugin/public'; -import { TimelinesUIStart } from '@kbn/timelines-plugin/public'; - export interface ObservabilityAppServices { application: ApplicationStart; cases: CasesUiStart; @@ -42,7 +40,6 @@ export interface ObservabilityAppServices { stateTransfer: EmbeddableStateTransfer; storage: IStorageWrapper; theme: ThemeServiceStart; - timelines: TimelinesUIStart; triggersActionsUi: TriggersAndActionsUIPublicPluginStart; uiSettings: IUiSettingsClient; isDev?: boolean; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts index b031bff08b0f8d..d1fdd6bc7cf676 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts @@ -158,7 +158,7 @@ describe('Lens Attribute', () => { customLabel: true, dataType: 'number', isBucketed: false, - label: 'Pages loaded', + label: 'test-series', operationType: 'formula', params: { format: { @@ -427,7 +427,13 @@ describe('Lens Attribute', () => { ], }, ], - legend: { isVisible: true, showSingleSeries: true, position: 'right' }, + legend: { + isVisible: true, + showSingleSeries: true, + position: 'right', + legendSize: 'large', + shouldTruncate: false, + }, preferredSeriesType: 'line', tickLabelsVisibilitySettings: { x: true, yLeft: true, yRight: true }, valueLabels: 'hide', @@ -545,7 +551,7 @@ describe('Lens Attribute', () => { 'transaction.type: page-load and processor.event: transaction and transaction.type : *', }, isBucketed: false, - label: 'Pages loaded', + label: 'test-series', operationType: 'formula', params: { format: { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts index 8e39ff3bdd2c69..60f554d5344c43 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts @@ -37,6 +37,7 @@ import { } from '@kbn/lens-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/common'; import { PersistableFilter } from '@kbn/lens-plugin/common'; +import { LegendSize } from '@kbn/visualizations-plugin/common/constants'; import { urlFiltersToKueryString } from '../utils/stringify_kueries'; import { FILTER_RECORDS, @@ -397,15 +398,14 @@ export class LensAttributes { return { ...buildNumberColumn(sourceField), label: - operationType === 'unique_count' || shortLabel - ? label || seriesConfig.labels[sourceField] - : i18n.translate('xpack.observability.expView.columns.operation.label', { - defaultMessage: '{operationType} of {sourceField}', - values: { - sourceField: label || seriesConfig.labels[sourceField], - operationType: capitalize(operationType), - }, - }), + label ?? + i18n.translate('xpack.observability.expView.columns.operation.label', { + defaultMessage: '{operationType} of {sourceField}', + values: { + sourceField: seriesConfig.labels[sourceField], + operationType: capitalize(operationType), + }, + }), filter: columnFilter, operationType, params: @@ -574,7 +574,7 @@ export class LensAttributes { const { type: fieldType } = fieldMeta ?? {}; if (columnType === TERMS_COLUMN) { - return this.getTermsColumn(fieldName, columnLabel || label); + return this.getTermsColumn(fieldName, label || columnLabel); } if (fieldName === RECORDS_FIELD || columnType === FILTER_RECORDS) { @@ -606,7 +606,7 @@ export class LensAttributes { columnType, columnFilter: columnFilters?.[0], operationType, - label: columnLabel || label, + label: label || columnLabel, seriesConfig: layerConfig.seriesConfig, shortLabel, }); @@ -615,7 +615,7 @@ export class LensAttributes { return this.getNumberOperationColumn({ sourceField: fieldName, operationType: 'unique_count', - label: columnLabel || label, + label: label || columnLabel, seriesConfig: layerConfig.seriesConfig, columnFilter: columnFilters?.[0], }); @@ -687,8 +687,18 @@ export class LensAttributes { getMainYAxis(layerConfig: LayerConfig, layerId: string, columnFilter: string) { const { breakdown } = layerConfig; - const { sourceField, operationType, label, timeScale } = - layerConfig.seriesConfig.yAxisColumns[0]; + const { + sourceField, + operationType, + label: colLabel, + timeScale, + } = layerConfig.seriesConfig.yAxisColumns[0]; + + let label = layerConfig.name || colLabel; + + if (layerConfig.seriesConfig.reportType === ReportTypes.CORE_WEB_VITAL) { + label = colLabel; + } if (sourceField === RECORDS_PERCENTAGE_FIELD) { return [ @@ -1028,7 +1038,13 @@ export class LensAttributes { getXyState(): XYState { return { - legend: { isVisible: true, showSingleSeries: true, position: 'right' }, + legend: { + isVisible: true, + showSingleSeries: true, + position: 'right', + legendSize: LegendSize.LARGE, + shouldTruncate: false, + }, valueLabels: 'hide', fittingFunction: 'Linear', curveType: 'CURVE_MONOTONE_X' as XYCurveType, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/mobile_test_attribute.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/mobile_test_attribute.ts index 85fd0c9f601b84..1af87c385d31e9 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/mobile_test_attribute.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/mobile_test_attribute.ts @@ -39,7 +39,7 @@ export const testMobileKPIAttr = { }, 'y-axis-column-layer0-0': { isBucketed: false, - label: 'Median of System memory usage', + label: 'test-series', operationType: 'median', params: {}, scale: 'ratio', @@ -58,7 +58,13 @@ export const testMobileKPIAttr = { }, }, visualization: { - legend: { isVisible: true, showSingleSeries: true, position: 'right' }, + legend: { + isVisible: true, + showSingleSeries: true, + position: 'right', + legendSize: 'large', + shouldTruncate: false, + }, valueLabels: 'hide', fittingFunction: 'Linear', curveType: 'CURVE_MONOTONE_X', diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute.ts index 6c6424a0362d11..4661775b3a83f5 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute.ts @@ -67,7 +67,7 @@ export const sampleAttribute = { 'transaction.type: page-load and processor.event: transaction and transaction.type : *', }, isBucketed: false, - label: 'Pages loaded', + label: 'test-series', operationType: 'formula', params: { format: { @@ -322,6 +322,8 @@ export const sampleAttribute = { isVisible: true, position: 'right', showSingleSeries: true, + legendSize: 'large', + shouldTruncate: false, }, preferredSeriesType: 'line', tickLabelsVisibilitySettings: { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts index 1cf945c4456a5a..15e462c10be289 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts @@ -141,6 +141,8 @@ export const sampleAttributeCoreWebVital = { isVisible: true, showSingleSeries: true, position: 'right', + shouldTruncate: false, + legendSize: 'large', }, preferredSeriesType: 'line', tickLabelsVisibilitySettings: { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts index 280438737b5da5..6482795198898b 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts @@ -45,7 +45,7 @@ export const sampleAttributeKpi = { query: 'transaction.type: page-load and processor.event: transaction', }, isBucketed: false, - label: 'Page views', + label: 'test-series', operationType: 'count', scale: 'ratio', sourceField: RECORDS_FIELD, @@ -95,6 +95,8 @@ export const sampleAttributeKpi = { isVisible: true, showSingleSeries: true, position: 'right', + legendSize: 'large', + shouldTruncate: false, }, preferredSeriesType: 'line', tickLabelsVisibilitySettings: { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_with_reference_lines.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_with_reference_lines.ts index 5d51b1c1934016..873e4a6269de6a 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_with_reference_lines.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_with_reference_lines.ts @@ -67,7 +67,7 @@ export const sampleAttributeWithReferenceLines = { 'transaction.type: page-load and processor.event: transaction and transaction.type : * and service.name: (elastic or kibana)', }, isBucketed: false, - label: 'Pages loaded', + label: 'test-series', operationType: 'formula', params: { format: { @@ -322,6 +322,8 @@ export const sampleAttributeWithReferenceLines = { isVisible: true, position: 'right', showSingleSeries: true, + legendSize: 'large', + shouldTruncate: false, }, preferredSeriesType: 'line', tickLabelsVisibilitySettings: { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_name.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_name.tsx index 68a628e23292cc..a8d0338e9eb7ea 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_name.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_name.tsx @@ -54,12 +54,14 @@ export function SeriesName({ series, seriesId }: Props) { const onOutsideClick = (event: Event) => { if (event.target !== buttonRef.current) { setIsEditingEnabled(false); + onSave(); } }; const onKeyDown: KeyboardEventHandler = (event) => { if (event.key === 'Enter') { setIsEditingEnabled(false); + onSave(); } }; diff --git a/x-pack/plugins/observability/public/config/register_alerts_table_configuration.tsx b/x-pack/plugins/observability/public/config/register_alerts_table_configuration.tsx index bc41b8d8032854..d6ac49b736a5a1 100644 --- a/x-pack/plugins/observability/public/config/register_alerts_table_configuration.tsx +++ b/x-pack/plugins/observability/public/config/register_alerts_table_configuration.tsx @@ -12,9 +12,9 @@ import { casesFeatureId, observabilityFeatureId } from '../../common'; import { useBulkAddToCaseActions } from '../hooks/use_alert_bulk_case_actions'; import { TopAlert, useToGetInternalFlyout } from '../pages/alerts'; import { getRenderCellValue } from '../pages/alerts/components/render_cell_value'; -import { addDisplayNames } from '../pages/alerts/containers/alerts_table_t_grid/add_display_names'; -import { columns as alertO11yColumns } from '../pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid'; -import { getRowActions } from '../pages/alerts/containers/alerts_table_t_grid/get_row_actions'; +import { addDisplayNames } from '../pages/alerts/containers/alerts_table/add_display_names'; +import { columns as alertO11yColumns } from '../pages/alerts/containers/alerts_table/default_columns'; +import { getRowActions } from '../pages/alerts/containers/alerts_table/get_row_actions'; import type { ObservabilityRuleTypeRegistry } from '../rules/create_observability_rule_type_registry'; import type { ConfigSchema } from '../plugin'; diff --git a/x-pack/plugins/observability/public/hooks/use_alert_bulk_case_actions.ts b/x-pack/plugins/observability/public/hooks/use_alert_bulk_case_actions.ts index 40219feb09c21e..6e4b915eccfe70 100644 --- a/x-pack/plugins/observability/public/hooks/use_alert_bulk_case_actions.ts +++ b/x-pack/plugins/observability/public/hooks/use_alert_bulk_case_actions.ts @@ -12,7 +12,7 @@ import { ADD_TO_CASE_DISABLED, ADD_TO_EXISTING_CASE, ADD_TO_NEW_CASE, -} from '../pages/alerts/containers/alerts_table_t_grid/translations'; +} from '../pages/alerts/containers/alerts_table/translations'; import { useGetUserCasesPermissions } from './use_get_user_cases_permissions'; import { ObservabilityAppServices } from '../application/types'; diff --git a/x-pack/plugins/observability/public/pages/alerts/components/default_cell_actions.tsx b/x-pack/plugins/observability/public/pages/alerts/components/default_cell_actions.tsx deleted file mode 100644 index 115fa703459b96..00000000000000 --- a/x-pack/plugins/observability/public/pages/alerts/components/default_cell_actions.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { TimelineNonEcsData } from '@kbn/timelines-plugin/common/search_strategy'; -import { TGridCellAction } from '@kbn/timelines-plugin/common/types/timeline'; -import { getPageRowIndex } from '@kbn/timelines-plugin/public'; -import FilterForValueButton from './filter_for_value'; -import { getMappedNonEcsValue } from './render_cell_value'; - -export const FILTER_FOR_VALUE = i18n.translate('xpack.observability.hoverActions.filterForValue', { - defaultMessage: 'Filter for value', -}); - -/** actions for adding filters to the search bar */ -const buildFilterCellActions = (addToQuery: (value: string) => void): TGridCellAction[] => [ - ({ data, pageSize }: { data: TimelineNonEcsData[][]; pageSize: number }) => - ({ rowIndex, columnId, Component }) => { - const value = getMappedNonEcsValue({ - data: data[getPageRowIndex(rowIndex, pageSize)], - fieldName: columnId, - }); - - return ( - - ); - }, -]; - -/** returns the default actions shown in `EuiDataGrid` cells */ -export const getDefaultCellActions = ({ addToQuery }: { addToQuery: (value: string) => void }) => - buildFilterCellActions(addToQuery); diff --git a/x-pack/plugins/observability/public/pages/alerts/components/index.ts b/x-pack/plugins/observability/public/pages/alerts/components/index.ts index 113e4b86c0e713..592ab16ddcadfb 100644 --- a/x-pack/plugins/observability/public/pages/alerts/components/index.ts +++ b/x-pack/plugins/observability/public/pages/alerts/components/index.ts @@ -10,7 +10,6 @@ export * from './render_cell_value'; export * from './severity_badge'; export * from './workflow_status_filter'; export * from './alerts_search_bar'; -export * from './default_cell_actions'; export * from './filter_for_value'; export * from './parse_alert'; export * from './alerts_status_filter'; diff --git a/x-pack/plugins/observability/public/pages/alerts/components/observability_actions.tsx b/x-pack/plugins/observability/public/pages/alerts/components/observability_actions.tsx index 8bed941ce1741d..0583b9a35eb645 100644 --- a/x-pack/plugins/observability/public/pages/alerts/components/observability_actions.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/components/observability_actions.tsx @@ -24,10 +24,7 @@ import { useKibana } from '../../../utils/kibana_react'; import { useGetUserCasesPermissions } from '../../../hooks/use_get_user_cases_permissions'; import { parseAlert } from './parse_alert'; import { translations, paths } from '../../../config'; -import { - ADD_TO_EXISTING_CASE, - ADD_TO_NEW_CASE, -} from '../containers/alerts_table_t_grid/translations'; +import { ADD_TO_EXISTING_CASE, ADD_TO_NEW_CASE } from '../containers/alerts_table/translations'; import { ObservabilityAppServices } from '../../../application/types'; import { RULE_DETAILS_PAGE_ID } from '../../rule_details/types'; import type { TopAlert } from '../containers/alerts_page/types'; diff --git a/x-pack/plugins/observability/public/pages/alerts/components/render_cell_value/index.ts b/x-pack/plugins/observability/public/pages/alerts/components/render_cell_value/index.ts index b6df77f0758887..009feb015eefd6 100644 --- a/x-pack/plugins/observability/public/pages/alerts/components/render_cell_value/index.ts +++ b/x-pack/plugins/observability/public/pages/alerts/components/render_cell_value/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { getRenderCellValue, getMappedNonEcsValue } from './render_cell_value'; +export { getRenderCellValue } from './render_cell_value'; diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/add_display_names.ts b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table/add_display_names.ts similarity index 90% rename from x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/add_display_names.ts rename to x-pack/plugins/observability/public/pages/alerts/containers/alerts_table/add_display_names.ts index ef36911a93530b..1f2efcbd6d7ea7 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/add_display_names.ts +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table/add_display_names.ts @@ -6,12 +6,10 @@ */ import { ALERT_DURATION, ALERT_REASON, ALERT_STATUS, TIMESTAMP } from '@kbn/rule-data-utils'; import { EuiDataGridColumn } from '@elastic/eui'; -import type { ColumnHeaderOptions } from '@kbn/timelines-plugin/common'; import { translations } from '../../../../config'; export const addDisplayNames = ( - column: Pick & - ColumnHeaderOptions + column: Pick ) => { if (column.id === ALERT_REASON) { return { ...column, displayAsText: translations.alertsTable.reasonColumnDescription }; diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table/default_columns.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table/default_columns.tsx new file mode 100644 index 00000000000000..4c187514d41de0 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table/default_columns.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * We need to produce types and code transpilation at different folders during the build of the package. + * We have types and code at different imports because we don't want to import the whole package in the resulting webpack bundle for the plugin. + * This way plugins can do targeted imports to reduce the final code bundle + */ +import { ALERT_DURATION, ALERT_REASON, ALERT_STATUS, TIMESTAMP } from '@kbn/rule-data-utils'; + +import { EuiDataGridColumn } from '@elastic/eui'; + +import type { ColumnHeaderOptions } from '@kbn/timelines-plugin/common'; + +import { translations } from '../../../../config'; + +/** + * columns implements a subset of `EuiDataGrid`'s `EuiDataGridColumn` interface, + * plus additional TGrid column properties + */ +export const columns: Array< + Pick & ColumnHeaderOptions +> = [ + { + columnHeaderType: 'not-filtered', + displayAsText: translations.alertsTable.statusColumnDescription, + id: ALERT_STATUS, + initialWidth: 110, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: translations.alertsTable.lastUpdatedColumnDescription, + id: TIMESTAMP, + initialWidth: 230, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: translations.alertsTable.durationColumnDescription, + id: ALERT_DURATION, + initialWidth: 116, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: translations.alertsTable.reasonColumnDescription, + id: ALERT_REASON, + linkField: '*', + }, +]; diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/get_row_actions.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table/get_row_actions.tsx similarity index 100% rename from x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/get_row_actions.tsx rename to x-pack/plugins/observability/public/pages/alerts/containers/alerts_table/get_row_actions.tsx diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/translations.ts b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table/translations.ts similarity index 100% rename from x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/translations.ts rename to x-pack/plugins/observability/public/pages/alerts/containers/alerts_table/translations.ts diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx deleted file mode 100644 index bcb2495d88b8fb..00000000000000 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx +++ /dev/null @@ -1,331 +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. - */ - -/** - * We need to produce types and code transpilation at different folders during the build of the package. - * We have types and code at different imports because we don't want to import the whole package in the resulting webpack bundle for the plugin. - * This way plugins can do targeted imports to reduce the final code bundle - */ -import { - ALERT_DURATION, - ALERT_EVALUATION_THRESHOLD, - ALERT_EVALUATION_VALUE, - ALERT_REASON, - ALERT_RULE_CATEGORY, - ALERT_RULE_NAME, - ALERT_STATUS, - ALERT_UUID, - TIMESTAMP, - ALERT_START, -} from '@kbn/rule-data-utils'; - -import { EuiDataGridColumn, EuiFlexGroup } from '@elastic/eui'; - -import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; - -import styled from 'styled-components'; -import React, { Suspense, useMemo, useState, useCallback, useEffect } from 'react'; - -import { pick } from 'lodash'; -import type { - TGridType, - TGridState, - TGridModel, - SortDirection, -} from '@kbn/timelines-plugin/public'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; -import type { - ActionProps, - ColumnHeaderOptions, - ControlColumnProps, - RowRenderer, -} from '@kbn/timelines-plugin/common'; -import { getAlertsPermissions } from '../../../../hooks/use_alert_permission'; - -import type { TopAlert } from '../alerts_page/types'; - -import { getRenderCellValue } from '../../components/render_cell_value'; -import { observabilityAppId, observabilityFeatureId } from '../../../../../common'; -import { useGetUserCasesPermissions } from '../../../../hooks/use_get_user_cases_permissions'; -import { usePluginContext } from '../../../../hooks/use_plugin_context'; -import { LazyAlertsFlyout } from '../../../..'; -import { translations } from '../../../../config'; -import { addDisplayNames } from './add_display_names'; -import { ObservabilityAppServices } from '../../../../application/types'; -import { useBulkAddToCaseActions } from '../../../../hooks/use_alert_bulk_case_actions'; -import { - ObservabilityActions, - ObservabilityActionsProps, -} from '../../components/observability_actions'; - -interface AlertsTableTGridProps { - indexNames: string[]; - rangeFrom: string; - rangeTo: string; - kuery?: string; - stateStorageKey: string; - storage: IStorageWrapper; - setRefetch: (ref: () => void) => void; - itemsPerPage?: number; -} - -const EventsThContent = styled.div.attrs(({ className = '' }) => ({ - className: `siemEventsTable__thContent ${className}`, -}))<{ textAlign?: string; width?: number }>` - font-size: ${({ theme }) => theme.eui.euiFontSizeXS}; - font-weight: ${({ theme }) => theme.eui.euiFontWeightBold}; - line-height: ${({ theme }) => theme.eui.euiLineHeight}; - min-width: 0; - padding: ${({ theme }) => theme.eui.euiSizeXS}; - text-align: ${({ textAlign }) => textAlign}; - width: ${({ width }) => - width != null - ? `${width}px` - : '100%'}; /* Using width: 100% instead of flex: 1 and max-width: 100% for IE11 */ - - > button.euiButtonIcon, - > .euiToolTipAnchor > button.euiButtonIcon { - margin-left: ${({ theme }) => `-${theme.eui.euiSizeXS}`}; - } -`; -/** - * columns implements a subset of `EuiDataGrid`'s `EuiDataGridColumn` interface, - * plus additional TGrid column properties - */ -export const columns: Array< - Pick & ColumnHeaderOptions -> = [ - { - columnHeaderType: 'not-filtered', - displayAsText: translations.alertsTable.statusColumnDescription, - id: ALERT_STATUS, - initialWidth: 110, - }, - { - columnHeaderType: 'not-filtered', - displayAsText: translations.alertsTable.lastUpdatedColumnDescription, - id: TIMESTAMP, - initialWidth: 230, - }, - { - columnHeaderType: 'not-filtered', - displayAsText: translations.alertsTable.durationColumnDescription, - id: ALERT_DURATION, - initialWidth: 116, - }, - { - columnHeaderType: 'not-filtered', - displayAsText: translations.alertsTable.reasonColumnDescription, - id: ALERT_REASON, - linkField: '*', - }, -]; - -const NO_ROW_RENDER: RowRenderer[] = []; - -const trailingControlColumns: never[] = []; - -const FIELDS_WITHOUT_CELL_ACTIONS = [ - '@timestamp', - 'signal.rule.risk_score', - 'signal.reason', - 'kibana.alert.duration.us', - 'kibana.alert.reason', -]; - -export function AlertsTableTGrid(props: AlertsTableTGridProps) { - const { - indexNames, - rangeFrom, - rangeTo, - kuery, - setRefetch, - stateStorageKey, - storage, - itemsPerPage, - } = props; - - const { - timelines, - application: { capabilities }, - } = useKibana().services; - const { observabilityRuleTypeRegistry, config } = usePluginContext(); - - const [flyoutAlert, setFlyoutAlert] = useState(undefined); - const [tGridState, setTGridState] = useState | null>( - storage.get(stateStorageKey) - ); - - const userCasesPermissions = useGetUserCasesPermissions(); - - const hasAlertsCrudPermissions = useCallback( - ({ ruleConsumer, ruleProducer }: { ruleConsumer: string; ruleProducer?: string }) => { - if (ruleConsumer === 'alerts' && ruleProducer) { - return getAlertsPermissions(capabilities, ruleProducer).crud; - } - return getAlertsPermissions(capabilities, ruleConsumer).crud; - }, - [capabilities] - ); - - const [deletedEventIds, setDeletedEventIds] = useState([]); - - useEffect(() => { - if (tGridState) { - const newState = { - ...tGridState, - columns: tGridState.columns?.map((c) => - pick(c, ['columnHeaderType', 'displayAsText', 'id', 'initialWidth', 'linkField']) - ), - }; - if (newState !== storage.get(stateStorageKey)) { - storage.set(stateStorageKey, newState); - } - } - }, [tGridState, stateStorageKey, storage]); - - const setEventsDeleted = useCallback((action) => { - if (action.isDeleted) { - setDeletedEventIds((ids) => [...ids, ...action.eventIds]); - } - }, []); - - const leadingControlColumns: ControlColumnProps[] = useMemo(() => { - return [ - { - id: 'expand', - width: 120, - headerCellRender: () => { - return {translations.alertsTable.actionsTextLabel}; - }, - rowCellRender: (actionProps: ActionProps) => { - return ( - - - - ); - }, - }, - ]; - }, [setEventsDeleted, observabilityRuleTypeRegistry, config]); - - const onStateChange = useCallback( - (state: TGridState) => { - const pickedState = pick(state.tableById['standalone-t-grid'], [ - 'columns', - 'sort', - 'selectedEventIds', - ]); - if (JSON.stringify(pickedState) !== JSON.stringify(tGridState)) { - setTGridState(pickedState); - } - }, - [tGridState] - ); - - const addToCaseBulkActions = useBulkAddToCaseActions(); - const bulkActions = useMemo( - () => ({ - alertStatusActions: false, - customBulkActions: addToCaseBulkActions, - }), - [addToCaseBulkActions] - ); - const tGridProps = useMemo(() => { - const type: TGridType = 'standalone'; - const sortDirection: SortDirection = 'desc'; - return { - appId: observabilityAppId, - casesOwner: observabilityFeatureId, - casePermissions: userCasesPermissions, - type, - columns: (tGridState?.columns ?? columns).map(addDisplayNames), - deletedEventIds, - disabledCellActions: FIELDS_WITHOUT_CELL_ACTIONS, - end: rangeTo, - filters: [], - hasAlertsCrudPermissions, - indexNames, - itemsPerPage, - itemsPerPageOptions: [10, 25, 50], - loadingText: translations.alertsTable.loadingTextLabel, - onStateChange, - query: { - query: kuery ?? '', - language: 'kuery', - }, - renderCellValue: getRenderCellValue({ setFlyoutAlert, observabilityRuleTypeRegistry }), - rowRenderers: NO_ROW_RENDER, - // TODO: implement Kibana data view runtime fields in observability - runtimeMappings: {}, - start: rangeFrom, - setRefetch, - bulkActions, - sort: tGridState?.sort ?? [ - { - columnId: '@timestamp', - columnType: 'date', - sortDirection, - }, - ], - queryFields: [ - ALERT_DURATION, - ALERT_EVALUATION_THRESHOLD, - ALERT_EVALUATION_VALUE, - ALERT_REASON, - ALERT_RULE_CATEGORY, - ALERT_RULE_NAME, - ALERT_STATUS, - ALERT_UUID, - ALERT_START, - TIMESTAMP, - ], - leadingControlColumns, - trailingControlColumns, - unit: (totalAlerts: number) => translations.alertsTable.showingAlertsTitle(totalAlerts), - }; - }, [ - userCasesPermissions, - tGridState?.columns, - tGridState?.sort, - deletedEventIds, - rangeTo, - hasAlertsCrudPermissions, - indexNames, - itemsPerPage, - observabilityRuleTypeRegistry, - onStateChange, - kuery, - rangeFrom, - setRefetch, - bulkActions, - leadingControlColumns, - ]); - - const handleFlyoutClose = () => setFlyoutAlert(undefined); - - return ( - <> - {flyoutAlert && ( - - - - )} - {timelines.getTGrid<'standalone'>(tGridProps)} - - ); -} diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/index.ts b/x-pack/plugins/observability/public/pages/alerts/containers/index.ts index 074f48f426640e..23b65105b7881d 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/index.ts +++ b/x-pack/plugins/observability/public/pages/alerts/containers/index.ts @@ -6,5 +6,4 @@ */ export * from './alerts_page'; -export * from './alerts_table_t_grid'; export * from './state_container'; diff --git a/x-pack/plugins/reporting/server/config/__snapshots__/schema.test.ts.snap b/x-pack/plugins/reporting/server/config/__snapshots__/schema.test.ts.snap index 690259a1c240e5..057f85d2c81d7d 100644 --- a/x-pack/plugins/reporting/server/config/__snapshots__/schema.test.ts.snap +++ b/x-pack/plugins/reporting/server/config/__snapshots__/schema.test.ts.snap @@ -36,7 +36,7 @@ Object { "pollEnabled": true, "pollInterval": "PT3S", "pollIntervalErrorMultiplier": 10, - "timeout": "PT2M", + "timeout": "PT4M", }, "roles": Object { "allow": Array [ @@ -82,7 +82,7 @@ Object { "pollEnabled": true, "pollInterval": "PT3S", "pollIntervalErrorMultiplier": 10, - "timeout": "PT2M", + "timeout": "PT4M", }, "roles": Object { "allow": Array [ diff --git a/x-pack/plugins/reporting/server/config/schema.ts b/x-pack/plugins/reporting/server/config/schema.ts index c5ec1a7c22c8d6..fc6ed9ae073908 100644 --- a/x-pack/plugins/reporting/server/config/schema.ts +++ b/x-pack/plugins/reporting/server/config/schema.ts @@ -42,7 +42,7 @@ const QueueSchema = schema.object({ }), pollIntervalErrorMultiplier: schema.number({ defaultValue: 10 }), timeout: schema.oneOf([schema.number(), schema.duration()], { - defaultValue: moment.duration({ minutes: 2 }), + defaultValue: moment.duration({ minutes: 4 }), }), }); diff --git a/x-pack/plugins/screenshotting/README.md b/x-pack/plugins/screenshotting/README.md index 3a3ea87448e647..04b7d07f223c48 100644 --- a/x-pack/plugins/screenshotting/README.md +++ b/x-pack/plugins/screenshotting/README.md @@ -89,9 +89,9 @@ Option | Required | Default | Description `layout` | no | `{}` | Page layout parameters describing characteristics of the capturing screenshot (e.g., dimensions, zoom, etc.). `request` | no | _none_ | Kibana Request reference to extract headers from. `timeouts` | no | _none_ | Timeouts for each phase of the screenshot. -`timeouts.openUrl` | no | `60000` | The timeout in milliseconds to allow the Chromium browser to wait for the "Loading…" screen to dismiss and find the initial data for the page. If the time is exceeded, a screenshot is captured showing the current page, and the result structure contains an error message. -`timeouts.renderComplete` | no | `30000` | The timeout in milliseconds to allow the Chromium browser to wait for all visualizations to fetch and render the data. If the time is exceeded, a screenshot is captured showing the current page, and the result structure contains an error message. -`timeouts.waitForElements` | no | `30000` | The timeout in milliseconds to allow the Chromium browser to wait for all visualization panels to load on the page. If the time is exceeded, a screenshot is captured showing the current page, and the result structure contains an error message. +`timeouts.openUrl` | no | (kibana.yml setting) | The timeout in milliseconds to allow the Chromium browser to wait for the "Loading…" screen to dismiss and find the initial data for the page. If the time is exceeded, a screenshot is captured showing the current page, and the result structure contains an error message. +`timeouts.renderComplete` | no | (kibana.yml setting) | The timeout in milliseconds to allow the Chromium browser to wait for all visualizations to fetch and render the data. If the time is exceeded, a screenshot is captured showing the current page, and the result structure contains an error message. +`timeouts.waitForElements` | no | (kibana.yml setting) | The timeout in milliseconds to allow the Chromium browser to wait for all visualization panels to load on the page. If the time is exceeded, a screenshot is captured showing the current page, and the result structure contains an error message. `urls` | no | `[]` | The list or URL to take screenshots of. Every item can either be a string or a tuple containing a URL and a context. The contextual data can be gathered using the screenshot mode plugin. Mutually exclusive with the `expression` parameter. #### `diagnose(flags?: string[]): Observable` diff --git a/x-pack/plugins/screenshotting/server/browsers/chromium/driver.ts b/x-pack/plugins/screenshotting/server/browsers/chromium/driver.ts index 3bdef53bc6a684..3cecb898890711 100644 --- a/x-pack/plugins/screenshotting/server/browsers/chromium/driver.ts +++ b/x-pack/plugins/screenshotting/server/browsers/chromium/driver.ts @@ -12,7 +12,7 @@ import { } from '@kbn/screenshot-mode-plugin/server'; import { truncate } from 'lodash'; import open from 'opn'; -import puppeteer, { ElementHandle, EvaluateFn, Page, SerializableOrJSHandle } from 'puppeteer'; +import puppeteer, { ElementHandle, Page, EvaluateFunc } from 'puppeteer'; import { Subject } from 'rxjs'; import { parse as parseUrl } from 'url'; import { getDisallowedOutgoingUrlError } from '.'; @@ -23,6 +23,16 @@ import { allowRequest } from '../network_policy'; import { stripUnsafeHeaders } from './strip_unsafe_headers'; import { getFooterTemplate, getHeaderTemplate } from './templates'; +declare module 'puppeteer' { + interface Page { + _client(): CDPSession; + } + + interface Target { + _targetId: string; + } +} + export type Context = Record; export interface ElementPosition { @@ -51,8 +61,8 @@ interface WaitForSelectorOpts { } interface EvaluateOpts { - fn: EvaluateFn; - args: SerializableOrJSHandle[]; + fn: EvaluateFunc; + args: unknown[]; } interface EvaluateMetaOpts { @@ -312,8 +322,8 @@ export class HeadlessChromiumDriver { args, timeout, }: { - fn: EvaluateFn; - args: SerializableOrJSHandle[]; + fn: EvaluateFunc; + args: unknown[]; timeout: number; }): Promise { await this.page.waitForFunction(fn, { timeout, polling: WAIT_FOR_DELAY_MS }, ...args); @@ -345,8 +355,8 @@ export class HeadlessChromiumDriver { return; } - // FIXME: retrieve the client in open() and pass in the client - const client = this.page.client(); + // FIXME: retrieve the client in open() and pass in the client? + const client = this.page._client(); // We have to reach into the Chrome Devtools Protocol to apply headers as using // puppeteer's API will cause map tile requests to hang indefinitely: @@ -437,12 +447,12 @@ export class HeadlessChromiumDriver { // In order to get the inspector running, we have to know the page's internal ID (again, private) // in order to construct the final debugging URL. + const client = this.page._client(); const target = this.page.target(); - const client = await target.createCDPSession(); + const targetId = target._targetId; await client.send('Debugger.enable'); await client.send('Debugger.pause'); - const targetId = target._targetId; const wsEndpoint = this.page.browser().wsEndpoint(); const { port } = parseUrl(wsEndpoint); diff --git a/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts b/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts index bc001470a8e6e7..984fa0470d216e 100644 --- a/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts +++ b/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts @@ -293,16 +293,14 @@ export class HeadlessChromiumDriverFactory { */ private async getErrorMessage(message: ConsoleMessage): Promise { for (const arg of message.args()) { - const errorMessage = await arg - .executionContext() - .evaluate((_arg: unknown) => { - /* !! We are now in the browser context !! */ - if (_arg instanceof Error) { - return _arg.message; - } - return undefined; - /* !! End of browser context !! */ - }, arg); + const errorMessage = await arg.evaluate((_arg: unknown) => { + /* !! We are now in the browser context !! */ + if (_arg instanceof Error) { + return _arg.message; + } + return undefined; + /* !! End of browser context !! */ + }, arg); if (errorMessage) { return errorMessage; } diff --git a/x-pack/plugins/screenshotting/server/browsers/chromium/paths.ts b/x-pack/plugins/screenshotting/server/browsers/chromium/paths.ts index 1a04574155c1e4..a04308c27dd8ec 100644 --- a/x-pack/plugins/screenshotting/server/browsers/chromium/paths.ts +++ b/x-pack/plugins/screenshotting/server/browsers/chromium/paths.ts @@ -14,11 +14,12 @@ export interface PackageInfo { archiveChecksum: string; binaryChecksum: string; binaryRelativePath: string; - revision: 901912 | 901913; isPreInstalled: boolean; location: 'custom' | 'common'; } +const REVISION = 1036745; + enum BaseUrl { // see https://www.chromium.org/getting-involved/download-chromium common = 'https://commondatastorage.googleapis.com/chromium-browser-snapshots', @@ -44,58 +45,53 @@ export class ChromiumArchivePaths { platform: 'darwin', architecture: 'x64', archiveFilename: 'chrome-mac.zip', - archiveChecksum: '229fd88c73c5878940821875f77578e4', - binaryChecksum: 'b0e5ca009306b14e41527000139852e5', + archiveChecksum: '5afc0d49865d55b69ea1ff65b9cc5794', + binaryChecksum: '4a7a663b2656d66ce975b00a30df3ab4', binaryRelativePath: 'chrome-mac/Chromium.app/Contents/MacOS/Chromium', location: 'common', archivePath: 'Mac', - revision: 901912, isPreInstalled: false, }, { platform: 'darwin', architecture: 'arm64', archiveFilename: 'chrome-mac.zip', - archiveChecksum: 'ecf7aa509c8e2545989ebb9711e35384', - binaryChecksum: 'b5072b06ffd2d2af4fea7012914da09f', + archiveChecksum: '5afc0d49865d55b69ea1ff65b9cc5794', + binaryChecksum: '4a7a663b2656d66ce975b00a30df3ab4', binaryRelativePath: 'chrome-mac/Chromium.app/Contents/MacOS/Chromium', location: 'common', archivePath: 'Mac_Arm', - revision: 901913, // NOTE: 901912 is not available isPreInstalled: false, }, { platform: 'linux', architecture: 'x64', - archiveFilename: 'chromium-70f5d88-locales-linux_x64.zip', - archiveChecksum: '759bda5e5d32533cb136a85e37c0d102', - binaryChecksum: '82e80f9727a88ba3836ce230134bd126', + archiveFilename: 'chromium-749e738-locales-linux_x64.zip', + archiveChecksum: '09ba194e6c720397728fbec3d3895b0b', + binaryChecksum: 'df1c957f41dcca8e33369b1d255406c2', binaryRelativePath: 'headless_shell-linux_x64/headless_shell', location: 'custom', - revision: 901912, isPreInstalled: true, }, { platform: 'linux', architecture: 'arm64', - archiveFilename: 'chromium-70f5d88-locales-linux_arm64.zip', - archiveChecksum: '33613b8dc5212c0457210d5a37ea4b43', - binaryChecksum: '29e943fbee6d87a217abd6cb6747058e', + archiveFilename: 'chromium-749e738-locales-linux_arm64.zip', + archiveChecksum: '1f535b1c2875d471829c6ff128a13262', + binaryChecksum: 'ca6b91d0ba8a65712554572dabc66968', binaryRelativePath: 'headless_shell-linux_arm64/headless_shell', location: 'custom', - revision: 901912, isPreInstalled: true, }, { platform: 'win32', architecture: 'x64', archiveFilename: 'chrome-win.zip', - archiveChecksum: '861bb8b7b8406a6934a87d3cbbce61d9', - binaryChecksum: 'ffa0949471e1b9a57bc8f8633fca9c7b', + archiveChecksum: '42db052673414b89d8cb45657c1a6aeb', + binaryChecksum: '1b6eef775198ffd48fb9669ac0c818f7', binaryRelativePath: path.join('chrome-win', 'chrome.exe'), location: 'common', archivePath: 'Win', - revision: 901912, isPreInstalled: true, }, ]; @@ -118,7 +114,7 @@ export class ChromiumArchivePaths { public getDownloadUrl(p: PackageInfo) { if (isCommonPackage(p)) { - return `${BaseUrl.common}/${p.archivePath}/${p.revision}/${p.archiveFilename}`; + return `${BaseUrl.common}/${p.archivePath}/${REVISION}/${p.archiveFilename}`; } return BaseUrl.custom + '/' + p.archiveFilename; // revision is not used for URL if package is a custom build } diff --git a/x-pack/plugins/screenshotting/server/browsers/download/index.test.ts b/x-pack/plugins/screenshotting/server/browsers/download/index.test.ts index 74a80cf10b58bf..21f55bfc93e936 100644 --- a/x-pack/plugins/screenshotting/server/browsers/download/index.test.ts +++ b/x-pack/plugins/screenshotting/server/browsers/download/index.test.ts @@ -88,11 +88,11 @@ describe('ensureDownloaded', () => { expect.arrayContaining([ 'chrome-mac.zip', 'chrome-win.zip', - 'chromium-70f5d88-locales-linux_x64.zip', + 'chromium-749e738-locales-linux_x64.zip', ]) ); expect(readdirSync(path.resolve(`${paths.archivesPath}/arm64`))).toEqual( - expect.arrayContaining(['chrome-mac.zip', 'chromium-70f5d88-locales-linux_arm64.zip']) + expect.arrayContaining(['chrome-mac.zip', 'chromium-749e738-locales-linux_arm64.zip']) ); }); diff --git a/x-pack/plugins/screenshotting/server/config/schema.test.ts b/x-pack/plugins/screenshotting/server/config/schema.test.ts index c2febf59062490..bb68e8e938acec 100644 --- a/x-pack/plugins/screenshotting/server/config/schema.test.ts +++ b/x-pack/plugins/screenshotting/server/config/schema.test.ts @@ -22,8 +22,8 @@ describe('ConfigSchema', () => { "capture": Object { "timeouts": Object { "openUrl": "PT1M", - "renderComplete": "PT30S", - "waitForElements": "PT30S", + "renderComplete": "PT2M", + "waitForElements": "PT1M", }, "zoom": 2, }, @@ -82,8 +82,8 @@ describe('ConfigSchema', () => { "capture": Object { "timeouts": Object { "openUrl": "PT1M", - "renderComplete": "PT30S", - "waitForElements": "PT30S", + "renderComplete": "PT2M", + "waitForElements": "PT1M", }, "zoom": 2, }, diff --git a/x-pack/plugins/screenshotting/server/config/schema.ts b/x-pack/plugins/screenshotting/server/config/schema.ts index 4900a5c9d775e5..724f84ea3c8116 100644 --- a/x-pack/plugins/screenshotting/server/config/schema.ts +++ b/x-pack/plugins/screenshotting/server/config/schema.ts @@ -50,7 +50,7 @@ export const ConfigSchema = schema.object({ schema.boolean({ defaultValue: false }), schema.maybe(schema.never()) ), - disableSandbox: schema.maybe(schema.boolean()), // default value is dynamic in createConfig$ + disableSandbox: schema.maybe(schema.boolean()), // default value is dynamic in createConfig proxy: schema.object({ enabled: schema.boolean({ defaultValue: false }), server: schema.conditional( @@ -74,10 +74,10 @@ export const ConfigSchema = schema.object({ defaultValue: moment.duration({ minutes: 1 }), }), waitForElements: schema.oneOf([schema.number(), schema.duration()], { - defaultValue: moment.duration({ seconds: 30 }), + defaultValue: moment.duration({ minutes: 1 }), }), renderComplete: schema.oneOf([schema.number(), schema.duration()], { - defaultValue: moment.duration({ seconds: 30 }), + defaultValue: moment.duration({ minutes: 2 }), }), }), zoom: schema.number({ defaultValue: 2 }), diff --git a/x-pack/plugins/screenshotting/server/screenshots/index.ts b/x-pack/plugins/screenshotting/server/screenshots/index.ts index 0c6c6f409f848a..c45a0583ffe724 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/index.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/index.ts @@ -201,8 +201,8 @@ export class Screenshots { { timeouts: { openUrl: 60000, - waitForElements: 30000, - renderComplete: 30000, + waitForElements: 60000, + renderComplete: 120000, }, urls: [], } diff --git a/x-pack/plugins/security/public/components/use_badge.test.tsx b/x-pack/plugins/security/public/components/use_badge.test.tsx new file mode 100644 index 00000000000000..f5d3c28e5f0b28 --- /dev/null +++ b/x-pack/plugins/security/public/components/use_badge.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; + +import { coreMock } from '@kbn/core/public/mocks'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; + +import type { ChromeBadge } from './use_badge'; +import { useBadge } from './use_badge'; + +describe('useBadge', () => { + it('should add badge to chrome', async () => { + const coreStart = coreMock.createStart(); + const badge: ChromeBadge = { + text: 'text', + tooltip: 'text', + }; + renderHook(useBadge, { + initialProps: badge, + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(coreStart.chrome.setBadge).toHaveBeenLastCalledWith(badge); + }); + + it('should remove badge from chrome on unmount', async () => { + const coreStart = coreMock.createStart(); + const badge: ChromeBadge = { + text: 'text', + tooltip: 'text', + }; + const { unmount } = renderHook(useBadge, { + initialProps: badge, + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(coreStart.chrome.setBadge).toHaveBeenLastCalledWith(badge); + + unmount(); + + expect(coreStart.chrome.setBadge).toHaveBeenLastCalledWith(); + }); + + it('should update chrome when badge changes', async () => { + const coreStart = coreMock.createStart(); + const badge1: ChromeBadge = { + text: 'text', + tooltip: 'text', + }; + const { rerender } = renderHook(useBadge, { + initialProps: badge1, + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(coreStart.chrome.setBadge).toHaveBeenLastCalledWith(badge1); + + const badge2: ChromeBadge = { + text: 'text2', + tooltip: 'text2', + }; + rerender(badge2); + + expect(coreStart.chrome.setBadge).toHaveBeenLastCalledWith(badge2); + }); +}); diff --git a/x-pack/plugins/security/public/components/use_badge.ts b/x-pack/plugins/security/public/components/use_badge.ts new file mode 100644 index 00000000000000..cd5a8d3620a2fc --- /dev/null +++ b/x-pack/plugins/security/public/components/use_badge.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DependencyList } from 'react'; +import { useEffect } from 'react'; + +import type { ChromeBadge } from '@kbn/core-chrome-browser'; +import type { CoreStart } from '@kbn/core/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; + +export type { ChromeBadge }; + +/** + * Renders a badge in the Kibana chrome. + * @param badge Params of the badge or `undefined` to render no badge. + * @param badge.iconType Icon type of the badge shown in the Kibana chrome. + * @param badge.text Title of tooltip displayed when hovering the badge. + * @param badge.tooltip Description of tooltip displayed when hovering the badge. + * @param deps If present, badge will be updated or removed if the values in the list change. + */ +export function useBadge( + badge: ChromeBadge | undefined, + deps: DependencyList = [badge?.iconType, badge?.text, badge?.tooltip] +) { + const { services } = useKibana(); + + useEffect(() => { + if (badge) { + services.chrome.setBadge(badge); + return () => services.chrome.setBadge(); + } + }, deps); // eslint-disable-line react-hooks/exhaustive-deps +} diff --git a/x-pack/plugins/security/public/components/use_capabilities.test.tsx b/x-pack/plugins/security/public/components/use_capabilities.test.tsx new file mode 100644 index 00000000000000..b5eca83a8d53e2 --- /dev/null +++ b/x-pack/plugins/security/public/components/use_capabilities.test.tsx @@ -0,0 +1,47 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; + +import { coreMock } from '@kbn/core/public/mocks'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; + +import { useCapabilities } from './use_capabilities'; + +describe('useCapabilities', () => { + it('should return capabilities', async () => { + const coreStart = coreMock.createStart(); + + const { result } = renderHook(useCapabilities, { + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(result.current).toEqual(coreStart.application.capabilities); + }); + + it('should return capabilities scoped by feature', async () => { + const coreStart = coreMock.createStart(); + coreStart.application.capabilities = { + ...coreStart.application.capabilities, + users: { + save: true, + }, + }; + + const { result } = renderHook(useCapabilities, { + initialProps: 'users', + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(result.current).toEqual({ save: true }); + }); +}); diff --git a/x-pack/plugins/security/public/components/use_capabilities.ts b/x-pack/plugins/security/public/components/use_capabilities.ts new file mode 100644 index 00000000000000..cdf54e2700a526 --- /dev/null +++ b/x-pack/plugins/security/public/components/use_capabilities.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Capabilities } from '@kbn/core-capabilities-common'; +import type { CoreStart } from '@kbn/core/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; + +type FeatureCapabilities = Capabilities[string]; + +/** + * Returns capabilities for a specific feature, or alternatively the entire capabilities object. + * @param featureId ID of feature + */ +export function useCapabilities(): Capabilities; +export function useCapabilities( + featureId: string +): T; +export function useCapabilities( + featureId?: string +) { + const { services } = useKibana(); + + if (featureId) { + return services.application.capabilities[featureId] as T; + } + + return services.application.capabilities; +} diff --git a/x-pack/plugins/security/public/management/badges/readonly_badge.tsx b/x-pack/plugins/security/public/management/badges/readonly_badge.tsx new file mode 100644 index 00000000000000..9f41ed350e158b --- /dev/null +++ b/x-pack/plugins/security/public/management/badges/readonly_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 { i18n } from '@kbn/i18n'; + +import { useBadge } from '../../components/use_badge'; +import { useCapabilities } from '../../components/use_capabilities'; + +export interface ReadonlyBadgeProps { + featureId: string; + tooltip: string; +} + +export const ReadonlyBadge = ({ featureId, tooltip }: ReadonlyBadgeProps) => { + const { save } = useCapabilities(featureId); + useBadge( + save + ? undefined + : { + iconType: 'glasses', + text: i18n.translate('xpack.security.management.readonlyBadge.text', { + defaultMessage: 'Read only', + }), + tooltip, + }, + [save] + ); + return null; +}; diff --git a/x-pack/plugins/security/public/management/users/edit_user/create_user_page.test.tsx b/x-pack/plugins/security/public/management/users/edit_user/create_user_page.test.tsx index 525df66251057d..329b4bfc28b54f 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/create_user_page.test.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/create_user_page.test.tsx @@ -22,12 +22,26 @@ jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ describe('CreateUserPage', () => { jest.setTimeout(15_000); + const coreStart = coreMock.createStart(); const theme$ = themeServiceMock.createTheme$(); + let history = createMemoryHistory({ initialEntries: ['/create'] }); + const authc = securityMock.createSetup().authc; + + beforeEach(() => { + history = createMemoryHistory({ initialEntries: ['/create'] }); + authc.getCurrentUser.mockClear(); + coreStart.http.delete.mockClear(); + coreStart.http.get.mockClear(); + coreStart.http.post.mockClear(); + coreStart.application.capabilities = { + ...coreStart.application.capabilities, + users: { + save: true, + }, + }; + }); it('creates user when submitting form and redirects back', async () => { - const coreStart = coreMock.createStart(); - const history = createMemoryHistory({ initialEntries: ['/create'] }); - const authc = securityMock.createSetup().authc; coreStart.http.post.mockResolvedValue({}); const { findByRole, findByLabelText } = render( @@ -57,11 +71,26 @@ describe('CreateUserPage', () => { }); }); - it('validates form', async () => { - const coreStart = coreMock.createStart(); - const history = createMemoryHistory({ initialEntries: ['/create'] }); - const authc = securityMock.createSetup().authc; + it('redirects back when viewing with readonly privileges', async () => { + coreStart.application.capabilities = { + ...coreStart.application.capabilities, + users: { + save: false, + }, + }; + render( + + + + ); + + await waitFor(() => { + expect(history.location.pathname).toBe('/'); + }); + }); + + it('validates form', async () => { coreStart.http.get.mockResolvedValueOnce([]); coreStart.http.get.mockResolvedValueOnce([ { diff --git a/x-pack/plugins/security/public/management/users/edit_user/create_user_page.tsx b/x-pack/plugins/security/public/management/users/edit_user/create_user_page.tsx index 52b2988ca5f83a..d72732cfd99ed2 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/create_user_page.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/create_user_page.tsx @@ -7,17 +7,25 @@ import { EuiPageHeader, EuiSpacer } from '@elastic/eui'; import type { FunctionComponent } from 'react'; -import React from 'react'; +import React, { useEffect } from 'react'; import { useHistory } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n-react'; +import { useCapabilities } from '../../../components/use_capabilities'; import { UserForm } from './user_form'; export const CreateUserPage: FunctionComponent = () => { const history = useHistory(); + const readOnly = !useCapabilities('users').save; const backToUsers = () => history.push('/'); + useEffect(() => { + if (readOnly) { + backToUsers(); + } + }, [readOnly]); // eslint-disable-line react-hooks/exhaustive-deps + return ( <> { coreStart.http.post.mockClear(); coreStart.notifications.toasts.addDanger.mockClear(); coreStart.notifications.toasts.addSuccess.mockClear(); + coreStart.application.capabilities = { + ...coreStart.application.capabilities, + users: { + save: true, + }, + }; }); it('warns when viewing deactivated user', async () => { @@ -125,4 +131,29 @@ describe('EditUserPage', () => { await findByText(/Role .deprecated_role. is deprecated. Use .new_role. instead/i); }); + + it('disables form when viewing with readonly privileges', async () => { + coreStart.http.get.mockResolvedValueOnce(userMock); + coreStart.http.get.mockResolvedValueOnce([]); + coreStart.application.capabilities = { + ...coreStart.application.capabilities, + users: { + save: false, + }, + }; + + const { findByRole, findAllByRole } = render( + + + + ); + + await findByRole('button', { name: 'Back to users' }); + + const fields = await findAllByRole('textbox'); + expect(fields.length).toBeGreaterThanOrEqual(1); + fields.forEach((field) => { + expect(field).toHaveProperty('disabled', true); + }); + }); }); diff --git a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx index bdbae4dc22a1c6..9a3c3f8153b8a0 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx @@ -30,6 +30,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { getUserDisplayName } from '../../../../common/model'; +import { useCapabilities } from '../../../components/use_capabilities'; import { UserAPIClient } from '../user_api_client'; import { isUserDeprecated, isUserReserved } from '../user_utils'; import { ChangePasswordModal } from './change_password_modal'; @@ -57,6 +58,7 @@ export const EditUserPage: FunctionComponent = ({ username }) [services.http] ); const [action, setAction] = useState('none'); + const readOnly = !useCapabilities('users').save; const backToUsers = () => history.push('/'); @@ -155,181 +157,186 @@ export const EditUserPage: FunctionComponent = ({ username }) defaultValues={user} onCancel={backToUsers} onSuccess={backToUsers} + disabled={readOnly} /> - {action === 'changePassword' ? ( - setAction('none')} - onSuccess={() => setAction('none')} - /> - ) : action === 'disableUser' ? ( - setAction('none')} - onSuccess={() => { - setAction('none'); - getUser(); - }} - /> - ) : action === 'enableUser' ? ( - setAction('none')} - onSuccess={() => { - setAction('none'); - getUser(); - }} - /> - ) : action === 'deleteUser' ? ( - setAction('none')} - onSuccess={backToUsers} - /> - ) : undefined} - - - - - - - - - - - - - - - - - - setAction('changePassword')} - size="s" - data-test-subj="editUserChangePasswordButton" - > - - - - - - - - {user.enabled === false ? ( - - - - - - - - - - - - - - setAction('enableUser')} - size="s" - data-test-subj="editUserEnableUserButton" - > - - - - - - ) : ( - - - - - - - - - - - - - - setAction('disableUser')} - size="s" - data-test-subj="editUserDisableUserButton" - > - - - - - - )} - - {!isReservedUser && ( + {readOnly ? undefined : ( <> + {action === 'changePassword' ? ( + setAction('none')} + onSuccess={() => setAction('none')} + /> + ) : action === 'disableUser' ? ( + setAction('none')} + onSuccess={() => { + setAction('none'); + getUser(); + }} + /> + ) : action === 'enableUser' ? ( + setAction('none')} + onSuccess={() => { + setAction('none'); + getUser(); + }} + /> + ) : action === 'deleteUser' ? ( + setAction('none')} + onSuccess={backToUsers} + /> + ) : undefined} + + + setAction('deleteUser')} + onClick={() => setAction('changePassword')} size="s" - color="danger" - data-test-subj="editUserDeleteUserButton" + data-test-subj="editUserChangePasswordButton" > + + + {user.enabled === false ? ( + + + + + + + + + + + + + + setAction('enableUser')} + size="s" + data-test-subj="editUserEnableUserButton" + > + + + + + + ) : ( + + + + + + + + + + + + + + setAction('disableUser')} + size="s" + data-test-subj="editUserDisableUserButton" + > + + + + + + )} + + {!isReservedUser && ( + <> + + + + + + + + + + + + + + + setAction('deleteUser')} + size="s" + color="danger" + data-test-subj="editUserDeleteUserButton" + > + + + + + + + )} )} diff --git a/x-pack/plugins/security/public/management/users/edit_user/user_form.tsx b/x-pack/plugins/security/public/management/users/edit_user/user_form.tsx index 83cf8dae894160..41c29ab7738683 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/user_form.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/user_form.tsx @@ -56,6 +56,7 @@ export interface UserFormProps { defaultValues?: UserFormValues; onCancel(): void; onSuccess?(): void; + disabled?: boolean; } const defaultDefaultValues: UserFormValues = { @@ -73,6 +74,7 @@ export const UserForm: FunctionComponent = ({ defaultValues = defaultDefaultValues, onSuccess, onCancel, + disabled = false, }) => { const { services } = useKibana(); @@ -269,7 +271,7 @@ export const UserForm: FunctionComponent = ({ value={form.values.username} isLoading={form.isValidating} isInvalid={form.touched.username && !!form.errors.username} - disabled={!isNewUser} + disabled={disabled || !isNewUser} onChange={eventHandlers.onChange} onBlur={eventHandlers.onBlur} /> @@ -291,6 +293,7 @@ export const UserForm: FunctionComponent = ({ isInvalid={form.touched.full_name && !!form.errors.full_name} onChange={eventHandlers.onChange} onBlur={eventHandlers.onBlur} + disabled={disabled} /> = ({ isInvalid={form.touched.email && !!form.errors.email} onChange={eventHandlers.onChange} onBlur={eventHandlers.onBlur} + disabled={disabled} /> @@ -349,6 +353,7 @@ export const UserForm: FunctionComponent = ({ autoComplete="new-password" onChange={eventHandlers.onChange} onBlur={eventHandlers.onBlur} + disabled={disabled} /> = ({ autoComplete="new-password" onChange={eventHandlers.onChange} onBlur={eventHandlers.onBlur} + disabled={disabled} /> @@ -423,12 +429,12 @@ export const UserForm: FunctionComponent = ({ selectedRoleNames={selectedRoleNames} onChange={(value) => form.setValue('roles', value)} isLoading={rolesState.loading} - isDisabled={isReservedUser} + isDisabled={disabled || isReservedUser} /> - {isReservedUser ? ( + {disabled || isReservedUser ? ( diff --git a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx index 99de5391c04163..3c133b3628b436 100644 --- a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx +++ b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx @@ -31,7 +31,7 @@ describe('UsersGridPage', () => { coreStart = coreMock.createStart(); }); - it('renders the list of users', async () => { + it('renders the list of users and create button', async () => { const apiClientMock = userAPIClientMock.create(); apiClientMock.getUsers.mockImplementation(() => { return Promise.resolve([ @@ -71,6 +71,7 @@ describe('UsersGridPage', () => { expect(wrapper.find('EuiInMemoryTable')).toHaveLength(1); expect(wrapper.find('EuiTableRow')).toHaveLength(2); expect(findTestSubject(wrapper, 'userDisabled')).toHaveLength(0); + expect(findTestSubject(wrapper, 'createUserButton')).toHaveLength(1); }); it('renders the loading indication on the table when fetching user with data', async () => { @@ -375,6 +376,46 @@ describe('UsersGridPage', () => { }, ]); }); + + it('hides controls when `readOnly` is enabled', async () => { + const apiClientMock = userAPIClientMock.create(); + apiClientMock.getUsers.mockImplementation(() => { + return Promise.resolve([ + { + username: 'foo', + email: 'foo@bar.net', + full_name: 'foo bar', + roles: ['kibana_user'], + enabled: true, + }, + { + username: 'reserved', + email: 'reserved@bar.net', + full_name: '', + roles: ['superuser'], + enabled: true, + metadata: { + _reserved: true, + }, + }, + ]); + }); + + const wrapper = mountWithIntl( + + ); + + await waitForRender(wrapper); + + expect(findTestSubject(wrapper, 'createUserButton')).toHaveLength(0); + }); }); async function waitForRender(wrapper: ReactWrapper) { diff --git a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.tsx b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.tsx index 748cd8527742b4..0649de83749cd3 100644 --- a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.tsx +++ b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.tsx @@ -40,6 +40,7 @@ interface Props { notifications: NotificationsStart; history: ScopedHistory; navigateToApp: ApplicationStart['navigateToApp']; + readOnly?: boolean; } interface State { @@ -55,6 +56,10 @@ interface State { } export class UsersGridPage extends Component { + static defaultProps: Partial = { + readOnly: false, + }; + constructor(props: Props) { super(props); this.state = { @@ -69,7 +74,6 @@ export class UsersGridPage extends Component { isTableLoading: false, }; } - public componentDidMount() { this.loadUsersAndRoles(); } @@ -231,19 +235,23 @@ export class UsersGridPage extends Component { defaultMessage="Users" /> } - rightSideItems={[ - - - , - ]} + rightSideItems={ + this.props.readOnly + ? undefined + : [ + + + , + ] + } /> @@ -266,7 +274,7 @@ export class UsersGridPage extends Component { })} rowHeader="username" columns={columns} - selection={selectionConfig} + selection={this.props.readOnly ? undefined : selectionConfig} pagination={pagination} items={this.state.visibleUsers} loading={this.state.isTableLoading} diff --git a/x-pack/plugins/security/public/management/users/users_management_app.test.tsx b/x-pack/plugins/security/public/management/users/users_management_app.test.tsx index ec7b1d19226ba0..dd5495cd8bd1d5 100644 --- a/x-pack/plugins/security/public/management/users/users_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/users/users_management_app.test.tsx @@ -22,9 +22,14 @@ describe('usersManagementApp', () => { const coreStartMock = coreMock.createStart(); getStartServices.mockResolvedValue([coreStartMock, {}, {}]); const { authc } = securityMock.createSetup(); - const setBreadcrumbs = jest.fn(); const history = scopedHistoryMock.create({ pathname: '/create' }); + coreStartMock.application.capabilities = { + ...coreStartMock.application.capabilities, + users: { + save: true, + }, + }; let unmount: Unmount = noop; await act(async () => { diff --git a/x-pack/plugins/security/public/management/users/users_management_app.tsx b/x-pack/plugins/security/public/management/users/users_management_app.tsx index de7a4110a3f3fd..a8a13bb72dc6a9 100644 --- a/x-pack/plugins/security/public/management/users/users_management_app.tsx +++ b/x-pack/plugins/security/public/management/users/users_management_app.tsx @@ -28,6 +28,7 @@ import { } from '../../components/breadcrumb'; import { AuthenticationProvider } from '../../components/use_current_user'; import type { PluginStartDependencies } from '../../plugin'; +import { ReadonlyBadge } from '../badges/readonly_badge'; import { tryDecodeURIComponent } from '../url_utils'; interface CreateParams { @@ -72,6 +73,12 @@ export const usersManagementApp = Object.freeze({ authc={authc} onChange={createBreadcrumbsChangeHandler(coreStart.chrome, setBreadcrumbs)} > + diff --git a/x-pack/plugins/security/server/features/security_features.ts b/x-pack/plugins/security/server/features/security_features.ts index b741d8091518db..396f2d1640e1f7 100644 --- a/x-pack/plugins/security/server/features/security_features.ts +++ b/x-pack/plugins/security/server/features/security_features.ts @@ -16,6 +16,10 @@ const userManagementFeature: ElasticsearchFeatureConfig = { privileges: [ { requiredClusterPrivileges: ['manage_security'], + ui: ['save'], + }, + { + requiredClusterPrivileges: ['read_security'], ui: [], }, ], diff --git a/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts index a268c2e0c8f25f..76d90f23a2e837 100644 --- a/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts @@ -25,7 +25,7 @@ import { userProfileMock } from '../../common/model/user_profile.mock'; import { authorizationMock } from '../authorization/index.mock'; import { securityMock } from '../mocks'; import { sessionMock } from '../session_management/session.mock'; -import { UserProfileService } from './user_profile_service'; +import { prefixCommaSeparatedValues, UserProfileService } from './user_profile_service'; const logger = loggingSystemMock.createLogger(); describe('UserProfileService', () => { @@ -245,7 +245,7 @@ describe('UserProfileService', () => { } as unknown as SecurityGetUserProfileResponse); const startContract = userProfileService.start(mockStartParams); - await expect(startContract.getCurrent({ request: mockRequest, dataPath: '*' })).resolves + await expect(startContract.getCurrent({ request: mockRequest, dataPath: 'one,two' })).resolves .toMatchInlineSnapshot(` Object { "data": Object { @@ -277,7 +277,7 @@ describe('UserProfileService', () => { mockStartParams.clusterClient.asInternalUser.security.getUserProfile ).toHaveBeenCalledWith({ uid: 'UID', - data: 'kibana.*', + data: 'kibana.one,kibana.two', }); }); }); @@ -556,8 +556,8 @@ describe('UserProfileService', () => { } as unknown as SecurityGetUserProfileResponse); const startContract = userProfileService.start(mockStartParams); - await expect(startContract.bulkGet({ uids: new Set(['UID-1']), dataPath: '*' })).resolves - .toMatchInlineSnapshot(` + await expect(startContract.bulkGet({ uids: new Set(['UID-1']), dataPath: 'one,two' })) + .resolves.toMatchInlineSnapshot(` Array [ Object { "data": Object { @@ -580,7 +580,7 @@ describe('UserProfileService', () => { mockStartParams.clusterClient.asInternalUser.security.getUserProfile ).toHaveBeenCalledWith({ uid: 'UID-1', - data: 'kibana.*', + data: 'kibana.one,kibana.two', }); }); @@ -683,7 +683,7 @@ describe('UserProfileService', () => { } as unknown as SecuritySuggestUserProfilesResponse); const startContract = userProfileService.start(mockStartParams); - await expect(startContract.suggest({ name: 'some', dataPath: '*' })).resolves + await expect(startContract.suggest({ name: 'some', dataPath: 'one,two' })).resolves .toMatchInlineSnapshot(` Array [ Object { @@ -708,7 +708,44 @@ describe('UserProfileService', () => { ).toHaveBeenCalledWith({ name: 'some', size: 10, - data: 'kibana.*', + data: 'kibana.one,kibana.two', + }); + expect(mockAuthz.checkUserProfilesPrivileges).not.toHaveBeenCalled(); + }); + + it('should request data if uid hints are specified', async () => { + mockStartParams.clusterClient.asInternalUser.security.suggestUserProfiles.mockResolvedValue({ + profiles: [ + userProfileMock.createWithSecurity({ + uid: 'UID-1', + }), + ], + } as unknown as SecuritySuggestUserProfilesResponse); + + const startContract = userProfileService.start(mockStartParams); + await expect(startContract.suggest({ hint: { uids: ['UID-1'] } })).resolves + .toMatchInlineSnapshot(` + Array [ + Object { + "data": Object {}, + "enabled": true, + "uid": "UID-1", + "user": Object { + "email": "some@email", + "full_name": undefined, + "username": "some-username", + }, + }, + ] + `); + expect( + mockStartParams.clusterClient.asInternalUser.security.suggestUserProfiles + ).toHaveBeenCalledTimes(1); + expect( + mockStartParams.clusterClient.asInternalUser.security.suggestUserProfiles + ).toHaveBeenCalledWith({ + size: 10, + hint: { uids: ['UID-1'] }, }); expect(mockAuthz.checkUserProfilesPrivileges).not.toHaveBeenCalled(); }); @@ -788,7 +825,7 @@ describe('UserProfileService', () => { startContract.suggest({ name: 'some', size: 3, - dataPath: '*', + dataPath: 'one,two', requiredPrivileges: { spaceId: 'some-space', privileges: { kibana: ['privilege-1', 'privilege-2'] }, @@ -842,7 +879,7 @@ describe('UserProfileService', () => { ).toHaveBeenCalledWith({ name: 'some', size: 10, - data: 'kibana.*', + data: 'kibana.one,kibana.two', }); expect(mockAuthz.checkUserProfilesPrivileges).toHaveBeenCalledTimes(1); @@ -891,7 +928,7 @@ describe('UserProfileService', () => { startContract.suggest({ name: 'some', size: 11, - dataPath: '*', + dataPath: 'one,two', requiredPrivileges: { spaceId: 'some-space', privileges: { kibana: ['privilege-1', 'privilege-2'] }, @@ -933,7 +970,7 @@ describe('UserProfileService', () => { ).toHaveBeenCalledWith({ name: 'some', size: 22, - data: 'kibana.*', + data: 'kibana.one,kibana.two', }); expect(mockAuthz.checkUserProfilesPrivileges).toHaveBeenCalledTimes(3); @@ -992,7 +1029,7 @@ describe('UserProfileService', () => { startContract.suggest({ name: 'some', size: 2, - dataPath: '*', + dataPath: 'one,two', requiredPrivileges: { spaceId: 'some-space', privileges: { kibana: ['privilege-1', 'privilege-2'] }, @@ -1034,7 +1071,7 @@ describe('UserProfileService', () => { ).toHaveBeenCalledWith({ name: 'some', size: 10, - data: 'kibana.*', + data: 'kibana.one,kibana.two', }); expect(mockAuthz.checkUserProfilesPrivileges).toHaveBeenCalledTimes(1); @@ -1049,3 +1086,19 @@ describe('UserProfileService', () => { }); }); }); + +describe('prefixCommaSeparatedValues', () => { + it('should prefix each value', () => { + expect(prefixCommaSeparatedValues('one,two,three', '_')).toBe('_.one,_.two,_.three'); + }); + + it('should trim whitespace', () => { + expect(prefixCommaSeparatedValues('one , two, three ', '_')).toBe('_.one,_.two,_.three'); + }); + + it('should ignore empty values', () => { + expect(prefixCommaSeparatedValues('', '_')).toBe(''); + expect(prefixCommaSeparatedValues(' ', '_')).toBe(''); + expect(prefixCommaSeparatedValues(' ,, ', '_')).toBe(''); + }); +}); diff --git a/x-pack/plugins/security/server/user_profile/user_profile_service.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.ts index 76948a1af9a73e..8babcaae4f90a1 100644 --- a/x-pack/plugins/security/server/user_profile/user_profile_service.ts +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.ts @@ -155,7 +155,19 @@ export interface UserProfileSuggestParams { * Query string used to match name-related fields in user profiles. The following fields are treated as * name-related: username, full_name and email. */ - name: string; + name?: string; + + /** + * Extra search criteria to improve relevance of the suggestion result. A profile matching the + * specified hint is ranked higher in the response. But not-matching the hint does not exclude a + * profile from the response as long as it matches the `name` field query. + */ + hint?: { + /** + * A list of Profile UIDs to match against. + */ + uids: string[]; + }; /** * Desired number of suggestion to return. The default value is 10. @@ -322,7 +334,7 @@ export class UserProfileService { // @ts-expect-error Invalid response format. body = (await clusterClient.asInternalUser.security.getUserProfile({ uid: userSession.userProfileId, - data: dataPath ? `${KIBANA_DATA_ROOT}.${dataPath}` : undefined, + data: dataPath ? prefixCommaSeparatedValues(dataPath, KIBANA_DATA_ROOT) : undefined, })) as { profiles: SecurityUserProfileWithMetadata[] }; } catch (error) { this.logger.error( @@ -360,7 +372,7 @@ export class UserProfileService { // @ts-expect-error Invalid response format. const body = (await clusterClient.asInternalUser.security.getUserProfile({ uid: [...uids].join(','), - data: dataPath ? `${KIBANA_DATA_ROOT}.${dataPath}` : undefined, + data: dataPath ? prefixCommaSeparatedValues(dataPath, KIBANA_DATA_ROOT) : undefined, })) as { profiles: SecurityUserProfileWithMetadata[] }; return body.profiles.map((rawUserProfile) => parseUserProfile(rawUserProfile)); @@ -402,7 +414,7 @@ export class UserProfileService { throw Error("Current license doesn't support user profile collaboration APIs."); } - const { name, size = DEFAULT_SUGGESTIONS_COUNT, dataPath, requiredPrivileges } = params; + const { name, hint, size = DEFAULT_SUGGESTIONS_COUNT, dataPath, requiredPrivileges } = params; if (size > MAX_SUGGESTIONS_COUNT) { throw Error( `Can return up to ${MAX_SUGGESTIONS_COUNT} suggestions, but ${size} suggestions were requested.` @@ -422,9 +434,10 @@ export class UserProfileService { const body = await clusterClient.asInternalUser.security.suggestUserProfiles({ name, size: numberOfResultsToRequest, + hint, // If fetching data turns out to be a performance bottleneck, we can try to fetch data // only for the profiles that pass privileges check as a separate bulkGet request. - data: dataPath ? `${KIBANA_DATA_ROOT}.${dataPath}` : undefined, + data: dataPath ? prefixCommaSeparatedValues(dataPath, KIBANA_DATA_ROOT) : undefined, }); const filteredProfiles = @@ -504,3 +517,21 @@ export class UserProfileService { return filteredProfiles; } } + +/** + * Returns string of comma separated values prefixed with `prefix`. + * @param str String of comma separated values + * @param prefix Prefix to use prepend to each value + */ +export function prefixCommaSeparatedValues(str: string, prefix: string) { + return str + .split(',') + .reduce((accumulator, value) => { + const trimmedValue = value.trim(); + if (trimmedValue) { + accumulator.push(`${prefix}.${trimmedValue}`); + } + return accumulator; + }, []) + .join(','); +} diff --git a/x-pack/plugins/security_solution/common/ecs/rule/index.ts b/x-pack/plugins/security_solution/common/ecs/rule/index.ts index 073bb7db3a3e8d..c52a9253122dcd 100644 --- a/x-pack/plugins/security_solution/common/ecs/rule/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/rule/index.ts @@ -17,6 +17,7 @@ export interface RuleEcs { risk_score?: string[]; output_index?: string[]; description?: string[]; + exceptions_list?: string[]; from?: string[]; immutable?: boolean[]; index?: string[]; diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_agent_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_agent_generator.ts index 127e69b862305f..d173798dcf87cc 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_agent_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_agent_generator.ts @@ -14,9 +14,12 @@ import type { FleetServerAgent, FleetServerAgentComponentStatus, } from '@kbn/fleet-plugin/common'; -import { AGENTS_INDEX, FleetServerAgentComponentStatuses } from '@kbn/fleet-plugin/common'; +import { FleetServerAgentComponentStatuses, AGENTS_INDEX } from '@kbn/fleet-plugin/common'; +import moment from 'moment'; import { BaseDataGenerator } from './base_data_generator'; +// List of computed (as in, done in code is kibana via +// https://github.com/elastic/kibana/blob/main/x-pack/plugins/fleet/common/services/agent_status.ts#L13-L44 const agentStatusList: readonly AgentStatus[] = [ 'offline', 'error', @@ -29,6 +32,13 @@ const agentStatusList: readonly AgentStatus[] = [ 'degraded', ]; +const lastCheckinStatusList: ReadonlyArray = [ + 'error', + 'online', + 'degraded', + 'updating', +]; + export class FleetAgentGenerator extends BaseDataGenerator { /** * @param [overrides] any partial value to the full Agent record @@ -138,8 +148,6 @@ export class FleetAgentGenerator extends BaseDataGenerator { policy_id: this.randomUUID(), type: 'PERMANENT', default_api_key: 'so3dWnkBj1tiuAw9yAm3:t7jNlnPnR6azEI_YpXuBXQ', - // policy_output_permissions_hash: - // '81b3d070dddec145fafcbdfb6f22888495a12edc31881f6b0511fa10de66daa7', default_api_key_id: 'so3dWnkBj1tiuAw9yAm3', updated_at: now, last_checkin: now, @@ -171,13 +179,76 @@ export class FleetAgentGenerator extends BaseDataGenerator { ], }, ], + last_checkin_status: this.randomChoice(lastCheckinStatusList), + upgraded_at: null, + upgrade_started_at: null, + unenrolled_at: undefined, + unenrollment_started_at: undefined, }, }, overrides ); } - private randomAgentStatus() { + generateEsHitWithStatus( + status: AgentStatus, + overrides: DeepPartial> = {} + ) { + const esHit = this.generateEsHit(overrides); + + // Basically: reverse engineer the Fleet `getAgentStatus()` utility: + // https://github.com/elastic/kibana/blob/main/x-pack/plugins/fleet/common/services/agent_status.ts#L13-L44 + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const fleetServerAgent = esHit._source!; + + // Reset the `last_checkin_status since we're controlling the agent status here + fleetServerAgent.last_checkin_status = 'online'; + + switch (status) { + case 'degraded': + fleetServerAgent.last_checkin_status = 'degraded'; + break; + + case 'enrolling': + fleetServerAgent.last_checkin = undefined; + + break; + case 'error': + fleetServerAgent.last_checkin_status = 'error'; + break; + + case 'inactive': + fleetServerAgent.active = false; + break; + + case 'offline': + // current fleet timeout interface for offline is 5 minutes + // https://github.com/elastic/kibana/blob/main/x-pack/plugins/fleet/common/services/agent_status.ts#L11 + fleetServerAgent.last_checkin = moment().subtract(6, 'minutes').toISOString(); + break; + + case 'unenrolling': + fleetServerAgent.unenrollment_started_at = fleetServerAgent.updated_at; + fleetServerAgent.unenrolled_at = undefined; + break; + + case 'updating': + fleetServerAgent.upgrade_started_at = fleetServerAgent.updated_at; + fleetServerAgent.upgraded_at = undefined; + break; + + case 'warning': + // NOt able to find anything in fleet + break; + + // default is `online`, which is also the default returned by `generateEsHit()` + } + + return esHit; + } + + public randomAgentStatus() { return this.randomChoice(agentStatusList); } } diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_flyout.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/add_edit_flyout/flyout_validation.cy.ts similarity index 83% rename from x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_flyout.cy.ts rename to x-pack/plugins/security_solution/cypress/e2e/exceptions/add_edit_flyout/flyout_validation.cy.ts index 952325ab015599..fcde59d0bd79aa 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_flyout.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/exceptions/add_edit_flyout/flyout_validation.cy.ts @@ -5,27 +5,32 @@ * 2.0. */ -import { getNewRule } from '../../objects/rule'; +import { getNewRule } from '../../../objects/rule'; -import { RULE_STATUS } from '../../screens/create_new_rule'; +import { RULE_STATUS } from '../../../screens/create_new_rule'; -import { createCustomRule } from '../../tasks/api_calls/rules'; -import { goToRuleDetails } from '../../tasks/alerts_detection_rules'; -import { esArchiverLoad, esArchiverResetKibana, esArchiverUnload } from '../../tasks/es_archiver'; -import { login, visitWithoutDateRange } from '../../tasks/login'; +import { createCustomRule } from '../../../tasks/api_calls/rules'; +import { goToRuleDetails } from '../../../tasks/alerts_detection_rules'; +import { + esArchiverLoad, + esArchiverResetKibana, + esArchiverUnload, +} from '../../../tasks/es_archiver'; +import { login, visitWithoutDateRange } from '../../../tasks/login'; import { openExceptionFlyoutFromEmptyViewerPrompt, goToExceptionsTab, openEditException, -} from '../../tasks/rule_details'; +} from '../../../tasks/rule_details'; import { addExceptionEntryFieldMatchAnyValue, addExceptionEntryFieldValue, addExceptionEntryFieldValueOfItemX, addExceptionEntryFieldValueValue, addExceptionEntryOperatorValue, + addExceptionFlyoutItemName, closeExceptionBuilderFlyout, -} from '../../tasks/exceptions'; +} from '../../../tasks/exceptions'; import { ADD_AND_BTN, ADD_OR_BTN, @@ -34,7 +39,6 @@ import { FIELD_INPUT, LOADING_SPINNER, EXCEPTION_ITEM_CONTAINER, - ADD_EXCEPTIONS_BTN_FROM_EMPTY_PROMPT_BTN, EXCEPTION_FIELD_LIST, EXCEPTION_EDIT_FLYOUT_SAVE_BTN, EXCEPTION_FLYOUT_VERSION_CONFLICT, @@ -42,17 +46,17 @@ import { CONFIRM_BTN, VALUES_INPUT, EXCEPTION_FLYOUT_TITLE, -} from '../../screens/exceptions'; +} from '../../../screens/exceptions'; -import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../urls/navigation'; -import { reload } from '../../tasks/common'; +import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../../urls/navigation'; +import { reload } from '../../../tasks/common'; import { createExceptionList, createExceptionListItem, updateExceptionListItem, deleteExceptionList, -} from '../../tasks/api_calls/exceptions'; -import { getExceptionList } from '../../objects/exception'; +} from '../../../tasks/api_calls/exceptions'; +import { getExceptionList } from '../../../objects/exception'; // NOTE: You might look at these tests and feel they're overkill, // but the exceptions flyout has a lot of logic making it difficult @@ -92,12 +96,11 @@ describe('Exceptions flyout', () => { }); it('Validates empty entry values correctly', () => { - cy.root() - .pipe(($el) => { - $el.find(ADD_EXCEPTIONS_BTN_FROM_EMPTY_PROMPT_BTN).trigger('click'); - return $el.find(ADD_AND_BTN); - }) - .should('be.visible'); + // open add exception modal + openExceptionFlyoutFromEmptyViewerPrompt(); + + // add exception item name + addExceptionFlyoutItemName('My item name'); // add an entry with a value and submit button should enable addExceptionEntryFieldValue('agent.name', 0); @@ -120,13 +123,27 @@ describe('Exceptions flyout', () => { closeExceptionBuilderFlyout(); }); + it('Validates custom fields correctly', () => { + // open add exception modal + openExceptionFlyoutFromEmptyViewerPrompt(); + + // add exception item name + addExceptionFlyoutItemName('My item name'); + + // add an entry with a value and submit button should enable + addExceptionEntryFieldValue('blooberty', 0); + addExceptionEntryFieldValueValue('blah', 0); + cy.get(CONFIRM_BTN).should('be.enabled'); + + closeExceptionBuilderFlyout(); + }); + it('Does not overwrite values and-ed together', () => { - cy.root() - .pipe(($el) => { - $el.find(ADD_EXCEPTIONS_BTN_FROM_EMPTY_PROMPT_BTN).trigger('click'); - return $el.find(ADD_AND_BTN); - }) - .should('be.visible'); + // open add exception modal + openExceptionFlyoutFromEmptyViewerPrompt(); + + // add exception item name + addExceptionFlyoutItemName('My item name'); // add multiple entries with invalid field values addExceptionEntryFieldValue('agent.name', 0); @@ -144,12 +161,12 @@ describe('Exceptions flyout', () => { }); it('Does not overwrite values or-ed together', () => { - cy.root() - .pipe(($el) => { - $el.find(ADD_EXCEPTIONS_BTN_FROM_EMPTY_PROMPT_BTN).trigger('click'); - return $el.find(ADD_AND_BTN); - }) - .should('be.visible'); + // open add exception modal + openExceptionFlyoutFromEmptyViewerPrompt(); + + // add exception item name + addExceptionFlyoutItemName('My item name'); + // exception item 1 addExceptionEntryFieldValueOfItemX('agent.name', 0, 0); cy.get(ADD_AND_BTN).click(); @@ -265,19 +282,17 @@ describe('Exceptions flyout', () => { }); it('Contains custom index fields', () => { - cy.root() - .pipe(($el) => { - $el.find(ADD_EXCEPTIONS_BTN_FROM_EMPTY_PROMPT_BTN).trigger('click'); - return $el.find(ADD_AND_BTN); - }) - .should('be.visible'); + // open add exception modal + openExceptionFlyoutFromEmptyViewerPrompt(); + cy.get(FIELD_INPUT).eq(0).click({ force: true }); cy.get(EXCEPTION_FIELD_LIST).contains('unique_value.test'); closeExceptionBuilderFlyout(); }); - describe('flyout errors', () => { + // TODO - Add back in error states into modal + describe.skip('flyout errors', () => { beforeEach(() => { // create exception item via api createExceptionListItem(getExceptionList().list_id, { diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/alerts_table_flow/add_exception.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/alerts_table_flow/add_exception.cy.ts index f1d6d2f1cc063e..213ea64fc4ceb8 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/exceptions/alerts_table_flow/add_exception.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/exceptions/alerts_table_flow/add_exception.cy.ts @@ -5,98 +5,190 @@ * 2.0. */ -import { getException } from '../../../objects/exception'; +import { ROLES } from '../../../../common/test'; +import { getExceptionList, expectedExportedExceptionList } from '../../../objects/exception'; import { getNewRule } from '../../../objects/rule'; -import { ALERTS_COUNT, EMPTY_ALERT_TABLE, NUMBER_OF_ALERTS } from '../../../screens/alerts'; +import { createCustomRule } from '../../../tasks/api_calls/rules'; +import { login, visitWithoutDateRange, waitForPageWithoutDateRange } from '../../../tasks/login'; +import { EXCEPTIONS_URL } from '../../../urls/navigation'; import { - addExceptionFromFirstAlert, - goToClosedAlerts, - goToOpenedAlerts, -} from '../../../tasks/alerts'; -import { createCustomRuleEnabled } from '../../../tasks/api_calls/rules'; -import { goToRuleDetails } from '../../../tasks/alerts_detection_rules'; -import { waitForAlertsToPopulate } from '../../../tasks/create_new_rule'; + deleteExceptionListWithRuleReference, + deleteExceptionListWithoutRuleReference, + exportExceptionList, + searchForExceptionList, + waitForExceptionsTableToBeLoaded, + clearSearchSelection, +} from '../../../tasks/exceptions_table'; import { - esArchiverLoad, - esArchiverUnload, - esArchiverResetKibana, -} from '../../../tasks/es_archiver'; -import { login, visitWithoutDateRange } from '../../../tasks/login'; -import { - addsException, - goToAlertsTab, - goToExceptionsTab, - removeException, - waitForTheRuleToBeExecuted, -} from '../../../tasks/rule_details'; - -import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../../urls/navigation'; -import { deleteAlertsAndRules } from '../../../tasks/common'; + EXCEPTIONS_TABLE_DELETE_BTN, + EXCEPTIONS_TABLE_LIST_NAME, + EXCEPTIONS_TABLE_SHOWING_LISTS, +} from '../../../screens/exceptions'; +import { createExceptionList } from '../../../tasks/api_calls/exceptions'; +import { esArchiverResetKibana } from '../../../tasks/es_archiver'; +import { TOASTER } from '../../../screens/alerts_detection_rules'; -describe('Adds rule exception from alerts flow', () => { - const NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS = '1 alert'; +const getExceptionList1 = () => ({ + ...getExceptionList(), + name: 'Test a new list 1', + list_id: 'exception_list_1', +}); +const getExceptionList2 = () => ({ + ...getExceptionList(), + name: 'Test list 2', + list_id: 'exception_list_2', +}); +describe('Exceptions Table', () => { before(() => { esArchiverResetKibana(); - esArchiverLoad('exceptions'); login(); - }); - beforeEach(() => { - deleteAlertsAndRules(); - createCustomRuleEnabled( - { + // Create exception list associated with a rule + createExceptionList(getExceptionList2(), getExceptionList2().list_id).then((response) => + createCustomRule({ ...getNewRule(), - customQuery: 'agent.name:*', - dataSource: { index: ['exceptions*'], type: 'indexPatterns' }, - }, - 'rule_testing', - '1s' + exceptionLists: [ + { + id: response.body.id, + list_id: getExceptionList2().list_id, + type: getExceptionList2().type, + namespace_type: getExceptionList2().namespace_type, + }, + ], + }) ); - visitWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); - goToRuleDetails(); - waitForTheRuleToBeExecuted(); - waitForAlertsToPopulate(); + + // Create exception list not used by any rules + createExceptionList(getExceptionList1(), getExceptionList1().list_id).as( + 'exceptionListResponse' + ); + + visitWithoutDateRange(EXCEPTIONS_URL); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '3'); + }); + + it('Exports exception list', function () { + cy.intercept(/(\/api\/exception_lists\/_export)/).as('export'); + + visitWithoutDateRange(EXCEPTIONS_URL); + waitForExceptionsTableToBeLoaded(); + exportExceptionList(); + + cy.wait('@export').then(({ response }) => { + cy.wrap(response?.body).should( + 'eql', + expectedExportedExceptionList(this.exceptionListResponse) + ); + + cy.get(TOASTER).should('have.text', 'Exception list export success'); + }); + }); + + it('Filters exception lists on search', () => { + visitWithoutDateRange(EXCEPTIONS_URL); + waitForExceptionsTableToBeLoaded(); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '3'); + + // Single word search + searchForExceptionList('Endpoint'); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '1'); + cy.get(EXCEPTIONS_TABLE_LIST_NAME).should('have.text', 'Endpoint Security Exception List'); + + // Multi word search + clearSearchSelection(); + searchForExceptionList('test'); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '2'); + cy.get(EXCEPTIONS_TABLE_LIST_NAME).eq(1).should('have.text', 'Test list 2'); + cy.get(EXCEPTIONS_TABLE_LIST_NAME).eq(0).should('have.text', 'Test a new list 1'); + + // Exact phrase search + clearSearchSelection(); + searchForExceptionList(`"${getExceptionList1().name}"`); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '1'); + cy.get(EXCEPTIONS_TABLE_LIST_NAME).should('have.text', getExceptionList1().name); + + // Field search + clearSearchSelection(); + searchForExceptionList('list_id:endpoint_list'); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '1'); + cy.get(EXCEPTIONS_TABLE_LIST_NAME).should('have.text', 'Endpoint Security Exception List'); + + clearSearchSelection(); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '3'); + }); + + it('Deletes exception list without rule reference', () => { + visitWithoutDateRange(EXCEPTIONS_URL); + waitForExceptionsTableToBeLoaded(); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '3'); + + deleteExceptionListWithoutRuleReference(); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '2'); }); - afterEach(() => { - esArchiverUnload('exceptions_2'); + it('Deletes exception list with rule reference', () => { + waitForPageWithoutDateRange(EXCEPTIONS_URL); + waitForExceptionsTableToBeLoaded(); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '2'); + + deleteExceptionListWithRuleReference(); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '1'); }); +}); + +describe('Exceptions Table - read only', () => { + before(() => { + // First we login as a privileged user to create exception list + esArchiverResetKibana(); + login(ROLES.platform_engineer); + visitWithoutDateRange(EXCEPTIONS_URL, ROLES.platform_engineer); + createExceptionList(getExceptionList(), getExceptionList().list_id); + + // Then we login as read-only user to test. + login(ROLES.reader); + visitWithoutDateRange(EXCEPTIONS_URL, ROLES.reader); + waitForExceptionsTableToBeLoaded(); - after(() => { - esArchiverUnload('exceptions'); + cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 1 list`); }); - it('Creates an exception from an alert and deletes it', () => { - cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS); - // Create an exception from the alerts actions menu that matches - // the existing alert - addExceptionFromFirstAlert(); - addsException(getException()); - - // Alerts table should now be empty from having added exception and closed - // matching alert - cy.get(EMPTY_ALERT_TABLE).should('exist'); - - // Closed alert should appear in table - goToClosedAlerts(); - cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', `${NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS}`); - - // Remove the exception and load an event that would have matched that exception - // to show that said exception now starts to show up again - goToExceptionsTab(); - removeException(); - esArchiverLoad('exceptions_2'); - goToAlertsTab(); - goToOpenedAlerts(); - waitForTheRuleToBeExecuted(); - waitForAlertsToPopulate(); - - cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', '2 alerts'); + it('Delete icon is not shown', () => { + cy.get(EXCEPTIONS_TABLE_DELETE_BTN).should('not.exist'); }); }); diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_table.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_management_flow/exceptions_table.cy.ts similarity index 91% rename from x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_table.cy.ts rename to x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_management_flow/exceptions_table.cy.ts index b037c4f6d62ce8..213ea64fc4ceb8 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_table.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_management_flow/exceptions_table.cy.ts @@ -5,14 +5,14 @@ * 2.0. */ -import { ROLES } from '../../../common/test'; -import { getExceptionList, expectedExportedExceptionList } from '../../objects/exception'; -import { getNewRule } from '../../objects/rule'; +import { ROLES } from '../../../../common/test'; +import { getExceptionList, expectedExportedExceptionList } from '../../../objects/exception'; +import { getNewRule } from '../../../objects/rule'; -import { createCustomRule } from '../../tasks/api_calls/rules'; -import { login, visitWithoutDateRange, waitForPageWithoutDateRange } from '../../tasks/login'; +import { createCustomRule } from '../../../tasks/api_calls/rules'; +import { login, visitWithoutDateRange, waitForPageWithoutDateRange } from '../../../tasks/login'; -import { EXCEPTIONS_URL } from '../../urls/navigation'; +import { EXCEPTIONS_URL } from '../../../urls/navigation'; import { deleteExceptionListWithRuleReference, deleteExceptionListWithoutRuleReference, @@ -20,15 +20,15 @@ import { searchForExceptionList, waitForExceptionsTableToBeLoaded, clearSearchSelection, -} from '../../tasks/exceptions_table'; +} from '../../../tasks/exceptions_table'; import { EXCEPTIONS_TABLE_DELETE_BTN, EXCEPTIONS_TABLE_LIST_NAME, EXCEPTIONS_TABLE_SHOWING_LISTS, -} from '../../screens/exceptions'; -import { createExceptionList } from '../../tasks/api_calls/exceptions'; -import { esArchiverResetKibana } from '../../tasks/es_archiver'; -import { TOASTER } from '../../screens/alerts_detection_rules'; +} from '../../../screens/exceptions'; +import { createExceptionList } from '../../../tasks/api_calls/exceptions'; +import { esArchiverResetKibana } from '../../../tasks/es_archiver'; +import { TOASTER } from '../../../screens/alerts_detection_rules'; const getExceptionList1 = () => ({ ...getExceptionList(), diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_endpoint_exception.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_endpoint_exception.cy.ts new file mode 100644 index 00000000000000..da975710c7f394 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_endpoint_exception.cy.ts @@ -0,0 +1,179 @@ +/* + * 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 { getNewRule } from '../../../objects/rule'; + +import { createCustomRule } from '../../../tasks/api_calls/rules'; +import { goToRuleDetails } from '../../../tasks/alerts_detection_rules'; +import { + esArchiverLoad, + esArchiverResetKibana, + esArchiverUnload, +} from '../../../tasks/es_archiver'; +import { login, visitWithoutDateRange } from '../../../tasks/login'; +import { + goToEndpointExceptionsTab, + openEditException, + openExceptionFlyoutFromEmptyViewerPrompt, + searchForExceptionItem, +} from '../../../tasks/rule_details'; +import { + addExceptionConditions, + addExceptionFlyoutItemName, + editException, + editExceptionFlyoutItemName, + selectOs, + submitEditedExceptionItem, + submitNewExceptionItem, +} from '../../../tasks/exceptions'; + +import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../../urls/navigation'; +import { deleteAlertsAndRules } from '../../../tasks/common'; +import { + NO_EXCEPTIONS_EXIST_PROMPT, + EXCEPTION_ITEM_VIEWER_CONTAINER, + NO_EXCEPTIONS_SEARCH_RESULTS_PROMPT, + CLOSE_ALERTS_CHECKBOX, + CONFIRM_BTN, + ADD_TO_RULE_OR_LIST_SECTION, + CLOSE_SINGLE_ALERT_CHECKBOX, + EXCEPTION_ITEM_CONTAINER, + VALUES_INPUT, + FIELD_INPUT, + EXCEPTION_CARD_ITEM_NAME, + EXCEPTION_CARD_ITEM_CONDITIONS, +} from '../../../screens/exceptions'; +import { createEndpointExceptionList } from '../../../tasks/api_calls/exceptions'; + +describe('Add endpoint exception from rule details', () => { + const ITEM_NAME = 'Sample Exception List Item'; + + before(() => { + esArchiverResetKibana(); + esArchiverLoad('auditbeat'); + login(); + }); + + before(() => { + deleteAlertsAndRules(); + // create rule with exception + createEndpointExceptionList().then((response) => { + createCustomRule( + { + ...getNewRule(), + customQuery: 'event.code:*', + dataSource: { index: ['auditbeat*'], type: 'indexPatterns' }, + exceptionLists: [ + { + id: response.body.id, + list_id: response.body.list_id, + type: response.body.type, + namespace_type: response.body.namespace_type, + }, + ], + }, + '2' + ); + }); + + visitWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); + goToRuleDetails(); + goToEndpointExceptionsTab(); + }); + + after(() => { + esArchiverUnload('auditbeat'); + }); + + it('creates an exception item', () => { + // when no exceptions exist, empty component shows with action to add exception + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('exist'); + + // open add exception modal + openExceptionFlyoutFromEmptyViewerPrompt(); + + // for endpoint exceptions, must specify OS + selectOs('windows'); + + // add exception item conditions + addExceptionConditions({ + field: 'event.code', + operator: 'is', + values: ['foo'], + }); + + // Name is required so want to check that submit is still disabled + cy.get(CONFIRM_BTN).should('have.attr', 'disabled'); + + // add exception item name + addExceptionFlyoutItemName(ITEM_NAME); + + // Option to add to rule or add to list should NOT appear + cy.get(ADD_TO_RULE_OR_LIST_SECTION).should('not.exist'); + + // not testing close alert functionality here, just ensuring that the options appear as expected + cy.get(CLOSE_SINGLE_ALERT_CHECKBOX).should('not.exist'); + cy.get(CLOSE_ALERTS_CHECKBOX).should('exist'); + + // submit + submitNewExceptionItem(); + + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + }); + + it('edits an endpoint exception item', () => { + const NEW_ITEM_NAME = 'Exception item-EDITED'; + const ITEM_FIELD = 'event.code'; + const FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD = 'agent.name'; + + // displays existing exception items + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('not.exist'); + cy.get(EXCEPTION_CARD_ITEM_NAME).should('have.text', ITEM_NAME); + cy.get(EXCEPTION_CARD_ITEM_CONDITIONS).should('have.text', ` ${ITEM_FIELD}IS foo`); + + // open edit exception modal + openEditException(); + + // edit exception item name + editExceptionFlyoutItemName(NEW_ITEM_NAME); + + // check that the existing item's field is being populated + cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(0).should('have.text', ITEM_FIELD); + cy.get(VALUES_INPUT).should('have.text', 'foo'); + + // edit conditions + editException(FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD, 0, 0); + + // submit + submitEditedExceptionItem(); + + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + + // check that updates stuck + cy.get(EXCEPTION_CARD_ITEM_NAME).should('have.text', NEW_ITEM_NAME); + cy.get(EXCEPTION_CARD_ITEM_CONDITIONS).should('have.text', ' agent.nameIS foo'); + }); + + it('allows user to search for items', () => { + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + + // can search for an exception value + searchForExceptionItem('foo'); + + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + + // displays empty search result view if no matches found + searchForExceptionItem('abc'); + + // new exception item displays + cy.get(NO_EXCEPTIONS_SEARCH_RESULTS_PROMPT).should('exist'); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception.cy.ts new file mode 100644 index 00000000000000..64a2e14bbf61f2 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception.cy.ts @@ -0,0 +1,336 @@ +/* + * 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 { getException, getExceptionList } from '../../../objects/exception'; +import { getNewRule } from '../../../objects/rule'; + +import { ALERTS_COUNT, EMPTY_ALERT_TABLE, NUMBER_OF_ALERTS } from '../../../screens/alerts'; +import { createCustomRule, createCustomRuleEnabled } from '../../../tasks/api_calls/rules'; +import { goToRuleDetails } from '../../../tasks/alerts_detection_rules'; +import { goToClosedAlerts, goToOpenedAlerts } from '../../../tasks/alerts'; +import { + esArchiverLoad, + esArchiverUnload, + esArchiverResetKibana, +} from '../../../tasks/es_archiver'; +import { login, visitWithoutDateRange } from '../../../tasks/login'; +import { + addExceptionFlyoutFromViewerHeader, + goToAlertsTab, + goToExceptionsTab, + openEditException, + openExceptionFlyoutFromEmptyViewerPrompt, + removeException, + searchForExceptionItem, + waitForTheRuleToBeExecuted, +} from '../../../tasks/rule_details'; +import { + addExceptionConditions, + addExceptionFlyoutItemName, + editException, + editExceptionFlyoutItemName, + selectAddToRuleRadio, + selectBulkCloseAlerts, + selectSharedListToAddExceptionTo, + submitEditedExceptionItem, + submitNewExceptionItem, +} from '../../../tasks/exceptions'; +import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../../urls/navigation'; +import { deleteAlertsAndRules } from '../../../tasks/common'; +import { + NO_EXCEPTIONS_EXIST_PROMPT, + EXCEPTION_ITEM_VIEWER_CONTAINER, + NO_EXCEPTIONS_SEARCH_RESULTS_PROMPT, + CLOSE_ALERTS_CHECKBOX, + CONFIRM_BTN, + ADD_TO_SHARED_LIST_RADIO_INPUT, + EXCEPTION_ITEM_CONTAINER, + FIELD_INPUT, + VALUES_MATCH_ANY_INPUT, + EXCEPTION_CARD_ITEM_NAME, + EXCEPTION_CARD_ITEM_CONDITIONS, +} from '../../../screens/exceptions'; +import { + createExceptionList, + createExceptionListItem, + deleteExceptionList, +} from '../../../tasks/api_calls/exceptions'; +import { waitForAlertsToPopulate } from '../../../tasks/create_new_rule'; + +describe('Add/edit exception from rule details', () => { + const NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS = '1 alert'; + const FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD = 'agent.name'; + const ITEM_FIELD = 'unique_value.test'; + + before(() => { + esArchiverResetKibana(); + esArchiverLoad('exceptions'); + login(); + }); + + after(() => { + esArchiverUnload('exceptions'); + }); + + describe('existing list and items', () => { + const exceptionList = getExceptionList(); + beforeEach(() => { + deleteAlertsAndRules(); + deleteExceptionList(exceptionList.list_id, exceptionList.namespace_type); + // create rule with exceptions + createExceptionList(exceptionList, exceptionList.list_id).then((response) => { + createCustomRule( + { + ...getNewRule(), + customQuery: 'agent.name:*', + dataSource: { index: ['exceptions*'], type: 'indexPatterns' }, + exceptionLists: [ + { + id: response.body.id, + list_id: exceptionList.list_id, + type: exceptionList.type, + namespace_type: exceptionList.namespace_type, + }, + ], + }, + '2' + ); + createExceptionListItem(exceptionList.list_id, { + list_id: exceptionList.list_id, + item_id: 'simple_list_item', + tags: [], + type: 'simple', + description: 'Test exception item 2', + name: 'Sample Exception List Item 2', + namespace_type: 'single', + entries: [ + { + field: ITEM_FIELD, + operator: 'included', + type: 'match_any', + value: ['foo'], + }, + ], + }); + }); + + visitWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); + goToRuleDetails(); + goToExceptionsTab(); + }); + + it('Edits an exception item', () => { + const NEW_ITEM_NAME = 'Exception item-EDITED'; + const ITEM_NAME = 'Sample Exception List Item 2'; + + // displays existing exception items + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('not.exist'); + cy.get(EXCEPTION_CARD_ITEM_NAME).should('have.text', ITEM_NAME); + cy.get(EXCEPTION_CARD_ITEM_CONDITIONS).should('have.text', ' unique_value.testis one of foo'); + + // open edit exception modal + openEditException(); + + // edit exception item name + editExceptionFlyoutItemName(NEW_ITEM_NAME); + + // check that the existing item's field is being populated + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(0) + .find(FIELD_INPUT) + .eq(0) + .should('have.text', ITEM_FIELD); + cy.get(VALUES_MATCH_ANY_INPUT).should('have.text', 'foo'); + + // edit conditions + editException(FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD, 0, 0); + + // submit + submitEditedExceptionItem(); + + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + + // check that updates stuck + cy.get(EXCEPTION_CARD_ITEM_NAME).should('have.text', NEW_ITEM_NAME); + cy.get(EXCEPTION_CARD_ITEM_CONDITIONS).should('have.text', ' agent.nameIS foo'); + }); + + describe('rule with existing shared exceptions', () => { + it('Creates an exception item to add to shared list', () => { + // displays existing exception items + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('not.exist'); + + // open add exception modal + addExceptionFlyoutFromViewerHeader(); + + // add exception item conditions + addExceptionConditions(getException()); + + // Name is required so want to check that submit is still disabled + cy.get(CONFIRM_BTN).should('have.attr', 'disabled'); + + // add exception item name + addExceptionFlyoutItemName('My item name'); + + // select to add exception item to a shared list + selectSharedListToAddExceptionTo(1); + + // not testing close alert functionality here, just ensuring that the options appear as expected + cy.get(CLOSE_ALERTS_CHECKBOX).should('exist'); + cy.get(CLOSE_ALERTS_CHECKBOX).should('not.have.attr', 'disabled'); + + // submit + submitNewExceptionItem(); + + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 2); + }); + + it('Creates an exception item to add to rule only', () => { + // displays existing exception items + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('not.exist'); + + // open add exception modal + addExceptionFlyoutFromViewerHeader(); + + // add exception item conditions + addExceptionConditions(getException()); + + // Name is required so want to check that submit is still disabled + cy.get(CONFIRM_BTN).should('have.attr', 'disabled'); + + // add exception item name + addExceptionFlyoutItemName('My item name'); + + // select to add exception item to rule only + selectAddToRuleRadio(); + + // not testing close alert functionality here, just ensuring that the options appear as expected + cy.get(CLOSE_ALERTS_CHECKBOX).should('exist'); + cy.get(CLOSE_ALERTS_CHECKBOX).should('not.have.attr', 'disabled'); + + // submit + submitNewExceptionItem(); + + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 2); + }); + + // Trying to figure out with EUI why the search won't trigger + it('Can search for items', () => { + // displays existing exception items + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('not.exist'); + + // can search for an exception value + searchForExceptionItem('foo'); + + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + + // displays empty search result view if no matches found + searchForExceptionItem('abc'); + + // new exception item displays + cy.get(NO_EXCEPTIONS_SEARCH_RESULTS_PROMPT).should('exist'); + }); + }); + }); + + describe('rule without existing exceptions', () => { + beforeEach(() => { + deleteAlertsAndRules(); + createCustomRuleEnabled( + { + ...getNewRule(), + customQuery: 'agent.name:*', + dataSource: { index: ['exceptions*'], type: 'indexPatterns' }, + }, + 'rule_testing', + '1s' + ); + visitWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); + goToRuleDetails(); + goToExceptionsTab(); + }); + + afterEach(() => { + esArchiverUnload('exceptions_2'); + }); + + it('Cannot create an item to add to rule but not shared list as rule has no lists attached', () => { + // when no exceptions exist, empty component shows with action to add exception + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('exist'); + + // open add exception modal + openExceptionFlyoutFromEmptyViewerPrompt(); + + // add exception item conditions + addExceptionConditions({ + field: 'agent.name', + operator: 'is', + values: ['foo'], + }); + + // Name is required so want to check that submit is still disabled + cy.get(CONFIRM_BTN).should('have.attr', 'disabled'); + + // add exception item name + addExceptionFlyoutItemName('My item name'); + + // select to add exception item to rule only + selectAddToRuleRadio(); + + // Check that add to shared list is disabled, should be unless + // rule has shared lists attached to it already + cy.get(ADD_TO_SHARED_LIST_RADIO_INPUT).should('have.attr', 'disabled'); + + // Close matching alerts + selectBulkCloseAlerts(); + + // submit + submitNewExceptionItem(); + + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + + // Alerts table should now be empty from having added exception and closed + // matching alert + goToAlertsTab(); + cy.get(EMPTY_ALERT_TABLE).should('exist'); + + // Closed alert should appear in table + goToClosedAlerts(); + cy.get(ALERTS_COUNT).should('exist'); + cy.get(NUMBER_OF_ALERTS).should('have.text', `${NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS}`); + + // Remove the exception and load an event that would have matched that exception + // to show that said exception now starts to show up again + goToExceptionsTab(); + + // when removing exception and again, no more exist, empty screen shows again + removeException(); + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('exist'); + + // load more docs + esArchiverLoad('exceptions_2'); + + // now that there are no more exceptions, the docs should match and populate alerts + goToAlertsTab(); + goToOpenedAlerts(); + waitForTheRuleToBeExecuted(); + waitForAlertsToPopulate(); + + cy.get(ALERTS_COUNT).should('exist'); + cy.get(NUMBER_OF_ALERTS).should('have.text', '2 alerts'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_exception_data_view.spect.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception_data_view.cy.ts similarity index 63% rename from x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_exception_data_view.spect.ts rename to x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception_data_view.cy.ts index 05b21abe525656..f17a5e4221a891 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_exception_data_view.spect.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception_data_view.cy.ts @@ -10,6 +10,11 @@ import { ALERTS_COUNT, EMPTY_ALERT_TABLE, NUMBER_OF_ALERTS } from '../../../scre import { createCustomRuleEnabled } from '../../../tasks/api_calls/rules'; import { goToRuleDetails } from '../../../tasks/alerts_detection_rules'; import { goToClosedAlerts, goToOpenedAlerts } from '../../../tasks/alerts'; +import { + editException, + editExceptionFlyoutItemName, + submitEditedExceptionItem, +} from '../../../tasks/exceptions'; import { esArchiverLoad, esArchiverUnload, @@ -20,6 +25,7 @@ import { addFirstExceptionFromRuleDetails, goToAlertsTab, goToExceptionsTab, + openEditException, removeException, waitForTheRuleToBeExecuted, } from '../../../tasks/rule_details'; @@ -29,11 +35,17 @@ import { postDataView, deleteAlertsAndRules } from '../../../tasks/common'; import { NO_EXCEPTIONS_EXIST_PROMPT, EXCEPTION_ITEM_VIEWER_CONTAINER, + EXCEPTION_CARD_ITEM_NAME, + EXCEPTION_CARD_ITEM_CONDITIONS, + EXCEPTION_ITEM_CONTAINER, + FIELD_INPUT, + VALUES_INPUT, } from '../../../screens/exceptions'; import { waitForAlertsToPopulate } from '../../../tasks/create_new_rule'; describe('Add exception using data views from rule details', () => { const NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS = '1 alert'; + const ITEM_NAME = 'Sample Exception List Item'; before(() => { esArchiverResetKibana(); @@ -66,17 +78,20 @@ describe('Add exception using data views from rule details', () => { esArchiverUnload('exceptions_2'); }); - it('Creates an exception item when none exist', () => { + it('Creates an exception item', () => { // when no exceptions exist, empty component shows with action to add exception cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('exist'); // clicks prompt button to add first exception that will also select to close // all matching alerts - addFirstExceptionFromRuleDetails({ - field: 'agent.name', - operator: 'is', - values: ['foo'], - }); + addFirstExceptionFromRuleDetails( + { + field: 'agent.name', + operator: 'is', + values: ['foo'], + }, + ITEM_NAME + ); // new exception item displays cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); @@ -111,4 +126,49 @@ describe('Add exception using data views from rule details', () => { cy.get(ALERTS_COUNT).should('exist'); cy.get(NUMBER_OF_ALERTS).should('have.text', '2 alerts'); }); + + it('Edits an exception item', () => { + const NEW_ITEM_NAME = 'Exception item-EDITED'; + const ITEM_FIELD = 'unique_value.test'; + const FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD = 'agent.name'; + + // add item to edit + addFirstExceptionFromRuleDetails( + { + field: ITEM_FIELD, + operator: 'is', + values: ['foo'], + }, + ITEM_NAME + ); + + // displays existing exception items + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('not.exist'); + cy.get(EXCEPTION_CARD_ITEM_NAME).should('have.text', ITEM_NAME); + cy.get(EXCEPTION_CARD_ITEM_CONDITIONS).should('have.text', ' unique_value.testIS foo'); + + // open edit exception modal + openEditException(); + + // edit exception item name + editExceptionFlyoutItemName(NEW_ITEM_NAME); + + // check that the existing item's field is being populated + cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(0).should('have.text', ITEM_FIELD); + cy.get(VALUES_INPUT).should('have.text', 'foo'); + + // edit conditions + editException(FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD, 0, 0); + + // submit + submitEditedExceptionItem(); + + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + + // check that updates stuck + cy.get(EXCEPTION_CARD_ITEM_NAME).should('have.text', NEW_ITEM_NAME); + cy.get(EXCEPTION_CARD_ITEM_CONDITIONS).should('have.text', ' agent.nameIS foo'); + }); }); diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_exception.spec.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_exception.spec.ts deleted file mode 100644 index 3ea14d8b3ffd45..00000000000000 --- a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_exception.spec.ts +++ /dev/null @@ -1,221 +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 { getException, getExceptionList } from '../../../objects/exception'; -import { getNewRule } from '../../../objects/rule'; - -import { ALERTS_COUNT, EMPTY_ALERT_TABLE, NUMBER_OF_ALERTS } from '../../../screens/alerts'; -import { createCustomRule, createCustomRuleEnabled } from '../../../tasks/api_calls/rules'; -import { goToRuleDetails } from '../../../tasks/alerts_detection_rules'; -import { goToClosedAlerts, goToOpenedAlerts } from '../../../tasks/alerts'; -import { - esArchiverLoad, - esArchiverUnload, - esArchiverResetKibana, -} from '../../../tasks/es_archiver'; -import { login, visitWithoutDateRange } from '../../../tasks/login'; -import { - addExceptionFromRuleDetails, - addFirstExceptionFromRuleDetails, - goToAlertsTab, - goToExceptionsTab, - removeException, - searchForExceptionItem, - waitForTheRuleToBeExecuted, -} from '../../../tasks/rule_details'; - -import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../../urls/navigation'; -import { deleteAlertsAndRules } from '../../../tasks/common'; -import { - NO_EXCEPTIONS_EXIST_PROMPT, - EXCEPTION_ITEM_VIEWER_CONTAINER, - NO_EXCEPTIONS_SEARCH_RESULTS_PROMPT, -} from '../../../screens/exceptions'; -import { - createExceptionList, - createExceptionListItem, - deleteExceptionList, -} from '../../../tasks/api_calls/exceptions'; -import { waitForAlertsToPopulate } from '../../../tasks/create_new_rule'; - -describe('Add exception from rule details', () => { - const NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS = '1 alert'; - - before(() => { - esArchiverResetKibana(); - esArchiverLoad('exceptions'); - login(); - }); - - after(() => { - esArchiverUnload('exceptions'); - }); - - describe('rule with existing exceptions', () => { - const exceptionList = getExceptionList(); - beforeEach(() => { - deleteAlertsAndRules(); - deleteExceptionList(exceptionList.list_id, exceptionList.namespace_type); - // create rule with exceptions - createExceptionList(exceptionList, exceptionList.list_id).then((response) => { - createCustomRule( - { - ...getNewRule(), - customQuery: 'agent.name:*', - dataSource: { index: ['exceptions*'], type: 'indexPatterns' }, - exceptionLists: [ - { - id: response.body.id, - list_id: exceptionList.list_id, - type: exceptionList.type, - namespace_type: exceptionList.namespace_type, - }, - ], - }, - '2' - ); - createExceptionListItem(exceptionList.list_id, { - list_id: exceptionList.list_id, - item_id: 'simple_list_item', - tags: [], - type: 'simple', - description: 'Test exception item', - name: 'Sample Exception List Item', - namespace_type: 'single', - entries: [ - { - field: 'user.name', - operator: 'included', - type: 'match_any', - value: ['bar'], - }, - ], - }); - createExceptionListItem(exceptionList.list_id, { - list_id: exceptionList.list_id, - item_id: 'simple_list_item_2', - tags: [], - type: 'simple', - description: 'Test exception item 2', - name: 'Sample Exception List Item 2', - namespace_type: 'single', - entries: [ - { - field: 'unique_value.test', - operator: 'included', - type: 'match_any', - value: ['foo'], - }, - ], - }); - }); - - visitWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); - goToRuleDetails(); - goToExceptionsTab(); - }); - - it('Creates an exception item', () => { - // displays existing exception items - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 2); - cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('not.exist'); - - // clicks prompt button to add a new exception item - addExceptionFromRuleDetails(getException()); - - // new exception item displays - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 3); - }); - - // Trying to figure out with EUI why the search won't trigger - it('Can search for items', () => { - // displays existing exception items - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 2); - cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('not.exist'); - - // can search for an exception value - searchForExceptionItem('foo'); - - // new exception item displays - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); - - // displays empty search result view if no matches found - searchForExceptionItem('abc'); - - // new exception item displays - cy.get(NO_EXCEPTIONS_SEARCH_RESULTS_PROMPT).should('exist'); - }); - }); - - describe('rule without existing exceptions', () => { - beforeEach(() => { - deleteAlertsAndRules(); - createCustomRuleEnabled( - { - ...getNewRule(), - customQuery: 'agent.name:*', - dataSource: { index: ['exceptions*'], type: 'indexPatterns' }, - }, - 'rule_testing', - '1s' - ); - visitWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); - goToRuleDetails(); - goToExceptionsTab(); - }); - - afterEach(() => { - esArchiverUnload('exceptions_2'); - }); - - it('Creates an exception item when none exist', () => { - // when no exceptions exist, empty component shows with action to add exception - cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('exist'); - - // clicks prompt button to add first exception that will also select to close - // all matching alerts - addFirstExceptionFromRuleDetails({ - field: 'agent.name', - operator: 'is', - values: ['foo'], - }); - - // new exception item displays - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); - - // Alerts table should now be empty from having added exception and closed - // matching alert - goToAlertsTab(); - cy.get(EMPTY_ALERT_TABLE).should('exist'); - - // Closed alert should appear in table - goToClosedAlerts(); - cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', `${NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS}`); - - // Remove the exception and load an event that would have matched that exception - // to show that said exception now starts to show up again - goToExceptionsTab(); - - // when removing exception and again, no more exist, empty screen shows again - removeException(); - cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('exist'); - - // load more docs - esArchiverLoad('exceptions_2'); - - // now that there are no more exceptions, the docs should match and populate alerts - goToAlertsTab(); - goToOpenedAlerts(); - waitForTheRuleToBeExecuted(); - waitForAlertsToPopulate(); - - cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', '2 alerts'); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/edit_exception.spec.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/edit_exception.spec.ts deleted file mode 100644 index ec44e76d09edbf..00000000000000 --- a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/edit_exception.spec.ts +++ /dev/null @@ -1,143 +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 { getExceptionList } from '../../../objects/exception'; -import { getNewRule } from '../../../objects/rule'; - -import { createCustomRuleEnabled } from '../../../tasks/api_calls/rules'; -import { goToRuleDetails } from '../../../tasks/alerts_detection_rules'; -import { goToOpenedAlerts } from '../../../tasks/alerts'; -import { - esArchiverLoad, - esArchiverUnload, - esArchiverResetKibana, -} from '../../../tasks/es_archiver'; -import { login, visitWithoutDateRange } from '../../../tasks/login'; -import { - goToExceptionsTab, - waitForTheRuleToBeExecuted, - openEditException, - goToAlertsTab, -} from '../../../tasks/rule_details'; - -import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../../urls/navigation'; -import { deleteAlertsAndRules } from '../../../tasks/common'; -import { - EXCEPTION_ITEM_VIEWER_CONTAINER, - EXCEPTION_ITEM_CONTAINER, - FIELD_INPUT, -} from '../../../screens/exceptions'; -import { - createExceptionList, - createExceptionListItem, - deleteExceptionList, -} from '../../../tasks/api_calls/exceptions'; -import { waitForAlertsToPopulate } from '../../../tasks/create_new_rule'; -import { editException } from '../../../tasks/exceptions'; -import { ALERTS_COUNT, NUMBER_OF_ALERTS } from '../../../screens/alerts'; - -describe('Edit exception from rule details', () => { - const exceptionList = getExceptionList(); - const NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS = '1 alert'; - const ITEM_FIELD = 'unique_value.test'; - const FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD = 'agent.name'; - - before(() => { - esArchiverResetKibana(); - esArchiverLoad('exceptions'); - login(); - }); - - after(() => { - esArchiverUnload('exceptions'); - }); - - beforeEach(() => { - deleteAlertsAndRules(); - deleteExceptionList(exceptionList.list_id, exceptionList.namespace_type); - // create rule with exceptions - createExceptionList(exceptionList, exceptionList.list_id).then((response) => { - createCustomRuleEnabled( - { - ...getNewRule(), - customQuery: 'agent.name:*', - dataSource: { index: ['exceptions*'], type: 'indexPatterns' }, - exceptionLists: [ - { - id: response.body.id, - list_id: exceptionList.list_id, - type: exceptionList.type, - namespace_type: exceptionList.namespace_type, - }, - ], - }, - '2', - '2s' - ); - createExceptionListItem(exceptionList.list_id, { - list_id: exceptionList.list_id, - item_id: 'simple_list_item', - tags: [], - type: 'simple', - description: 'Test exception item', - name: 'Sample Exception List Item', - namespace_type: 'single', - entries: [ - { - field: ITEM_FIELD, - operator: 'included', - type: 'match_any', - value: ['bar'], - }, - ], - }); - }); - - visitWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); - goToRuleDetails(); - waitForTheRuleToBeExecuted(); - waitForAlertsToPopulate(); - goToExceptionsTab(); - }); - - afterEach(() => { - esArchiverUnload('exceptions_2'); - }); - - it('Edits an exception item', () => { - // displays existing exception item - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); - - openEditException(); - - // check that the existing item's field is being populated - cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(0).should('have.text', ITEM_FIELD); - - // check that you can select a different field - editException(FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD, 0, 0); - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); - - // Alerts table should still show single alert - goToAlertsTab(); - cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', `${NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS}`); - - // load more docs - esArchiverLoad('exceptions_2'); - - // now that 2 more docs have been added, one should match the edited exception - goToAlertsTab(); - goToOpenedAlerts(); - waitForTheRuleToBeExecuted(); - waitForAlertsToPopulate(2); - - // there should be 2 alerts, one is the original alert and the second is for the newly - // matching doc - cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', '2 alerts'); - }); -}); diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/edit_exception_data_view.spec.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/edit_exception_data_view.spec.ts deleted file mode 100644 index 587486d99a0684..00000000000000 --- a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/edit_exception_data_view.spec.ts +++ /dev/null @@ -1,144 +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 { getExceptionList } from '../../../objects/exception'; -import { getNewRule } from '../../../objects/rule'; - -import { createCustomRuleEnabled } from '../../../tasks/api_calls/rules'; -import { goToRuleDetails } from '../../../tasks/alerts_detection_rules'; -import { goToOpenedAlerts } from '../../../tasks/alerts'; -import { - esArchiverLoad, - esArchiverUnload, - esArchiverResetKibana, -} from '../../../tasks/es_archiver'; -import { login, visitWithoutDateRange } from '../../../tasks/login'; -import { - goToExceptionsTab, - waitForTheRuleToBeExecuted, - openEditException, - goToAlertsTab, -} from '../../../tasks/rule_details'; - -import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../../urls/navigation'; -import { postDataView, deleteAlertsAndRules } from '../../../tasks/common'; -import { - EXCEPTION_ITEM_VIEWER_CONTAINER, - EXCEPTION_ITEM_CONTAINER, - FIELD_INPUT, -} from '../../../screens/exceptions'; -import { - createExceptionList, - createExceptionListItem, - deleteExceptionList, -} from '../../../tasks/api_calls/exceptions'; -import { waitForAlertsToPopulate } from '../../../tasks/create_new_rule'; -import { editException } from '../../../tasks/exceptions'; -import { ALERTS_COUNT, NUMBER_OF_ALERTS } from '../../../screens/alerts'; - -describe('Edit exception using data views from rule details', () => { - const exceptionList = getExceptionList(); - const NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS = '1 alert'; - const ITEM_FIELD = 'unique_value.test'; - const FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD = 'agent.name'; - - before(() => { - esArchiverResetKibana(); - esArchiverLoad('exceptions'); - login(); - postDataView('exceptions-*'); - }); - - after(() => { - esArchiverUnload('exceptions'); - }); - - beforeEach(() => { - deleteAlertsAndRules(); - deleteExceptionList(exceptionList.list_id, exceptionList.namespace_type); - // create rule with exceptions - createExceptionList(exceptionList, exceptionList.list_id).then((response) => { - createCustomRuleEnabled( - { - ...getNewRule(), - customQuery: 'agent.name:*', - dataSource: { dataView: 'exceptions-*', type: 'dataView' }, - exceptionLists: [ - { - id: response.body.id, - list_id: exceptionList.list_id, - type: exceptionList.type, - namespace_type: exceptionList.namespace_type, - }, - ], - }, - '2', - '2s' - ); - createExceptionListItem(exceptionList.list_id, { - list_id: exceptionList.list_id, - item_id: 'simple_list_item', - tags: [], - type: 'simple', - description: 'Test exception item', - name: 'Sample Exception List Item', - namespace_type: 'single', - entries: [ - { - field: ITEM_FIELD, - operator: 'included', - type: 'match_any', - value: ['bar'], - }, - ], - }); - }); - - visitWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); - goToRuleDetails(); - waitForTheRuleToBeExecuted(); - waitForAlertsToPopulate(); - goToExceptionsTab(); - }); - - afterEach(() => { - esArchiverUnload('exceptions_2'); - }); - - it('Edits an exception item', () => { - // displays existing exception item - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); - - openEditException(); - - // check that the existing item's field is being populated - cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(0).should('have.text', ITEM_FIELD); - - // check that you can select a different field - editException(FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD, 0, 0); - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); - - // Alerts table should still show single alert - goToAlertsTab(); - cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', `${NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS}`); - - // load more docs - esArchiverLoad('exceptions_2'); - - // now that 2 more docs have been added, one should match the edited exception - goToAlertsTab(); - goToOpenedAlerts(); - waitForTheRuleToBeExecuted(); - waitForAlertsToPopulate(2); - - // there should be 2 alerts, one is the original alert and the second is for the newly - // matching doc - cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', '2 alerts'); - }); -}); diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/read_only_view.spect.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/read_only_view.cy.ts similarity index 100% rename from x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/read_only_view.spect.ts rename to x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/read_only_view.cy.ts diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts.ts b/x-pack/plugins/security_solution/cypress/screens/alerts.ts index 2ceeaac0e8ca70..2434b713a64522 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts.ts @@ -7,6 +7,8 @@ export const ADD_EXCEPTION_BTN = '[data-test-subj="add-exception-menu-item"]'; +export const ADD_ENDPOINT_EXCEPTION_BTN = '[data-test-subj="add-endpoint-exception-menu-item"]'; + export const ALERT_COUNT_TABLE_FIRST_ROW_COUNT = '[data-test-subj="alertsCountTable"] tr:nth-child(1) td:nth-child(2) .euiTableCellContent__text'; @@ -22,6 +24,8 @@ export const ALERT_SEVERITY = '[data-test-subj="formatted-field-kibana.alert.sev export const ALERT_DATA_GRID = '[data-test-subj="euiDataGridBody"]'; +export const ALERTS = '[data-test-subj="events-viewer-panel"][data-test-subj="event"]'; + export const ALERTS_COUNT = '[data-test-subj="events-viewer-panel"] [data-test-subj="server-side-event-count"]'; @@ -42,6 +46,10 @@ export const EMPTY_ALERT_TABLE = '[data-test-subj="tGridEmptyState"]'; export const EXPAND_ALERT_BTN = '[data-test-subj="expand-event"]'; +export const TAKE_ACTION_BTN = '[data-test-subj="take-action-dropdown-btn"]'; + +export const TAKE_ACTION_MENU = '[data-test-subj="takeActionPanelMenu"]'; + export const CLOSE_FLYOUT = '[data-test-subj="euiFlyoutCloseButton"]'; export const GROUP_BY_TOP_INPUT = '[data-test-subj="groupByTop"] [data-test-subj="comboBoxInput"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/exceptions.ts b/x-pack/plugins/security_solution/cypress/screens/exceptions.ts index 1ca8ded9463005..bf97d3e2e20392 100644 --- a/x-pack/plugins/security_solution/cypress/screens/exceptions.ts +++ b/x-pack/plugins/security_solution/cypress/screens/exceptions.ts @@ -5,10 +5,11 @@ * 2.0. */ -export const CLOSE_ALERTS_CHECKBOX = - '[data-test-subj="bulk-close-alert-on-add-add-exception-checkbox"]'; +export const CLOSE_ALERTS_CHECKBOX = '[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]'; -export const CONFIRM_BTN = '[data-test-subj="add-exception-confirm-button"]'; +export const CLOSE_SINGLE_ALERT_CHECKBOX = '[data-test-subj="closeAlertOnAddExceptionCheckbox"]'; + +export const CONFIRM_BTN = '[data-test-subj="addExceptionConfirmButton"]'; export const FIELD_INPUT = '[data-test-subj="fieldAutocompleteComboBox"] [data-test-subj="comboBoxInput"]'; @@ -57,9 +58,9 @@ export const EXCEPTION_ITEM_CONTAINER = '[data-test-subj="exceptionEntriesContai export const EXCEPTION_FIELD_LIST = '[data-test-subj="comboBoxOptionsList fieldAutocompleteComboBox-optionsList"]'; -export const EXCEPTION_FLYOUT_TITLE = '[data-test-subj="exception-flyout-title"]'; +export const EXCEPTION_FLYOUT_TITLE = '[data-test-subj="exceptionFlyoutTitle"]'; -export const EXCEPTION_EDIT_FLYOUT_SAVE_BTN = '[data-test-subj="edit-exception-confirm-button"]'; +export const EXCEPTION_EDIT_FLYOUT_SAVE_BTN = '[data-test-subj="editExceptionConfirmButton"]'; export const EXCEPTION_FLYOUT_VERSION_CONFLICT = '[data-test-subj="exceptionsFlyoutVersionConflict"]'; @@ -68,7 +69,7 @@ export const EXCEPTION_FLYOUT_LIST_DELETED_ERROR = '[data-test-subj="errorCallou // Exceptions all items view export const NO_EXCEPTIONS_EXIST_PROMPT = - '[data-test-subj="exceptionItemViewerEmptyPrompts-empty-detection"]'; + '[data-test-subj="exceptionItemViewerEmptyPrompts-empty"]'; export const ADD_EXCEPTIONS_BTN_FROM_EMPTY_PROMPT_BTN = '[data-test-subj="exceptionsEmptyPromptButton"]'; @@ -82,3 +83,25 @@ export const NO_EXCEPTIONS_SEARCH_RESULTS_PROMPT = '[data-test-subj="exceptionItemViewerEmptyPrompts-emptySearch"]'; export const EXCEPTION_ITEM_VIEWER_SEARCH = 'input[data-test-subj="exceptionsViewerSearchBar"]'; + +export const EXCEPTION_CARD_ITEM_NAME = '[data-test-subj="exceptionItemCardHeader-title"]'; + +export const EXCEPTION_CARD_ITEM_CONDITIONS = + '[data-test-subj="exceptionItemCardConditions-condition"]'; + +// Exception flyout components +export const EXCEPTION_ITEM_NAME_INPUT = 'input[data-test-subj="exceptionFlyoutNameInput"]'; + +export const ADD_TO_SHARED_LIST_RADIO_LABEL = '[data-test-subj="addToListsRadioOption"] label'; + +export const ADD_TO_SHARED_LIST_RADIO_INPUT = 'input[id="add_to_lists"]'; + +export const SHARED_LIST_CHECKBOX = '.euiTableRow .euiCheckbox__input'; + +export const ADD_TO_RULE_RADIO_LABEL = 'label [data-test-subj="addToRuleRadioOption"]'; + +export const ADD_TO_RULE_OR_LIST_SECTION = '[data-test-subj="exceptionItemAddToRuleOrListSection"]'; + +export const OS_SELECTION_SECTION = '[data-test-subj="osSelectionDropdown"]'; + +export const OS_INPUT = '[data-test-subj="osSelectionDropdown"] [data-test-subj="comboBoxInput"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/rule_details.ts b/x-pack/plugins/security_solution/cypress/screens/rule_details.ts index bad0770ffd7639..606ee4ae7a043c 100644 --- a/x-pack/plugins/security_solution/cypress/screens/rule_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/rule_details.ts @@ -43,6 +43,8 @@ export const FALSE_POSITIVES_DETAILS = 'False positive examples'; export const INDEX_PATTERNS_DETAILS = 'Index patterns'; +export const ENDPOINT_EXCEPTIONS_TAB = 'a[data-test-subj="navigation-endpoint_exceptions"]'; + export const INDICATOR_INDEX_PATTERNS = 'Indicator index patterns'; export const INDICATOR_INDEX_QUERY = 'Indicator index query'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts index 9df0fb86f218c8..8003f1ba3c304e 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts @@ -16,6 +16,7 @@ import { GROUP_BY_TOP_INPUT, ACKNOWLEDGED_ALERTS_FILTER_BTN, LOADING_ALERTS_PANEL, + MANAGE_ALERT_DETECTION_RULES_BTN, MARK_ALERT_ACKNOWLEDGED_BTN, OPEN_ALERT_BTN, OPENED_ALERTS_FILTER_BTN, @@ -25,6 +26,9 @@ import { TIMELINE_CONTEXT_MENU_BTN, CLOSE_FLYOUT, OPEN_ANALYZER_BTN, + TAKE_ACTION_BTN, + TAKE_ACTION_MENU, + ADD_ENDPOINT_EXCEPTION_BTN, } from '../screens/alerts'; import { REFRESH_BUTTON } from '../screens/security_header'; import { @@ -41,10 +45,44 @@ import { CELL_EXPANSION_POPOVER, USER_DETAILS_LINK, } from '../screens/alerts_details'; +import { FIELD_INPUT } from '../screens/exceptions'; export const addExceptionFromFirstAlert = () => { cy.get(TIMELINE_CONTEXT_MENU_BTN).first().click({ force: true }); - cy.get(ADD_EXCEPTION_BTN).click(); + cy.root() + .pipe(($el) => { + $el.find(ADD_EXCEPTION_BTN).trigger('click'); + return $el.find(FIELD_INPUT); + }) + .should('be.visible'); +}; + +export const openAddEndpointExceptionFromFirstAlert = () => { + cy.get(TIMELINE_CONTEXT_MENU_BTN).first().click({ force: true }); + cy.root() + .pipe(($el) => { + $el.find(ADD_ENDPOINT_EXCEPTION_BTN).trigger('click'); + return $el.find(FIELD_INPUT); + }) + .should('be.visible'); +}; + +export const openAddExceptionFromAlertDetails = () => { + cy.get(EXPAND_ALERT_BTN).first().click({ force: true }); + + cy.root() + .pipe(($el) => { + $el.find(TAKE_ACTION_BTN).trigger('click'); + return $el.find(TAKE_ACTION_MENU); + }) + .should('be.visible'); + + cy.root() + .pipe(($el) => { + $el.find(ADD_EXCEPTION_BTN).trigger('click'); + return $el.find(ADD_EXCEPTION_BTN); + }) + .should('not.be.visible'); }; export const closeFirstAlert = () => { @@ -106,6 +144,10 @@ export const goToClosedAlerts = () => { cy.get(TIMELINE_COLUMN_SPINNER).should('not.exist'); }; +export const goToManageAlertsDetectionRules = () => { + cy.get(MANAGE_ALERT_DETECTION_RULES_BTN).should('exist').click({ force: true }); +}; + export const goToOpenedAlerts = () => { cy.get(OPENED_ALERTS_FILTER_BTN).click({ force: true }); cy.get(REFRESH_BUTTON).should('not.have.attr', 'aria-label', 'Needs updating'); diff --git a/x-pack/plugins/security_solution/cypress/tasks/api_calls/exceptions.ts b/x-pack/plugins/security_solution/cypress/tasks/api_calls/exceptions.ts index 68909d37dd1e48..fd070cfcda55e3 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/api_calls/exceptions.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/api_calls/exceptions.ts @@ -7,6 +7,14 @@ import type { ExceptionList, ExceptionListItem } from '../../objects/exception'; +export const createEndpointExceptionList = () => + cy.request({ + method: 'POST', + url: '/api/endpoint_list', + headers: { 'kbn-xsrf': 'cypress-creds' }, + failOnStatusCode: false, + }); + export const createExceptionList = ( exceptionList: ExceptionList, exceptionListId = 'exception_list_testing' diff --git a/x-pack/plugins/security_solution/cypress/tasks/exceptions.ts b/x-pack/plugins/security_solution/cypress/tasks/exceptions.ts index 5525fdcca8fcf2..1e89e14c1280e6 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/exceptions.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/exceptions.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { Exception } from '../objects/exception'; import { FIELD_INPUT, OPERATOR_INPUT, @@ -14,6 +15,15 @@ import { VALUES_INPUT, VALUES_MATCH_ANY_INPUT, EXCEPTION_EDIT_FLYOUT_SAVE_BTN, + CLOSE_ALERTS_CHECKBOX, + CONFIRM_BTN, + EXCEPTION_ITEM_NAME_INPUT, + CLOSE_SINGLE_ALERT_CHECKBOX, + ADD_TO_RULE_RADIO_LABEL, + ADD_TO_SHARED_LIST_RADIO_LABEL, + SHARED_LIST_CHECKBOX, + OS_SELECTION_SECTION, + OS_INPUT, } from '../screens/exceptions'; export const addExceptionEntryFieldValueOfItemX = ( @@ -56,8 +66,72 @@ export const closeExceptionBuilderFlyout = () => { export const editException = (updatedField: string, itemIndex = 0, fieldIndex = 0) => { addExceptionEntryFieldValueOfItemX(`${updatedField}{downarrow}{enter}`, itemIndex, fieldIndex); addExceptionEntryFieldValueValue('foo', itemIndex); +}; + +export const addExceptionFlyoutItemName = (name: string) => { + cy.root() + .pipe(($el) => { + return $el.find(EXCEPTION_ITEM_NAME_INPUT); + }) + .type(`${name}{enter}`) + .should('have.value', name); +}; + +export const editExceptionFlyoutItemName = (name: string) => { + cy.root() + .pipe(($el) => { + return $el.find(EXCEPTION_ITEM_NAME_INPUT); + }) + .clear() + .type(`${name}{enter}`) + .should('have.value', name); +}; +export const selectBulkCloseAlerts = () => { + cy.get(CLOSE_ALERTS_CHECKBOX).click({ force: true }); +}; + +export const selectCloseSingleAlerts = () => { + cy.get(CLOSE_SINGLE_ALERT_CHECKBOX).click({ force: true }); +}; + +export const addExceptionConditions = (exception: Exception) => { + cy.root() + .pipe(($el) => { + return $el.find(FIELD_INPUT); + }) + .type(`${exception.field}{downArrow}{enter}`); + cy.get(OPERATOR_INPUT).type(`${exception.operator}{enter}`); + exception.values.forEach((value) => { + cy.get(VALUES_INPUT).type(`${value}{enter}`); + }); +}; + +export const submitNewExceptionItem = () => { + cy.get(CONFIRM_BTN).click(); + cy.get(CONFIRM_BTN).should('not.exist'); +}; + +export const submitEditedExceptionItem = () => { cy.get(EXCEPTION_EDIT_FLYOUT_SAVE_BTN).click(); - cy.get(EXCEPTION_EDIT_FLYOUT_SAVE_BTN).should('have.attr', 'disabled'); cy.get(EXCEPTION_EDIT_FLYOUT_SAVE_BTN).should('not.exist'); }; + +export const selectAddToRuleRadio = () => { + cy.get(ADD_TO_RULE_RADIO_LABEL).click(); +}; + +export const selectSharedListToAddExceptionTo = (numListsToCheck = 1) => { + cy.get(ADD_TO_SHARED_LIST_RADIO_LABEL).click(); + for (let i = 0; i < numListsToCheck; i++) { + cy.get(SHARED_LIST_CHECKBOX) + .eq(i) + .pipe(($el) => $el.trigger('click')) + .should('be.checked'); + } +}; + +export const selectOs = (os: string) => { + cy.get(OS_SELECTION_SECTION).should('exist'); + cy.get(OS_INPUT).type(`${os}{downArrow}{enter}`); +}; diff --git a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts index 3f7d31f0604734..bbfadc337f5e25 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts @@ -10,13 +10,8 @@ import { RULE_STATUS } from '../screens/create_new_rule'; import { ADD_EXCEPTIONS_BTN_FROM_EMPTY_PROMPT_BTN, ADD_EXCEPTIONS_BTN_FROM_VIEWER_HEADER, - CLOSE_ALERTS_CHECKBOX, - CONFIRM_BTN, EXCEPTION_ITEM_VIEWER_SEARCH, FIELD_INPUT, - LOADING_SPINNER, - OPERATOR_INPUT, - VALUES_INPUT, } from '../screens/exceptions'; import { ALERTS_TAB, @@ -32,8 +27,15 @@ import { DETAILS_DESCRIPTION, EXCEPTION_ITEM_ACTIONS_BUTTON, EDIT_EXCEPTION_BTN, + ENDPOINT_EXCEPTIONS_TAB, EDIT_RULE_SETTINGS_LINK, } from '../screens/rule_details'; +import { + addExceptionConditions, + addExceptionFlyoutItemName, + selectBulkCloseAlerts, + submitNewExceptionItem, +} from './exceptions'; import { addsFields, closeFieldsBrowser, filterFieldsBrowser } from './fields_browser'; export const enablesRule = () => { @@ -46,21 +48,6 @@ export const enablesRule = () => { }); }; -export const addsException = (exception: Exception) => { - cy.get(LOADING_SPINNER).should('exist'); - cy.get(LOADING_SPINNER).should('not.exist'); - cy.get(FIELD_INPUT).should('exist'); - cy.get(FIELD_INPUT).type(`${exception.field}{enter}`); - cy.get(OPERATOR_INPUT).type(`${exception.operator}{enter}`); - exception.values.forEach((value) => { - cy.get(VALUES_INPUT).type(`${value}{enter}`); - }); - cy.get(CLOSE_ALERTS_CHECKBOX).click({ force: true }); - cy.get(CONFIRM_BTN).click(); - cy.get(CONFIRM_BTN).should('have.attr', 'disabled'); - cy.get(CONFIRM_BTN).should('not.exist'); -}; - export const addsFieldsToTimeline = (search: string, fields: string[]) => { cy.get(FIELDS_BROWSER_BTN).click(); filterFieldsBrowser(search); @@ -86,7 +73,7 @@ export const searchForExceptionItem = (query: string) => { }); }; -const addExceptionFlyoutFromViewerHeader = () => { +export const addExceptionFlyoutFromViewerHeader = () => { cy.root() .pipe(($el) => { $el.find(ADD_EXCEPTIONS_BTN_FROM_VIEWER_HEADER).trigger('click'); @@ -97,28 +84,16 @@ const addExceptionFlyoutFromViewerHeader = () => { export const addExceptionFromRuleDetails = (exception: Exception) => { addExceptionFlyoutFromViewerHeader(); - cy.get(FIELD_INPUT).type(`${exception.field}{downArrow}{enter}`); - cy.get(OPERATOR_INPUT).type(`${exception.operator}{enter}`); - exception.values.forEach((value) => { - cy.get(VALUES_INPUT).type(`${value}{enter}`); - }); - cy.get(CLOSE_ALERTS_CHECKBOX).click({ force: true }); - cy.get(CONFIRM_BTN).click(); - cy.get(CONFIRM_BTN).should('have.attr', 'disabled'); - cy.get(CONFIRM_BTN).should('not.exist'); + addExceptionConditions(exception); + submitNewExceptionItem(); }; -export const addFirstExceptionFromRuleDetails = (exception: Exception) => { +export const addFirstExceptionFromRuleDetails = (exception: Exception, name: string) => { openExceptionFlyoutFromEmptyViewerPrompt(); - cy.get(FIELD_INPUT).type(`${exception.field}{downArrow}{enter}`); - cy.get(OPERATOR_INPUT).type(`${exception.operator}{enter}`); - exception.values.forEach((value) => { - cy.get(VALUES_INPUT).type(`${value}{enter}`); - }); - cy.get(CLOSE_ALERTS_CHECKBOX).click({ force: true }); - cy.get(CONFIRM_BTN).click(); - cy.get(CONFIRM_BTN).should('have.attr', 'disabled'); - cy.get(CONFIRM_BTN).should('not.exist'); + addExceptionFlyoutItemName(name); + addExceptionConditions(exception); + selectBulkCloseAlerts(); + submitNewExceptionItem(); }; export const goToAlertsTab = () => { @@ -130,9 +105,13 @@ export const goToExceptionsTab = () => { cy.get(EXCEPTIONS_TAB).click(); }; +export const goToEndpointExceptionsTab = () => { + cy.get(ENDPOINT_EXCEPTIONS_TAB).should('exist'); + cy.get(ENDPOINT_EXCEPTIONS_TAB).click(); +}; + export const openEditException = (index = 0) => { cy.get(EXCEPTION_ITEM_ACTIONS_BUTTON).eq(index).click({ force: true }); - cy.get(EDIT_EXCEPTION_BTN).eq(index).click({ force: true }); }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.test.tsx index de5eca78aaffba..0613f08b7f5729 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.test.tsx @@ -7,21 +7,24 @@ import React from 'react'; import type { ReactWrapper } from 'enzyme'; -import { mount } from 'enzyme'; +import { mount, shallow } from 'enzyme'; import { waitFor } from '@testing-library/react'; -import { AddExceptionFlyout } from '.'; -import { getExceptionBuilderComponentLazy } from '@kbn/lists-plugin/public'; -import { useAsync } from '@kbn/securitysolution-hook-utils'; import { getExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_schema.mock'; -import { useFetchIndex } from '../../../../common/containers/source'; +import { getExceptionBuilderComponentLazy } from '@kbn/lists-plugin/public'; +import type { EntriesArray } from '@kbn/securitysolution-io-ts-list-types'; +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; +import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; import { createStubIndexPattern, stubIndexPattern } from '@kbn/data-plugin/common/stubs'; -import { useAddOrUpdateException } from '../../logic/use_add_exception'; -import { useFetchOrCreateRuleExceptionList } from '../../logic/use_fetch_or_create_rule_exception_list'; + +import { AddExceptionFlyout } from '.'; +import { useFetchIndex } from '../../../../common/containers/source'; +import { useCreateOrUpdateException } from '../../logic/use_create_update_exception'; +import { useFetchIndexPatterns } from '../../logic/use_exception_flyout_data'; import { useSignalIndex } from '../../../../detections/containers/detection_engine/alerts/use_signal_index'; import * as helpers from '../../utils/helpers'; -import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; -import type { EntriesArray } from '@kbn/securitysolution-io-ts-list-types'; +import type { Rule } from '../../../../detections/containers/detection_engine/rules/types'; +import * as i18n from './translations'; import { TestProviders } from '../../../../common/mock'; @@ -29,59 +32,61 @@ import { getRulesEqlSchemaMock, getRulesSchemaMock, } from '../../../../../common/detection_engine/schemas/response/rules_schema.mocks'; -import { useRuleAsync } from '../../../../detections/containers/detection_engine/rules/use_rule_async'; import type { AlertData } from '../../utils/types'; +import { useFindRules } from '../../../../detections/pages/detection_engine/rules/all/rules_table/use_find_rules'; +import { useFindExceptionListReferences } from '../../logic/use_find_references'; jest.mock('../../../../detections/containers/detection_engine/alerts/use_signal_index'); jest.mock('../../../../common/lib/kibana'); jest.mock('../../../../common/containers/source'); jest.mock('../../../../detections/containers/detection_engine/rules'); -jest.mock('../../logic/use_add_exception'); -jest.mock('../../logic/use_fetch_or_create_rule_exception_list'); +jest.mock('../../logic/use_create_update_exception'); +jest.mock('../../logic/use_exception_flyout_data'); +jest.mock('../../logic/use_find_references'); jest.mock('@kbn/securitysolution-hook-utils', () => ({ ...jest.requireActual('@kbn/securitysolution-hook-utils'), useAsync: jest.fn(), })); jest.mock('../../../../detections/containers/detection_engine/rules/use_rule_async'); jest.mock('@kbn/lists-plugin/public'); +jest.mock('../../../../detections/pages/detection_engine/rules/all/rules_table/use_find_rules'); const mockGetExceptionBuilderComponentLazy = getExceptionBuilderComponentLazy as jest.Mock< ReturnType >; -const mockUseAsync = useAsync as jest.Mock>; -const mockUseAddOrUpdateException = useAddOrUpdateException as jest.Mock< - ReturnType +const mockUseAddOrUpdateException = useCreateOrUpdateException as jest.Mock< + ReturnType >; -const mockUseFetchOrCreateRuleExceptionList = useFetchOrCreateRuleExceptionList as jest.Mock< - ReturnType +const mockFetchIndexPatterns = useFetchIndexPatterns as jest.Mock< + ReturnType >; const mockUseSignalIndex = useSignalIndex as jest.Mock>>; const mockUseFetchIndex = useFetchIndex as jest.Mock; -const mockUseRuleAsync = useRuleAsync as jest.Mock; +const mockUseFindRules = useFindRules as jest.Mock; +const mockUseFindExceptionListReferences = useFindExceptionListReferences as jest.Mock; + +const alertDataMock: AlertData = { + '@timestamp': '1234567890', + _id: 'test-id', + file: { path: 'test/path' }, +}; describe('When the add exception modal is opened', () => { - const ruleName = 'test rule'; let defaultEndpointItems: jest.SpyInstance< ReturnType >; beforeEach(() => { mockGetExceptionBuilderComponentLazy.mockReturnValue( - + ); defaultEndpointItems = jest.spyOn(helpers, 'defaultEndpointExceptionItems'); - mockUseAsync.mockImplementation(() => ({ - start: jest.fn(), - loading: false, - error: {}, - result: true, + mockUseAddOrUpdateException.mockImplementation(() => [false, jest.fn()]); + mockFetchIndexPatterns.mockImplementation(() => ({ + isLoading: false, + indexPatterns: stubIndexPattern, })); - mockUseAddOrUpdateException.mockImplementation(() => [{ isLoading: false }, jest.fn()]); - mockUseFetchOrCreateRuleExceptionList.mockImplementation(() => [ - false, - getExceptionListSchemaMock(), - ]); mockUseSignalIndex.mockImplementation(() => ({ loading: false, signalIndexName: 'mock-siem-signals-index', @@ -92,9 +97,48 @@ describe('When the add exception modal is opened', () => { indexPatterns: stubIndexPattern, }, ]); - mockUseRuleAsync.mockImplementation(() => ({ - rule: getRulesSchemaMock(), + mockUseFindRules.mockImplementation(() => ({ + data: { + rules: [ + { + ...getRulesSchemaMock(), + exceptions_list: [], + } as Rule, + ], + total: 1, + }, + isFetched: true, })); + mockUseFindExceptionListReferences.mockImplementation(() => [ + false, + false, + { + my_list_id: { + ...getExceptionListSchemaMock(), + id: '123', + list_id: 'my_list_id', + namespace_type: 'single', + type: ExceptionListTypeEnum.DETECTION, + name: 'My exception list', + referenced_rules: [ + { + id: '345', + name: 'My rule', + rule_id: 'my_rule_id', + exception_lists: [ + { + id: '123', + list_id: 'my_list_id', + namespace_type: 'single', + type: ExceptionListTypeEnum.DETECTION, + }, + ], + }, + ], + }, + }, + jest.fn(), + ]); }); afterEach(() => { @@ -106,87 +150,705 @@ describe('When the add exception modal is opened', () => { let wrapper: ReactWrapper; beforeEach(() => { // Mocks one of the hooks as loading - mockUseFetchIndex.mockImplementation(() => [ - true, - { - indexPatterns: stubIndexPattern, - }, - ]); + mockFetchIndexPatterns.mockImplementation(() => ({ + isLoading: true, + indexPatterns: { fields: [], title: 'foo' }, + })); + wrapper = mount( ); }); + it('should show the loading spinner', () => { expect(wrapper.find('[data-test-subj="loadingAddExceptionFlyout"]').exists()).toBeTruthy(); }); }); - describe('when there is no alert data passed to an endpoint list exception', () => { + describe('exception list type of "endpoint"', () => { + describe('common functionality to test regardless of alert input', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + wrapper = mount( + + + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) + ); + }); + + it('displays proper flyout and button text', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutTitle"]').at(1).text()).toEqual( + i18n.ADD_ENDPOINT_EXCEPTION + ); + expect(wrapper.find('[data-test-subj="addExceptionConfirmButton"]').at(1).text()).toEqual( + i18n.ADD_ENDPOINT_EXCEPTION + ); + }); + + it('should render item name input', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutNameInput"]').exists()).toBeTruthy(); + }); + + it('should render the exception builder', () => { + expect(wrapper.find('[data-test-subj="alertExceptionBuilder"]').exists()).toBeTruthy(); + }); + + it('does NOT render options to add exception to a rule or shared list', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemAddToRuleOrListSection"]').exists() + ).toBeFalsy(); + }); + + it('should contain the endpoint specific documentation text', () => { + expect(wrapper.find('[data-test-subj="addExceptionEndpointText"]').exists()).toBeTruthy(); + }); + + it('should NOT display the eql sequence callout', () => { + expect(wrapper.find('[data-test-subj="eqlSequenceCallout"]').exists()).not.toBeTruthy(); + }); + }); + + describe('alert data is passed in', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + wrapper = mount( + + + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) + ); + }); + + it('should prepopulate endpoint items', () => { + expect(defaultEndpointItems).toHaveBeenCalled(); + }); + + it('should render the close single alert checkbox', () => { + expect( + wrapper.find('[data-test-subj="closeAlertOnAddExceptionCheckbox"]').exists() + ).toBeTruthy(); + }); + + it('should have the bulk close alerts checkbox disabled', () => { + expect( + wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() + ).toBeDisabled(); + }); + + it('should NOT render the os selection dropdown', () => { + expect(wrapper.find('[data-test-subj="osSelectionDropdown"]').exists()).toBeFalsy(); + }); + }); + + describe('bulk closeable alert data is passed in', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + mockUseFetchIndex.mockImplementation(() => [ + false, + { + indexPatterns: createStubIndexPattern({ + spec: { + id: '1234', + title: 'filebeat-*', + fields: { + 'event.code': { + name: 'event.code', + type: 'string', + aggregatable: true, + searchable: true, + }, + 'file.path.caseless': { + name: 'file.path.caseless', + type: 'string', + aggregatable: true, + searchable: true, + }, + subject_name: { + name: 'subject_name', + type: 'string', + aggregatable: true, + searchable: true, + }, + trusted: { + name: 'trusted', + type: 'string', + aggregatable: true, + searchable: true, + }, + 'file.hash.sha256': { + name: 'file.hash.sha256', + type: 'string', + aggregatable: true, + searchable: true, + }, + }, + }, + }), + }, + ]); + wrapper = mount( + + + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ + exceptionItems: [ + { + ...getExceptionListItemSchemaMock(), + entries: [{ field: 'file.hash.sha256', operator: 'included', type: 'match' }], + }, + ], + }) + ); + }); + + it('should prepopulate endpoint items', () => { + expect(defaultEndpointItems).toHaveBeenCalled(); + }); + + it('should render the close single alert checkbox', () => { + expect( + wrapper.find('[data-test-subj="closeAlertOnAddExceptionCheckbox"]').exists() + ).toBeTruthy(); + }); + + it('should have the bulk close checkbox enabled', () => { + expect( + wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() + ).not.toBeDisabled(); + }); + + describe('when a "is in list" entry is added', () => { + it('should have the bulk close checkbox disabled', async () => { + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + + await waitFor(() => + callProps.onChange({ + exceptionItems: [ + ...callProps.exceptionListItems, + { + ...getExceptionListItemSchemaMock(), + entries: [ + { field: 'event.code', operator: 'included', type: 'list' }, + ] as EntriesArray, + }, + ], + }) + ); + + expect( + wrapper + .find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]') + .getDOMNode() + ).toBeDisabled(); + }); + }); + }); + + describe('alert data NOT passed in', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + wrapper = mount( + + + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) + ); + }); + + it('should NOT render the close single alert checkbox', () => { + expect( + wrapper.find('[data-test-subj="closeAlertOnAddExceptionCheckbox"]').exists() + ).toBeFalsy(); + }); + + it('should render the os selection dropdown', () => { + expect(wrapper.find('[data-test-subj="osSelectionDropdown"]').exists()).toBeTruthy(); + }); + }); + }); + + describe('exception list type is NOT "endpoint" ("rule_default" or "detection")', () => { + describe('common features to test regardless of alert input', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + wrapper = mount( + + + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ exceptionItems: [getExceptionListItemSchemaMock()] }) + ); + }); + + it('displays proper flyout and button text', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutTitle"]').at(1).text()).toEqual( + i18n.CREATE_RULE_EXCEPTION + ); + expect(wrapper.find('[data-test-subj="addExceptionConfirmButton"]').at(1).text()).toEqual( + i18n.CREATE_RULE_EXCEPTION + ); + }); + + it('should NOT prepopulate items', () => { + expect(defaultEndpointItems).not.toHaveBeenCalled(); + }); + + // button is disabled until there are exceptions, a name, and selection made on + // add to rule or lists section + it('has the add exception button disabled', () => { + expect( + wrapper.find('button[data-test-subj="addExceptionConfirmButton"]').getDOMNode() + ).toBeDisabled(); + }); + + it('should render item name input', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutNameInput"]').exists()).toBeTruthy(); + }); + + it('should NOT render the os selection dropdown', () => { + expect(wrapper.find('[data-test-subj="osSelectionDropdown"]').exists()).toBeFalsy(); + }); + + it('should render the exception builder', () => { + expect(wrapper.find('[data-test-subj="alertExceptionBuilder"]').exists()).toBeTruthy(); + }); + + it('renders options to add exception to a rule or shared list and has "add to rule" selected by default', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemAddToRuleOrListSection"]').exists() + ).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="addToRuleOptionsRadio"] input').getDOMNode() + ).toBeChecked(); + }); + + it('should NOT contain the endpoint specific documentation text', () => { + expect(wrapper.find('[data-test-subj="addExceptionEndpointText"]').exists()).toBeFalsy(); + }); + + it('should NOT display the eql sequence callout', () => { + expect(wrapper.find('[data-test-subj="eqlSequenceCallout"]').exists()).not.toBeTruthy(); + }); + }); + + describe('alert data is passed in', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + wrapper = mount( + + + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ exceptionItems: [getExceptionListItemSchemaMock()] }) + ); + }); + + it('should render the close single alert checkbox', () => { + expect( + wrapper.find('[data-test-subj="closeAlertOnAddExceptionCheckbox"]').exists() + ).toBeTruthy(); + expect( + wrapper.find('input[data-test-subj="closeAlertOnAddExceptionCheckbox"]').getDOMNode() + ).not.toBeDisabled(); + }); + + it('should have the bulk close checkbox disabled', () => { + expect( + wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() + ).toBeDisabled(); + }); + }); + + describe('bulk closeable alert data is passed in', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + mockUseFetchIndex.mockImplementation(() => [ + false, + { + indexPatterns: createStubIndexPattern({ + spec: { + id: '1234', + title: 'filebeat-*', + fields: { + 'event.code': { + name: 'event.code', + type: 'string', + aggregatable: true, + searchable: true, + }, + 'file.path.caseless': { + name: 'file.path.caseless', + type: 'string', + aggregatable: true, + searchable: true, + }, + subject_name: { + name: 'subject_name', + type: 'string', + aggregatable: true, + searchable: true, + }, + trusted: { + name: 'trusted', + type: 'string', + aggregatable: true, + searchable: true, + }, + 'file.hash.sha256': { + name: 'file.hash.sha256', + type: 'string', + aggregatable: true, + searchable: true, + }, + }, + }, + }), + }, + ]); + wrapper = mount( + + + + ); + + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ + exceptionItems: [ + { + ...getExceptionListItemSchemaMock(), + entries: [{ field: 'file.hash.sha256', operator: 'included', type: 'match' }], + }, + ], + }) + ); + }); + + it('should render the close single alert checkbox', () => { + expect( + wrapper.find('[data-test-subj="closeAlertOnAddExceptionCheckbox"]').exists() + ).toBeTruthy(); + expect( + wrapper.find('input[data-test-subj="closeAlertOnAddExceptionCheckbox"]').getDOMNode() + ).not.toBeDisabled(); + }); + + it('should have the bulk close checkbox enabled', () => { + expect( + wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() + ).not.toBeDisabled(); + }); + + describe('when a "is in list" entry is added', () => { + it('should have the bulk close checkbox disabled', async () => { + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + + await waitFor(() => + callProps.onChange({ + exceptionItems: [ + ...callProps.exceptionListItems, + { + ...getExceptionListItemSchemaMock(), + entries: [ + { field: 'event.code', operator: 'included', type: 'list' }, + ] as EntriesArray, + }, + ], + }) + ); + + expect( + wrapper + .find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]') + .getDOMNode() + ).toBeDisabled(); + }); + }); + }); + + describe('alert data NOT passed in', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + wrapper = mount( + + + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) + ); + }); + + it('should NOT render the close single alert checkbox', () => { + expect( + wrapper.find('[data-test-subj="closeAlertOnAddExceptionCheckbox"]').exists() + ).toBeFalsy(); + }); + + it('should have the bulk close checkbox disabled', () => { + expect( + wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() + ).toBeDisabled(); + }); + }); + }); + + /* Say for example, from the lists management or lists details page */ + describe('when no rules are passed in', () => { let wrapper: ReactWrapper; beforeEach(async () => { wrapper = mount( ); const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; - await waitFor(() => callProps.onChange({ exceptionItems: [] })); - }); - it('has the add exception button disabled', () => { - expect( - wrapper.find('button[data-test-subj="add-exception-confirm-button"]').getDOMNode() - ).toBeDisabled(); + await waitFor(() => + callProps.onChange({ exceptionItems: [getExceptionListItemSchemaMock()] }) + ); }); - it('should render the exception builder', () => { - expect(wrapper.find('[data-test-subj="alert-exception-builder"]').exists()).toBeTruthy(); + + it('allows large value lists', () => { + expect(wrapper.find('ExceptionsConditions').prop('allowLargeValueLists')).toBeTruthy(); }); - it('should not render the close on add exception checkbox', () => { + + it('defaults to selecting add to rule option, displaying rules selection table', () => { + expect(wrapper.find('[data-test-subj="addExceptionToRulesTable"]').exists()).toBeTruthy(); expect( - wrapper.find('[data-test-subj="close-alert-on-add-add-exception-checkbox"]').exists() - ).toBeFalsy(); - }); - it('should contain the endpoint specific documentation text', () => { - expect(wrapper.find('[data-test-subj="add-exception-endpoint-text"]').exists()).toBeTruthy(); - }); - it('should render the os selection dropdown', () => { - expect(wrapper.find('[data-test-subj="os-selection-dropdown"]').exists()).toBeTruthy(); + wrapper.find('[data-test-subj="selectRulesToAddToOptionRadio"] input').getDOMNode() + ).toHaveAttribute('checked'); }); }); - describe('when there is alert data passed to an endpoint list exception', () => { + /* Say for example, from the rule details page, exceptions tab, or from an alert */ + describe('when a single rule is passed in', () => { let wrapper: ReactWrapper; beforeEach(async () => { - const alertDataMock: AlertData = { - '@timestamp': '1234567890', - _id: 'test-id', - file: { path: 'test/path' }, - }; wrapper = mount( ); @@ -195,119 +857,304 @@ describe('When the add exception modal is opened', () => { callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) ); }); - it('has the add exception button enabled', () => { - expect( - wrapper.find('button[data-test-subj="add-exception-confirm-button"]').getDOMNode() - ).not.toBeDisabled(); + it('does not allow large value list selection for query rule', () => { + const shallowWrapper = shallow( + + ); + + expect(shallowWrapper.find('ExceptionsConditions').prop('allowLargeValueLists')).toBeTruthy(); }); - it('should render the exception builder', () => { - expect(wrapper.find('[data-test-subj="alert-exception-builder"]').exists()).toBeTruthy(); + + it('does not allow large value list selection if EQL rule', () => { + const shallowWrapper = shallow( + + ); + + expect(shallowWrapper.find('ExceptionsConditions').prop('allowLargeValueLists')).toBeFalsy(); }); - it('should prepopulate endpoint items', () => { - expect(defaultEndpointItems).toHaveBeenCalled(); + + it('does not allow large value list selection if threshold rule', () => { + const shallowWrapper = shallow( + + ); + + expect(shallowWrapper.find('ExceptionsConditions').prop('allowLargeValueLists')).toBeFalsy(); }); - it('should render the close on add exception checkbox', () => { + + it('does not allow large value list selection if new trems rule', () => { + const shallowWrapper = shallow( + + ); + + expect(shallowWrapper.find('ExceptionsConditions').prop('allowLargeValueLists')).toBeFalsy(); + }); + + it('defaults to selecting add to rule radio option', () => { expect( - wrapper.find('[data-test-subj="close-alert-on-add-add-exception-checkbox"]').exists() + wrapper.find('[data-test-subj="exceptionItemAddToRuleOrListSection"]').exists() ).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="addToRuleOptionsRadio"] input').getDOMNode() + ).toBeChecked(); }); - it('should have the bulk close checkbox disabled', () => { + + it('disables add to shared lists option if rule has no shared exception lists attached already', () => { expect( - wrapper - .find('input[data-test-subj="bulk-close-alert-on-add-add-exception-checkbox"]') - .getDOMNode() + wrapper.find('[data-test-subj="addToListsRadioOption"] input').getDOMNode() ).toBeDisabled(); }); - it('should contain the endpoint specific documentation text', () => { - expect(wrapper.find('[data-test-subj="add-exception-endpoint-text"]').exists()).toBeTruthy(); - }); - it('should not display the eql sequence callout', () => { - expect(wrapper.find('[data-test-subj="eql-sequence-callout"]').exists()).not.toBeTruthy(); - }); - it('should not render the os selection dropdown', () => { - expect(wrapper.find('[data-test-subj="os-selection-dropdown"]').exists()).toBeFalsy(); + + it('enables add to shared lists option if rule has shared list', () => { + wrapper = mount( + + + + ); + + expect( + wrapper.find('[data-test-subj="addToListsRadioOption"] input').getDOMNode() + ).toBeEnabled(); }); }); - describe('when there is alert data passed to a detection list exception', () => { + /* Say for example, add exception item from rules bulk action */ + describe('when multiple rules are passed in - bulk action', () => { let wrapper: ReactWrapper; beforeEach(async () => { - const alertDataMock: AlertData = { - '@timestamp': '1234567890', - _id: 'test-id', - file: { path: 'test/path' }, - }; wrapper = mount( ); const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; await waitFor(() => - callProps.onChange({ exceptionItems: [getExceptionListItemSchemaMock()] }) + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) ); }); - it('has the add exception button enabled', () => { - expect( - wrapper.find('button[data-test-subj="add-exception-confirm-button"]').getDOMNode() - ).not.toBeDisabled(); - }); - it('should render the exception builder', () => { - expect(wrapper.find('[data-test-subj="alert-exception-builder"]').exists()).toBeTruthy(); - }); - it('should not prepopulate endpoint items', () => { - expect(defaultEndpointItems).not.toHaveBeenCalled(); + + it('allows large value lists', () => { + const shallowWrapper = shallow( + + ); + + expect(shallowWrapper.find('ExceptionsConditions').prop('allowLargeValueLists')).toBeTruthy(); }); - it('should render the close on add exception checkbox', () => { + + it('defaults to selecting add to rules radio option', () => { expect( - wrapper.find('[data-test-subj="close-alert-on-add-add-exception-checkbox"]').exists() + wrapper.find('[data-test-subj="exceptionItemAddToRuleOrListSection"]').exists() ).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="addToRulesOptionsRadio"] input').getDOMNode() + ).toBeChecked(); }); - it('should have the bulk close checkbox disabled', () => { + + it('disables add to shared lists option if rules have no shared lists in common', () => { expect( - wrapper - .find('input[data-test-subj="bulk-close-alert-on-add-add-exception-checkbox"]') - .getDOMNode() + wrapper.find('[data-test-subj="addToListsRadioOption"] input').getDOMNode() ).toBeDisabled(); }); - it('should not display the eql sequence callout', () => { - expect(wrapper.find('[data-test-subj="eql-sequence-callout"]').exists()).not.toBeTruthy(); + + it('enables add to shared lists option if rules have at least one shared list in common', () => { + wrapper = mount( + + + + ); + + expect( + wrapper.find('[data-test-subj="addToListsRadioOption"] input').getDOMNode() + ).toBeEnabled(); }); }); describe('when there is an exception being created on a sequence eql rule type', () => { let wrapper: ReactWrapper; beforeEach(async () => { - mockUseRuleAsync.mockImplementation(() => ({ - rule: { - ...getRulesEqlSchemaMock(), - query: - 'sequence [process where process.name = "test.exe"] [process where process.name = "explorer.exe"]', - }, - })); - const alertDataMock: AlertData = { - '@timestamp': '1234567890', - _id: 'test-id', - file: { path: 'test/path' }, - }; wrapper = mount( ); @@ -316,177 +1163,58 @@ describe('When the add exception modal is opened', () => { callProps.onChange({ exceptionItems: [getExceptionListItemSchemaMock()] }) ); }); - it('has the add exception button enabled', () => { - expect( - wrapper.find('button[data-test-subj="add-exception-confirm-button"]').getDOMNode() - ).not.toBeDisabled(); - }); + it('should render the exception builder', () => { - expect(wrapper.find('[data-test-subj="alert-exception-builder"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="alertExceptionBuilder"]').exists()).toBeTruthy(); }); + it('should not prepopulate endpoint items', () => { expect(defaultEndpointItems).not.toHaveBeenCalled(); }); - it('should render the close on add exception checkbox', () => { + + it('should render the close single alert checkbox', () => { expect( - wrapper.find('[data-test-subj="close-alert-on-add-add-exception-checkbox"]').exists() + wrapper.find('[data-test-subj="closeAlertOnAddExceptionCheckbox"]').exists() ).toBeTruthy(); }); + it('should have the bulk close checkbox disabled', () => { expect( - wrapper - .find('input[data-test-subj="bulk-close-alert-on-add-add-exception-checkbox"]') - .getDOMNode() + wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() ).toBeDisabled(); }); + it('should display the eql sequence callout', () => { - expect(wrapper.find('[data-test-subj="eql-sequence-callout"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="eqlSequenceCallout"]').exists()).toBeTruthy(); }); }); - describe('when there is bulk-closeable alert data passed to an endpoint list exception', () => { - let wrapper: ReactWrapper; - - beforeEach(async () => { - mockUseFetchIndex.mockImplementation(() => [ - false, - { - indexPatterns: createStubIndexPattern({ - spec: { - id: '1234', - title: 'filebeat-*', - fields: { - 'event.code': { - name: 'event.code', - type: 'string', - aggregatable: true, - searchable: true, - }, - 'file.path.caseless': { - name: 'file.path.caseless', - type: 'string', - aggregatable: true, - searchable: true, - }, - subject_name: { - name: 'subject_name', - type: 'string', - aggregatable: true, - searchable: true, - }, - trusted: { - name: 'trusted', - type: 'string', - aggregatable: true, - searchable: true, - }, - 'file.hash.sha256': { - name: 'file.hash.sha256', - type: 'string', - aggregatable: true, - searchable: true, - }, - }, - }, - }), - }, - ]); - - const alertDataMock: AlertData = { - '@timestamp': '1234567890', - _id: 'test-id', - file: { path: 'test/path' }, - }; - wrapper = mount( + describe('error states', () => { + test('when there are exception builder errors submit button is disabled', async () => { + const wrapper = mount( ); - const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; - await waitFor(() => { - return callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }); - }); - }); - it('has the add exception button enabled', async () => { + await waitFor(() => callProps.onChange({ exceptionItems: [], errorExists: true })); expect( - wrapper.find('button[data-test-subj="add-exception-confirm-button"]').getDOMNode() - ).not.toBeDisabled(); - }); - it('should render the exception builder', () => { - expect(wrapper.find('[data-test-subj="alert-exception-builder"]').exists()).toBeTruthy(); - }); - it('should prepopulate endpoint items', () => { - expect(defaultEndpointItems).toHaveBeenCalled(); - }); - it('should render the close on add exception checkbox', () => { - expect( - wrapper.find('[data-test-subj="close-alert-on-add-add-exception-checkbox"]').exists() - ).toBeTruthy(); - }); - it('should contain the endpoint specific documentation text', () => { - expect(wrapper.find('[data-test-subj="add-exception-endpoint-text"]').exists()).toBeTruthy(); - }); - it('should have the bulk close checkbox enabled', () => { - expect( - wrapper - .find('input[data-test-subj="bulk-close-alert-on-add-add-exception-checkbox"]') - .getDOMNode() - ).not.toBeDisabled(); - }); - describe('when a "is in list" entry is added', () => { - it('should have the bulk close checkbox disabled', async () => { - const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; - - await waitFor(() => - callProps.onChange({ - exceptionItems: [ - ...callProps.exceptionListItems, - { - ...getExceptionListItemSchemaMock(), - entries: [ - { field: 'event.code', operator: 'included', type: 'list' }, - ] as EntriesArray, - }, - ], - }) - ); - - expect( - wrapper - .find('input[data-test-subj="bulk-close-alert-on-add-add-exception-checkbox"]') - .getDOMNode() - ).toBeDisabled(); - }); + wrapper.find('button[data-test-subj="addExceptionConfirmButton"]').getDOMNode() + ).toBeDisabled(); }); }); - - test('when there are exception builder errors submit button is disabled', async () => { - const wrapper = mount( - - - - ); - const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; - await waitFor(() => callProps.onChange({ exceptionItems: [], errorExists: true })); - expect( - wrapper.find('button[data-test-subj="add-exception-confirm-button"]').getDOMNode() - ).toBeDisabled(); - }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.tsx index c7b7050f23ffe9..6d190913e358dd 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.tsx @@ -5,13 +5,10 @@ * 2.0. */ -// Component being re-implemented in 8.5 - -/* eslint complexity: ["error", 35]*/ - -import React, { memo, useEffect, useState, useCallback, useMemo } from 'react'; +import React, { memo, useEffect, useCallback, useMemo, useReducer } from 'react'; import styled, { css } from 'styled-components'; -import type { EuiComboBoxOptionOption } from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; + import { EuiFlyout, EuiFlyoutHeader, @@ -21,62 +18,52 @@ import { EuiButton, EuiButtonEmpty, EuiHorizontalRule, - EuiCheckbox, EuiSpacer, - EuiFormRow, - EuiText, - EuiCallOut, - EuiComboBox, EuiFlexGroup, + EuiLoadingContent, + EuiCallOut, + EuiText, } from '@elastic/eui'; -import type { - CreateExceptionListItemSchema, - ExceptionListItemSchema, - ExceptionListType, - OsTypeArray, -} from '@kbn/securitysolution-io-ts-list-types'; +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; +import type { OsTypeArray, ExceptionListSchema } from '@kbn/securitysolution-io-ts-list-types'; import type { ExceptionsBuilderExceptionItem, ExceptionsBuilderReturnExceptionItem, } from '@kbn/securitysolution-list-utils'; -import { getExceptionBuilderComponentLazy } from '@kbn/lists-plugin/public'; -import type { DataViewBase } from '@kbn/es-query'; -import { useRuleIndices } from '../../../../detections/containers/detection_engine/rules/use_rule_indices'; -import { hasEqlSequenceQuery, isEqlRule } from '../../../../../common/detection_engine/utils'; +import { ENDPOINT_LIST_ID } from '@kbn/securitysolution-list-constants'; + import type { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; -import * as i18nCommon from '../../../../common/translations'; import * as i18n from './translations'; -import * as sharedI18n from '../../utils/translations'; -import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; -import { useKibana } from '../../../../common/lib/kibana'; -import { Loader } from '../../../../common/components/loader'; -import { useAddOrUpdateException } from '../../logic/use_add_exception'; -import { useSignalIndex } from '../../../../detections/containers/detection_engine/alerts/use_signal_index'; -import { useRuleAsync } from '../../../../detections/containers/detection_engine/rules/use_rule_async'; -import { useFetchOrCreateRuleExceptionList } from '../../logic/use_fetch_or_create_rule_exception_list'; -import { ExceptionItemComments } from '../item_comments'; import { - enrichNewExceptionItemsWithComments, - enrichExceptionItemsWithOS, - lowercaseHashValues, defaultEndpointExceptionItems, - entryHasListType, - entryHasNonEcsType, retrieveAlertOsTypes, filterIndexPatterns, } from '../../utils/helpers'; -import type { ErrorInfo } from '../error_callout'; -import { ErrorCallout } from '../error_callout'; import type { AlertData } from '../../utils/types'; -import { useFetchIndex } from '../../../../common/containers/source'; +import { initialState, createExceptionItemsReducer } from './reducer'; +import { ExceptionsFlyoutMeta } from '../flyout_components/item_meta_form'; +import { ExceptionsConditions } from '../flyout_components/item_conditions'; +import { useFetchIndexPatterns } from '../../logic/use_exception_flyout_data'; +import type { Rule } from '../../../../detections/containers/detection_engine/rules/types'; +import { ExceptionItemsFlyoutAlertsActions } from '../flyout_components/alerts_actions'; +import { ExceptionsAddToRulesOrLists } from '../flyout_components/add_exception_to_rule_or_list'; +import { useAddNewExceptionItems } from './use_add_new_exceptions'; +import { entrichNewExceptionItems } from '../flyout_components/utils'; +import { useCloseAlertsFromExceptions } from '../../logic/use_close_alerts'; import { ruleTypesThatAllowLargeValueLists } from '../../utils/constants'; +import { ExceptionItemComments } from '../item_comments'; + +const SectionHeader = styled(EuiTitle)` + ${() => css` + font-weight: ${({ theme }) => theme.eui.euiFontWeightSemiBold}; + `} +`; export interface AddExceptionFlyoutProps { - ruleName: string; - ruleId: string; - exceptionListType: ExceptionListType; - ruleIndices: string[]; - dataViewId?: string; + rules: Rule[] | null; + isBulkAction: boolean; + showAlertCloseOptions: boolean; + isEndpointItem: boolean; alertData?: AlertData; /** * The components that use this may or may not define `alertData` @@ -86,40 +73,22 @@ export interface AddExceptionFlyoutProps { */ isAlertDataLoading?: boolean; alertStatus?: Status; - onCancel: () => void; - onConfirm: (didCloseAlert: boolean, didBulkCloseAlert: boolean) => void; - onRuleChange?: () => void; + onCancel: (didRuleChange: boolean) => void; + onConfirm: (didRuleChange: boolean, didCloseAlert: boolean, didBulkCloseAlert: boolean) => void; } -const FlyoutHeader = styled(EuiFlyoutHeader)` - ${({ theme }) => css` - border-bottom: 1px solid ${theme.eui.euiColorLightShade}; - `} -`; - -const FlyoutSubtitle = styled.div` - ${({ theme }) => css` - color: ${theme.eui.euiColorMediumShade}; - `} -`; - -const FlyoutBodySection = styled.section` - ${({ theme }) => css` - padding: ${theme.eui.euiSizeS} ${theme.eui.euiSizeL}; - +const FlyoutBodySection = styled(EuiFlyoutBody)` + ${() => css` &.builder-section { overflow-y: scroll; } `} `; -const FlyoutCheckboxesSection = styled(EuiFlyoutBody)` - overflow-y: inherit; - height: auto; - - .euiFlyoutBody__overflowContent { - padding-top: 0; - } +const FlyoutHeader = styled(EuiFlyoutHeader)` + ${({ theme }) => css` + border-bottom: 1px solid ${theme.eui.euiColorLightShade}; + `} `; const FlyoutFooterGroup = styled(EuiFlexGroup)` @@ -129,487 +98,430 @@ const FlyoutFooterGroup = styled(EuiFlexGroup)` `; export const AddExceptionFlyout = memo(function AddExceptionFlyout({ - ruleName, - ruleId, - ruleIndices, - dataViewId, - exceptionListType, + rules, + isBulkAction, + isEndpointItem, alertData, + showAlertCloseOptions, isAlertDataLoading, + alertStatus, onCancel, onConfirm, - onRuleChange, - alertStatus, }: AddExceptionFlyoutProps) { - const { http, unifiedSearch, data } = useKibana().services; - const [errorsExist, setErrorExists] = useState(false); - const [comment, setComment] = useState(''); - const { rule: maybeRule, loading: isRuleLoading } = useRuleAsync(ruleId); - const [shouldCloseAlert, setShouldCloseAlert] = useState(false); - const [shouldBulkCloseAlert, setShouldBulkCloseAlert] = useState(false); - const [shouldDisableBulkClose, setShouldDisableBulkClose] = useState(false); - const [exceptionItemsToAdd, setExceptionItemsToAdd] = useState< - ExceptionsBuilderReturnExceptionItem[] - >([]); - const [fetchOrCreateListError, setFetchOrCreateListError] = useState(null); - const { addError, addSuccess, addWarning } = useAppToasts(); - const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex(); - const memoSignalIndexName = useMemo( - () => (signalIndexName !== null ? [signalIndexName] : []), - [signalIndexName] - ); - const [isSignalIndexPatternLoading, { indexPatterns: signalIndexPatterns }] = - useFetchIndex(memoSignalIndexName); + const { isLoading, indexPatterns } = useFetchIndexPatterns(rules); + const [isSubmitting, submitNewExceptionItems] = useAddNewExceptionItems(); + const [isClosingAlerts, closeAlerts] = useCloseAlertsFromExceptions(); + + const allowLargeValueLists = useMemo((): boolean => { + if (rules != null && rules.length === 1) { + // We'll only block this when we know what rule we're dealing with. + // When dealing with numerous rules that can be a mix of those that do and + // don't work with large value lists we'll need to communicate that to the + // user but not block. + return ruleTypesThatAllowLargeValueLists.includes(rules[0].type); + } else { + return true; + } + }, [rules]); - const { mlJobLoading, ruleIndices: memoRuleIndices } = useRuleIndices( - maybeRule?.machine_learning_job_id, - ruleIndices - ); - const hasDataViewId = dataViewId || maybeRule?.data_view_id || null; - const [dataViewIndexPatterns, setDataViewIndexPatterns] = useState(null); - - useEffect(() => { - const fetchSingleDataView = async () => { - if (hasDataViewId) { - const dv = await data.dataViews.get(hasDataViewId); - setDataViewIndexPatterns(dv); - } - }; + const [ + { + exceptionItemMeta: { name: exceptionItemName }, + listType, + selectedOs, + initialItems, + exceptionItems, + disableBulkClose, + bulkCloseAlerts, + closeSingleAlert, + bulkCloseIndex, + addExceptionToRadioSelection, + selectedRulesToAddTo, + exceptionListsToAddTo, + newComment, + itemConditionValidationErrorExists, + errorSubmitting, + }, + dispatch, + ] = useReducer(createExceptionItemsReducer(), { + ...initialState, + addExceptionToRadioSelection: isBulkAction + ? 'add_to_rules' + : rules != null && rules.length === 1 + ? 'add_to_rule' + : 'select_rules_to_add_to', + listType: isEndpointItem ? ExceptionListTypeEnum.ENDPOINT : ExceptionListTypeEnum.RULE_DEFAULT, + selectedRulesToAddTo: rules != null ? rules : [], + }); - fetchSingleDataView(); - }, [hasDataViewId, data.dataViews, setDataViewIndexPatterns]); + const hasAlertData = useMemo((): boolean => { + return alertData != null; + }, [alertData]); - const [isIndexPatternLoading, { indexPatterns: indexIndexPatterns }] = useFetchIndex( - hasDataViewId ? [] : memoRuleIndices + /** + * Reducer action dispatchers + * */ + const setInitialExceptionItems = useCallback( + (items: ExceptionsBuilderExceptionItem[]): void => { + dispatch({ + type: 'setInitialExceptionItems', + items, + }); + }, + [dispatch] ); - const indexPattern = useMemo( - (): DataViewBase | null => (hasDataViewId ? dataViewIndexPatterns : indexIndexPatterns), - [hasDataViewId, dataViewIndexPatterns, indexIndexPatterns] + const setExceptionItemsToAdd = useCallback( + (items: ExceptionsBuilderReturnExceptionItem[]): void => { + dispatch({ + type: 'setExceptionItems', + items, + }); + }, + [dispatch] ); - const handleBuilderOnChange = useCallback( - ({ - exceptionItems, - errorExists, - }: { - exceptionItems: ExceptionsBuilderReturnExceptionItem[]; - errorExists: boolean; - }) => { - setExceptionItemsToAdd(exceptionItems); - setErrorExists(errorExists); + const setRadioOption = useCallback( + (option: string): void => { + dispatch({ + type: 'setListOrRuleRadioOption', + option, + }); }, - [setExceptionItemsToAdd] + [dispatch] ); - const handleRuleChange = useCallback( - (ruleChanged: boolean): void => { - if (ruleChanged && onRuleChange) { - onRuleChange(); - } + const setSelectedRules = useCallback( + (rulesSelectedToAdd: Rule[]): void => { + dispatch({ + type: 'setSelectedRulesToAddTo', + rules: rulesSelectedToAdd, + }); }, - [onRuleChange] + [dispatch] ); - const handleDissasociationSuccess = useCallback( - (id: string): void => { - handleRuleChange(true); - addSuccess(sharedI18n.DISSASOCIATE_LIST_SUCCESS(id)); - onCancel(); + const setListsToAddExceptionTo = useCallback( + (lists: ExceptionListSchema[]): void => { + dispatch({ + type: 'setAddExceptionToLists', + listsToAddTo: lists, + }); }, - [handleRuleChange, addSuccess, onCancel] + [dispatch] ); - const handleDissasociationError = useCallback( - (error: Error): void => { - addError(error, { title: sharedI18n.DISSASOCIATE_EXCEPTION_LIST_ERROR }); - onCancel(); + const setExceptionItemMeta = useCallback( + (value: [string, string]): void => { + dispatch({ + type: 'setExceptionItemMeta', + value, + }); }, - [addError, onCancel] + [dispatch] ); - const onError = useCallback( - (error: Error): void => { - addError(error, { title: i18n.ADD_EXCEPTION_ERROR }); - onCancel(); + const setConditionsValidationError = useCallback( + (errorExists: boolean): void => { + dispatch({ + type: 'setConditionValidationErrorExists', + errorExists, + }); }, - [addError, onCancel] + [dispatch] ); - const onSuccess = useCallback( - (updated: number, conflicts: number): void => { - handleRuleChange(true); - addSuccess(i18n.ADD_EXCEPTION_SUCCESS); - onConfirm(shouldCloseAlert, shouldBulkCloseAlert); - if (conflicts > 0) { - addWarning({ - title: i18nCommon.UPDATE_ALERT_STATUS_FAILED(conflicts), - text: i18nCommon.UPDATE_ALERT_STATUS_FAILED_DETAILED(updated, conflicts), - }); - } + const setSelectedOs = useCallback( + (os: OsTypeArray | undefined): void => { + dispatch({ + type: 'setSelectedOsOptions', + selectedOs: os, + }); }, - [addSuccess, addWarning, onConfirm, shouldBulkCloseAlert, shouldCloseAlert, handleRuleChange] + [dispatch] ); - const [{ isLoading: addExceptionIsLoading }, addOrUpdateExceptionItems] = useAddOrUpdateException( - { - http, - onSuccess, - onError, - } + const setComment = useCallback( + (comment: string): void => { + dispatch({ + type: 'setComment', + comment, + }); + }, + [dispatch] ); - const handleFetchOrCreateExceptionListError = useCallback( - (error: Error, statusCode: number | null, message: string | null): void => { - setFetchOrCreateListError({ - reason: error.message, - code: statusCode, - details: message, - listListId: null, + const setBulkCloseIndex = useCallback( + (index: string[] | undefined): void => { + dispatch({ + type: 'setBulkCloseIndex', + bulkCloseIndex: index, }); }, - [setFetchOrCreateListError] + [dispatch] ); - const [isLoadingExceptionList, ruleExceptionList] = useFetchOrCreateRuleExceptionList({ - http, - ruleId, - exceptionListType, - onError: handleFetchOrCreateExceptionListError, - onSuccess: handleRuleChange, - }); - - const initialExceptionItems = useMemo((): ExceptionsBuilderExceptionItem[] => { - if (exceptionListType === 'endpoint' && alertData != null && ruleExceptionList) { - return defaultEndpointExceptionItems(ruleExceptionList.list_id, ruleName, alertData); - } else { - return []; - } - }, [exceptionListType, ruleExceptionList, ruleName, alertData]); - - useEffect((): void => { - if (isSignalIndexPatternLoading === false && isSignalIndexLoading === false) { - setShouldDisableBulkClose( - entryHasListType(exceptionItemsToAdd) || - entryHasNonEcsType(exceptionItemsToAdd, signalIndexPatterns) || - exceptionItemsToAdd.every((item) => item.entries.length === 0) - ); - } - }, [ - setShouldDisableBulkClose, - exceptionItemsToAdd, - isSignalIndexPatternLoading, - isSignalIndexLoading, - signalIndexPatterns, - ]); - - useEffect((): void => { - if (shouldDisableBulkClose === true) { - setShouldBulkCloseAlert(false); - } - }, [shouldDisableBulkClose]); - - const onCommentChange = useCallback( - (value: string): void => { - setComment(value); + const setCloseSingleAlert = useCallback( + (close: boolean): void => { + dispatch({ + type: 'setCloseSingleAlert', + close, + }); }, - [setComment] + [dispatch] ); - const onCloseAlertCheckboxChange = useCallback( - (event: React.ChangeEvent): void => { - setShouldCloseAlert(event.currentTarget.checked); + const setBulkCloseAlerts = useCallback( + (bulkClose: boolean): void => { + dispatch({ + type: 'setBulkCloseAlerts', + bulkClose, + }); }, - [setShouldCloseAlert] + [dispatch] ); - const onBulkCloseAlertCheckboxChange = useCallback( - (event: React.ChangeEvent): void => { - setShouldBulkCloseAlert(event.currentTarget.checked); + const setDisableBulkCloseAlerts = useCallback( + (disableBulkCloseAlerts: boolean): void => { + dispatch({ + type: 'setDisableBulkCloseAlerts', + disableBulkCloseAlerts, + }); }, - [setShouldBulkCloseAlert] + [dispatch] ); - const hasAlertData = useMemo((): boolean => { - return alertData !== undefined; - }, [alertData]); + const setErrorSubmitting = useCallback( + (err: Error | null): void => { + dispatch({ + type: 'setErrorSubmitting', + err, + }); + }, + [dispatch] + ); - const [selectedOs, setSelectedOs] = useState(); + useEffect((): void => { + if (listType === ExceptionListTypeEnum.ENDPOINT && alertData != null) { + setInitialExceptionItems( + defaultEndpointExceptionItems(ENDPOINT_LIST_ID, exceptionItemName, alertData) + ); + } + }, [listType, exceptionItemName, alertData, setInitialExceptionItems]); const osTypesSelection = useMemo((): OsTypeArray => { return hasAlertData ? retrieveAlertOsTypes(alertData) : selectedOs ? [...selectedOs] : []; }, [hasAlertData, alertData, selectedOs]); - const enrichExceptionItems = useCallback((): ExceptionsBuilderReturnExceptionItem[] => { - let enriched: ExceptionsBuilderReturnExceptionItem[] = []; - enriched = - comment !== '' - ? enrichNewExceptionItemsWithComments(exceptionItemsToAdd, [{ comment }]) - : exceptionItemsToAdd; - if (exceptionListType === 'endpoint') { - const osTypes = osTypesSelection; - enriched = lowercaseHashValues(enrichExceptionItemsWithOS(enriched, osTypes)); - } - return enriched; - }, [comment, exceptionItemsToAdd, exceptionListType, osTypesSelection]); - - const onAddExceptionConfirm = useCallback((): void => { - if (addOrUpdateExceptionItems != null) { - const alertIdToClose = shouldCloseAlert && alertData ? alertData._id : undefined; - const bulkCloseIndex = - shouldBulkCloseAlert && signalIndexName != null ? [signalIndexName] : undefined; - addOrUpdateExceptionItems( - maybeRule?.rule_id ?? '', - // This is being rewritten in https://github.com/elastic/kibana/pull/140643 - // As of now, flyout cannot yet create item of type CreateRuleExceptionListItemSchema - enrichExceptionItems() as Array, - alertIdToClose, - bulkCloseIndex - ); + const handleOnSubmit = useCallback(async (): Promise => { + if (submitNewExceptionItems == null) return; + + try { + const ruleDefaultOptions = ['add_to_rule', 'add_to_rules', 'select_rules_to_add_to']; + const addToRules = ruleDefaultOptions.includes(addExceptionToRadioSelection); + const addToSharedLists = addExceptionToRadioSelection === 'add_to_lists'; + + const items = entrichNewExceptionItems({ + itemName: exceptionItemName, + commentToAdd: newComment, + addToRules, + addToSharedLists, + sharedLists: exceptionListsToAddTo, + listType, + selectedOs: osTypesSelection, + items: exceptionItems, + }); + + const addedItems = await submitNewExceptionItems({ + itemsToAdd: items, + selectedRulesToAddTo, + listType, + addToRules: addToRules && !isEmpty(selectedRulesToAddTo), + addToSharedLists: addToSharedLists && !isEmpty(exceptionListsToAddTo), + sharedLists: exceptionListsToAddTo, + }); + + const alertIdToClose = closeSingleAlert && alertData ? alertData._id : undefined; + const ruleStaticIds = addToRules + ? selectedRulesToAddTo.map(({ rule_id: ruleId }) => ruleId) + : (rules ?? []).map(({ rule_id: ruleId }) => ruleId); + + if (closeAlerts != null && !isEmpty(ruleStaticIds) && (bulkCloseAlerts || closeSingleAlert)) { + await closeAlerts(ruleStaticIds, addedItems, alertIdToClose, bulkCloseIndex); + } + + // Rule only would have been updated if we had to create a rule default list + // to attach to it, all shared lists would already be referenced on the rule + onConfirm(true, closeSingleAlert, bulkCloseAlerts); + } catch (e) { + setErrorSubmitting(e); } }, [ - addOrUpdateExceptionItems, - maybeRule, - enrichExceptionItems, - shouldCloseAlert, - shouldBulkCloseAlert, + submitNewExceptionItems, + addExceptionToRadioSelection, + exceptionItemName, + newComment, + exceptionListsToAddTo, + listType, + osTypesSelection, + exceptionItems, + selectedRulesToAddTo, + closeSingleAlert, alertData, - signalIndexName, + rules, + closeAlerts, + bulkCloseAlerts, + onConfirm, + bulkCloseIndex, + setErrorSubmitting, ]); const isSubmitButtonDisabled = useMemo( (): boolean => - fetchOrCreateListError != null || - exceptionItemsToAdd.every((item) => item.entries.length === 0) || - errorsExist, - [fetchOrCreateListError, exceptionItemsToAdd, errorsExist] - ); - - const addExceptionMessage = - exceptionListType === 'endpoint' ? i18n.ADD_ENDPOINT_EXCEPTION : i18n.ADD_EXCEPTION; - - const isRuleEQLSequenceStatement = useMemo((): boolean => { - if (maybeRule != null) { - return isEqlRule(maybeRule.type) && hasEqlSequenceQuery(maybeRule.query); - } - return false; - }, [maybeRule]); - - const OsOptions: Array> = useMemo((): Array< - EuiComboBoxOptionOption - > => { - return [ - { - label: sharedI18n.OPERATING_SYSTEM_WINDOWS, - value: ['windows'], - }, - { - label: sharedI18n.OPERATING_SYSTEM_MAC, - value: ['macos'], - }, - { - label: sharedI18n.OPERATING_SYSTEM_LINUX, - value: ['linux'], - }, - { - label: sharedI18n.OPERATING_SYSTEM_WINDOWS_AND_MAC, - value: ['windows', 'macos'], - }, - ]; - }, []); - - const handleOSSelectionChange = useCallback( - (selectedOptions): void => { - setSelectedOs(selectedOptions[0].value); - }, - [setSelectedOs] + isSubmitting || + isClosingAlerts || + errorSubmitting != null || + exceptionItemName.trim() === '' || + exceptionItems.every((item) => item.entries.length === 0) || + itemConditionValidationErrorExists || + (addExceptionToRadioSelection === 'add_to_lists' && isEmpty(exceptionListsToAddTo)), + [ + isSubmitting, + isClosingAlerts, + errorSubmitting, + exceptionItemName, + exceptionItems, + itemConditionValidationErrorExists, + addExceptionToRadioSelection, + exceptionListsToAddTo, + ] ); - const selectedOStoOptions = useMemo((): Array> => { - return OsOptions.filter((option) => { - return selectedOs === option.value; - }); - }, [selectedOs, OsOptions]); - - const singleSelectionOptions = useMemo(() => { - return { asPlainText: true }; - }, []); + const handleDismissError = useCallback((): void => { + setErrorSubmitting(null); + }, [setErrorSubmitting]); - const hasOsSelection = useMemo(() => { - return exceptionListType === 'endpoint' && !hasAlertData; - }, [exceptionListType, hasAlertData]); + const handleCloseFlyout = useCallback((): void => { + onCancel(false); + }, [onCancel]); - const isExceptionBuilderFormDisabled = useMemo(() => { - return hasOsSelection && selectedOs === undefined; - }, [hasOsSelection, selectedOs]); - - const allowLargeValueLists = useMemo( - () => (maybeRule != null ? ruleTypesThatAllowLargeValueLists.includes(maybeRule.type) : false), - [maybeRule] - ); + const addExceptionMessage = useMemo(() => { + return listType === ExceptionListTypeEnum.ENDPOINT + ? i18n.ADD_ENDPOINT_EXCEPTION + : i18n.CREATE_RULE_EXCEPTION; + }, [listType]); return ( -

{addExceptionMessage}

+

{addExceptionMessage}

- - - {ruleName} -
- {fetchOrCreateListError != null && ( - - } + {!isLoading && ( + + {errorSubmitting != null && ( + <> + + {i18n.SUBMIT_ERROR_DISMISS_MESSAGE} + + + {i18n.SUBMIT_ERROR_DISMISS_BUTTON} + + + + + )} + - - )} - {fetchOrCreateListError == null && - (isLoadingExceptionList || - isIndexPatternLoading || - isSignalIndexLoading || - isAlertDataLoading || - isSignalIndexPatternLoading) && ( - - )} - {fetchOrCreateListError == null && - indexPattern != null && - !isSignalIndexLoading && - !isSignalIndexPatternLoading && - !isLoadingExceptionList && - !isIndexPatternLoading && - !isRuleLoading && - !mlJobLoading && - !isAlertDataLoading && - ruleExceptionList && ( - <> - - {isRuleEQLSequenceStatement && ( - <> - - - - )} - {i18n.EXCEPTION_BUILDER_INFO} - - {exceptionListType === 'endpoint' && !hasAlertData && ( - <> - - - - - - )} - {getExceptionBuilderComponentLazy({ - allowLargeValueLists, - httpService: http, - autocompleteService: unifiedSearch.autocomplete, - exceptionListItems: initialExceptionItems, - listType: exceptionListType, - osTypes: osTypesSelection, - listId: ruleExceptionList.list_id, - listNamespaceType: ruleExceptionList.namespace_type, - listTypeSpecificIndexPatternFilter: filterIndexPatterns, - ruleName, - indexPatterns: indexPattern, - isOrDisabled: isExceptionBuilderFormDisabled, - isAndDisabled: isExceptionBuilderFormDisabled, - isNestedDisabled: isExceptionBuilderFormDisabled, - dataTestSubj: 'alert-exception-builder', - idAria: 'alert-exception-builder', - onChange: handleBuilderOnChange, - isDisabled: isExceptionBuilderFormDisabled, - })} - - - - + + + {listType !== ExceptionListTypeEnum.ENDPOINT && ( + <> + + + + )} + + +

{i18n.COMMENTS_SECTION_TITLE(0)}

+ + } + newCommentValue={newComment} + newCommentOnChange={setComment} + /> + {showAlertCloseOptions && ( + <> + + -
- - - {alertData != null && alertStatus !== 'closed' && ( - - - - )} - - - - {exceptionListType === 'endpoint' && ( - <> - - - {i18n.ENDPOINT_QUARANTINE_TEXT} - - - )} - - - )} - {fetchOrCreateListError == null && ( - - - - {i18n.CANCEL} - - - - {addExceptionMessage} - - - + + )} + )} + + + + {i18n.CANCEL} + + + + {addExceptionMessage} + + +
); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/reducer.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/reducer.ts new file mode 100644 index 00000000000000..3e5f8afd196261 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/reducer.ts @@ -0,0 +1,250 @@ +/* + * 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 { ExceptionListSchema, OsTypeArray } from '@kbn/securitysolution-io-ts-list-types'; +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; +import type { + ExceptionsBuilderExceptionItem, + ExceptionsBuilderReturnExceptionItem, +} from '@kbn/securitysolution-list-utils'; + +import type { Rule } from '../../../../detections/containers/detection_engine/rules/types'; + +export interface State { + exceptionItemMeta: { name: string }; + listType: ExceptionListTypeEnum; + initialItems: ExceptionsBuilderExceptionItem[]; + exceptionItems: ExceptionsBuilderReturnExceptionItem[]; + newComment: string; + addExceptionToRadioSelection: string; + itemConditionValidationErrorExists: boolean; + closeSingleAlert: boolean; + bulkCloseAlerts: boolean; + disableBulkClose: boolean; + bulkCloseIndex: string[] | undefined; + selectedOs: OsTypeArray | undefined; + exceptionListsToAddTo: ExceptionListSchema[]; + selectedRulesToAddTo: Rule[]; + errorSubmitting: Error | null; +} + +export const initialState: State = { + initialItems: [], + exceptionItems: [], + exceptionItemMeta: { name: '' }, + newComment: '', + itemConditionValidationErrorExists: false, + closeSingleAlert: false, + bulkCloseAlerts: false, + disableBulkClose: false, + bulkCloseIndex: undefined, + selectedOs: undefined, + exceptionListsToAddTo: [], + addExceptionToRadioSelection: 'add_to_rule', + selectedRulesToAddTo: [], + listType: ExceptionListTypeEnum.RULE_DEFAULT, + errorSubmitting: null, +}; + +export type Action = + | { + type: 'setExceptionItemMeta'; + value: [string, string]; + } + | { + type: 'setInitialExceptionItems'; + items: ExceptionsBuilderExceptionItem[]; + } + | { + type: 'setExceptionItems'; + items: ExceptionsBuilderReturnExceptionItem[]; + } + | { + type: 'setConditionValidationErrorExists'; + errorExists: boolean; + } + | { + type: 'setComment'; + comment: string; + } + | { + type: 'setCloseSingleAlert'; + close: boolean; + } + | { + type: 'setBulkCloseAlerts'; + bulkClose: boolean; + } + | { + type: 'setDisableBulkCloseAlerts'; + disableBulkCloseAlerts: boolean; + } + | { + type: 'setBulkCloseIndex'; + bulkCloseIndex: string[] | undefined; + } + | { + type: 'setSelectedOsOptions'; + selectedOs: OsTypeArray | undefined; + } + | { + type: 'setAddExceptionToLists'; + listsToAddTo: ExceptionListSchema[]; + } + | { + type: 'setListOrRuleRadioOption'; + option: string; + } + | { + type: 'setSelectedRulesToAddTo'; + rules: Rule[]; + } + | { + type: 'setListType'; + listType: ExceptionListTypeEnum; + } + | { + type: 'setErrorSubmitting'; + err: Error | null; + }; + +export const createExceptionItemsReducer = + () => + (state: State, action: Action): State => { + switch (action.type) { + case 'setExceptionItemMeta': { + const { value } = action; + + return { + ...state, + exceptionItemMeta: { + ...state.exceptionItemMeta, + [value[0]]: value[1], + }, + }; + } + case 'setInitialExceptionItems': { + const { items } = action; + + return { + ...state, + initialItems: items, + }; + } + case 'setExceptionItems': { + const { items } = action; + + return { + ...state, + exceptionItems: items, + }; + } + case 'setConditionValidationErrorExists': { + const { errorExists } = action; + + return { + ...state, + itemConditionValidationErrorExists: errorExists, + }; + } + case 'setComment': { + const { comment } = action; + + return { + ...state, + newComment: comment, + }; + } + case 'setCloseSingleAlert': { + const { close } = action; + + return { + ...state, + closeSingleAlert: close, + }; + } + case 'setBulkCloseAlerts': { + const { bulkClose } = action; + + return { + ...state, + bulkCloseAlerts: bulkClose, + }; + } + case 'setBulkCloseIndex': { + const { bulkCloseIndex } = action; + + return { + ...state, + bulkCloseIndex, + }; + } + case 'setSelectedOsOptions': { + const { selectedOs } = action; + + return { + ...state, + selectedOs, + }; + } + case 'setAddExceptionToLists': { + const { listsToAddTo } = action; + + return { + ...state, + exceptionListsToAddTo: listsToAddTo, + }; + } + case 'setListOrRuleRadioOption': { + const { option } = action; + + return { + ...state, + addExceptionToRadioSelection: option, + listType: + option === 'add_to_lists' + ? ExceptionListTypeEnum.DETECTION + : ExceptionListTypeEnum.RULE_DEFAULT, + selectedRulesToAddTo: option === 'add_to_lists' ? [] : state.selectedRulesToAddTo, + }; + } + case 'setSelectedRulesToAddTo': { + const { rules } = action; + + return { + ...state, + selectedRulesToAddTo: rules, + }; + } + case 'setListType': { + const { listType } = action; + + return { + ...state, + listType, + }; + } + case 'setDisableBulkCloseAlerts': { + const { disableBulkCloseAlerts } = action; + + return { + ...state, + disableBulkClose: disableBulkCloseAlerts, + }; + } + case 'setErrorSubmitting': { + const { err } = action; + + return { + ...state, + errorSubmitting: err, + }; + } + default: + return state; + } + }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/translations.ts index fe0b3166482145..eea7f90d07e5cb 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/translations.ts @@ -7,79 +7,79 @@ import { i18n } from '@kbn/i18n'; -export const CANCEL = i18n.translate('xpack.securitySolution.exceptions.addException.cancel', { +export const CANCEL = i18n.translate('xpack.securitySolution.ruleExceptions.addException.cancel', { defaultMessage: 'Cancel', }); -export const ADD_EXCEPTION = i18n.translate( - 'xpack.securitySolution.exceptions.addException.addException', +export const CREATE_RULE_EXCEPTION = i18n.translate( + 'xpack.securitySolution.ruleExceptions.addException.createRuleExceptionLabel', { - defaultMessage: 'Add Rule Exception', + defaultMessage: 'Add rule exception', } ); export const ADD_ENDPOINT_EXCEPTION = i18n.translate( - 'xpack.securitySolution.exceptions.addException.addEndpointException', + 'xpack.securitySolution.ruleExceptions.addException.addEndpointException', { defaultMessage: 'Add Endpoint Exception', } ); -export const ADD_EXCEPTION_ERROR = i18n.translate( - 'xpack.securitySolution.exceptions.addException.error', +export const SUBMIT_ERROR_TITLE = i18n.translate( + 'xpack.securitySolution.ruleExceptions.addException.submitError.title', { - defaultMessage: 'Failed to add exception', + defaultMessage: 'An error occured submitting exception', } ); -export const ADD_EXCEPTION_SUCCESS = i18n.translate( - 'xpack.securitySolution.exceptions.addException.success', +export const SUBMIT_ERROR_DISMISS_BUTTON = i18n.translate( + 'xpack.securitySolution.ruleExceptions.addException.submitError.dismissButton', { - defaultMessage: 'Successfully added exception', + defaultMessage: 'Dismiss', } ); -export const ENDPOINT_QUARANTINE_TEXT = i18n.translate( - 'xpack.securitySolution.exceptions.addException.endpointQuarantineText', +export const SUBMIT_ERROR_DISMISS_MESSAGE = i18n.translate( + 'xpack.securitySolution.ruleExceptions.addException.submitError.message', { - defaultMessage: - 'On all Endpoint hosts, quarantined files that match the exception are automatically restored to their original locations. This exception applies to all rules using Endpoint exceptions.', + defaultMessage: 'View toast for error details.', } ); -export const BULK_CLOSE_LABEL = i18n.translate( - 'xpack.securitySolution.exceptions.addException.bulkCloseLabel', +export const ADD_EXCEPTION_SUCCESS = i18n.translate( + 'xpack.securitySolution.ruleExceptions.addException.success', { - defaultMessage: 'Close all alerts that match this exception and were generated by this rule', + defaultMessage: 'Rule exception added to shared exception list', } ); -export const BULK_CLOSE_LABEL_DISABLED = i18n.translate( - 'xpack.securitySolution.exceptions.addException.bulkCloseLabel.disabled', - { - defaultMessage: - 'Close all alerts that match this exception and were generated by this rule (Lists and non-ECS fields are not supported)', - } -); +export const ADD_EXCEPTION_SUCCESS_DETAILS = (listNames: string) => + i18n.translate( + 'xpack.securitySolution.ruleExceptions.addExceptionFlyout.closeAlerts.successDetails', + { + values: { listNames }, + defaultMessage: 'Rule exception has been added to shared lists: {listNames}.', + } + ); -export const EXCEPTION_BUILDER_INFO = i18n.translate( - 'xpack.securitySolution.exceptions.addException.infoLabel', +export const ADD_RULE_EXCEPTION_SUCCESS_TITLE = i18n.translate( + 'xpack.securitySolution.ruleExceptions.addExceptionFlyout.addRuleExceptionToastSuccessTitle', { - defaultMessage: "Alerts are generated when the rule's conditions are met, except when:", + defaultMessage: 'Rule exception added', } ); -export const ADD_EXCEPTION_SEQUENCE_WARNING = i18n.translate( - 'xpack.securitySolution.exceptions.addException.sequenceWarning', - { - defaultMessage: - "This rule's query contains an EQL sequence statement. The exception created will apply to all events in the sequence.", - } -); +export const ADD_RULE_EXCEPTION_SUCCESS_TEXT = (ruleName: string) => + i18n.translate( + 'xpack.securitySolution.ruleExceptions.addExceptionFlyout.addRuleExceptionToastSuccessText', + { + values: { ruleName }, + defaultMessage: 'Exception has been added to rules - {ruleName}.', + } + ); -export const OPERATING_SYSTEM_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.exceptions.addException.operatingSystemPlaceHolder', - { - defaultMessage: 'Select an operating system', - } -); +export const COMMENTS_SECTION_TITLE = (comments: number) => + i18n.translate('xpack.securitySolution.ruleExceptions.addExceptionFlyout.commentsTitle', { + values: { comments }, + defaultMessage: 'Add comments ({comments})', + }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/use_add_new_exceptions.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/use_add_new_exceptions.ts new file mode 100644 index 00000000000000..909d8e85804722 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/use_add_new_exceptions.ts @@ -0,0 +1,148 @@ +/* + * 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 { useEffect, useRef, useCallback, useState } from 'react'; +import type { + CreateExceptionListItemSchema, + CreateRuleExceptionListItemSchema, + ExceptionListItemSchema, + ExceptionListSchema, +} from '@kbn/securitysolution-io-ts-list-types'; +import { + createExceptionListItemSchema, + exceptionListItemSchema, + ExceptionListTypeEnum, + createRuleExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; +import type { ExceptionsBuilderReturnExceptionItem } from '@kbn/securitysolution-list-utils'; + +import * as i18n from './translations'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import type { Rule } from '../../../../detections/containers/detection_engine/rules/types'; +import { useCreateOrUpdateException } from '../../logic/use_create_update_exception'; +import { useAddRuleDefaultException } from '../../logic/use_add_rule_exception'; + +export interface AddNewExceptionItemHookProps { + itemsToAdd: ExceptionsBuilderReturnExceptionItem[]; + listType: ExceptionListTypeEnum; + selectedRulesToAddTo: Rule[]; + addToSharedLists: boolean; + addToRules: boolean; + sharedLists: ExceptionListSchema[]; +} + +export type AddNewExceptionItemHookFuncProps = ( + arg: AddNewExceptionItemHookProps +) => Promise; + +export type ReturnUseAddNewExceptionItems = [boolean, AddNewExceptionItemHookFuncProps | null]; + +/** + * Hook for adding new exception items from flyout + * + */ +export const useAddNewExceptionItems = (): ReturnUseAddNewExceptionItems => { + const { addSuccess, addError, addWarning } = useAppToasts(); + const [isAddRuleExceptionLoading, addRuleExceptions] = useAddRuleDefaultException(); + const [isAddingExceptions, addSharedExceptions] = useCreateOrUpdateException(); + + const [isLoading, setIsLoading] = useState(false); + const addNewExceptionsRef = useRef(null); + + const areRuleDefaultItems = useCallback( + ( + items: ExceptionsBuilderReturnExceptionItem[] + ): items is CreateRuleExceptionListItemSchema[] => { + return items.every((item) => createRuleExceptionListItemSchema.is(item)); + }, + [] + ); + + const areSharedListItems = useCallback( + ( + items: ExceptionsBuilderReturnExceptionItem[] + ): items is Array => { + return items.every( + (item) => exceptionListItemSchema.is(item) || createExceptionListItemSchema.is(item) + ); + }, + [] + ); + + useEffect(() => { + const abortCtrl = new AbortController(); + + const addNewExceptions = async ({ + itemsToAdd, + listType, + selectedRulesToAddTo, + addToRules, + addToSharedLists, + sharedLists, + }: AddNewExceptionItemHookProps): Promise => { + try { + let result: ExceptionListItemSchema[] = []; + setIsLoading(true); + + if ( + addToRules && + addRuleExceptions != null && + listType !== ExceptionListTypeEnum.ENDPOINT && + areRuleDefaultItems(itemsToAdd) + ) { + result = await addRuleExceptions(itemsToAdd, selectedRulesToAddTo); + + const ruleNames = selectedRulesToAddTo.map(({ name }) => name).join(', '); + + addSuccess({ + title: i18n.ADD_RULE_EXCEPTION_SUCCESS_TITLE, + text: i18n.ADD_RULE_EXCEPTION_SUCCESS_TEXT(ruleNames), + }); + } else if ( + (listType === ExceptionListTypeEnum.ENDPOINT || addToSharedLists) && + addSharedExceptions != null && + areSharedListItems(itemsToAdd) + ) { + result = await addSharedExceptions(itemsToAdd); + + const sharedListNames = sharedLists.map(({ name }) => name); + + addSuccess({ + title: i18n.ADD_EXCEPTION_SUCCESS, + text: i18n.ADD_EXCEPTION_SUCCESS_DETAILS(sharedListNames.join(',')), + }); + } + + setIsLoading(false); + + return result; + } catch (e) { + setIsLoading(false); + addError(e, { title: i18n.SUBMIT_ERROR_TITLE }); + throw e; + } + }; + + addNewExceptionsRef.current = addNewExceptions; + return (): void => { + abortCtrl.abort(); + }; + }, [ + addSuccess, + addError, + addWarning, + addRuleExceptions, + addSharedExceptions, + areRuleDefaultItems, + areSharedListItems, + ]); + + return [ + isLoading || isAddingExceptions || isAddRuleExceptionLoading, + addNewExceptionsRef.current, + ]; +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/all_items.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/all_items.test.tsx index 0df9fad55a14d2..2caf7d352a733e 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/all_items.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/all_items.test.tsx @@ -10,7 +10,6 @@ import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; -import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import { ExceptionsViewerItems } from './all_items'; import { getMockTheme } from '../../../../common/lib/kibana/kibana_react.mock'; @@ -31,7 +30,7 @@ describe('ExceptionsViewerItems', () => { { ); expect( - wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty-detection"]').exists() + wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty"]').exists() ).toBeTruthy(); expect(wrapper.find('[data-test-subj="exceptionsContainer"]').exists()).toBeFalsy(); }); @@ -55,7 +54,7 @@ describe('ExceptionsViewerItems', () => { { void; @@ -42,7 +39,7 @@ interface ExceptionItemsViewerProps { const ExceptionItemsViewerComponent: React.FC = ({ isReadOnly, exceptions, - listType, + isEndpoint, disableActions, ruleReferences, viewerState, @@ -55,7 +52,7 @@ const ExceptionItemsViewerComponent: React.FC = ({ {viewerState != null && viewerState !== 'deleting' ? ( @@ -68,8 +65,8 @@ const ExceptionItemsViewerComponent: React.FC = ({ { const wrapper = mount( @@ -33,7 +31,7 @@ describe('ExeptionItemsViewerEmptyPrompts', () => { const wrapper = mount( @@ -44,11 +42,11 @@ describe('ExeptionItemsViewerEmptyPrompts', () => { ).toBeTruthy(); }); - it('it renders no endpoint items screen when "currentState" is "empty" and "listType" is "endpoint"', () => { + it('it renders no endpoint items screen when "currentState" is "empty" and "isEndpoint" is "true"', () => { const wrapper = mount( @@ -61,15 +59,15 @@ describe('ExeptionItemsViewerEmptyPrompts', () => { i18n.EXCEPTION_EMPTY_PROMPT_ENDPOINT_BUTTON ); expect( - wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty-endpoint"]').exists() + wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty"]').exists() ).toBeTruthy(); }); - it('it renders no exception items screen when "currentState" is "empty" and "listType" is "detection"', () => { + it('it renders no exception items screen when "currentState" is "empty" and "isEndpoint" is "false"', () => { const wrapper = mount( @@ -82,7 +80,7 @@ describe('ExeptionItemsViewerEmptyPrompts', () => { i18n.EXCEPTION_EMPTY_PROMPT_BUTTON ); expect( - wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty-detection"]').exists() + wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty"]').exists() ).toBeTruthy(); }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/empty_viewer_state.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/empty_viewer_state.tsx index 2be1860f138d36..b00bf7dd751340 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/empty_viewer_state.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/empty_viewer_state.tsx @@ -15,21 +15,20 @@ import { EuiPanel, } from '@elastic/eui'; -import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import * as i18n from './translations'; import type { ViewerState } from './reducer'; import illustration from '../../../../common/images/illustration_product_no_results_magnifying_glass.svg'; interface ExeptionItemsViewerEmptyPromptsComponentProps { isReadOnly: boolean; - listType: ExceptionListTypeEnum; + isEndpoint: boolean; currentState: ViewerState; onCreateExceptionListItem: () => void; } const ExeptionItemsViewerEmptyPromptsComponent = ({ isReadOnly, - listType, + isEndpoint, currentState, onCreateExceptionListItem, }: ExeptionItemsViewerEmptyPromptsComponentProps): JSX.Element => { @@ -60,7 +59,7 @@ const ExeptionItemsViewerEmptyPromptsComponent = ({ } body={

- {listType === ExceptionListTypeEnum.ENDPOINT + {isEndpoint ? i18n.EXCEPTION_EMPTY_ENDPOINT_PROMPT_BODY : i18n.EXCEPTION_EMPTY_PROMPT_BODY}

@@ -74,12 +73,12 @@ const ExeptionItemsViewerEmptyPromptsComponent = ({ isDisabled={isReadOnly} fill > - {listType === ExceptionListTypeEnum.ENDPOINT + {isEndpoint ? i18n.EXCEPTION_EMPTY_PROMPT_ENDPOINT_BUTTON : i18n.EXCEPTION_EMPTY_PROMPT_BUTTON}
, ]} - data-test-subj={`exceptionItemViewerEmptyPrompts-empty-${listType}`} + data-test-subj="exceptionItemViewerEmptyPrompts-empty" /> ); case 'empty_search': @@ -100,7 +99,13 @@ const ExeptionItemsViewerEmptyPromptsComponent = ({ ); } - }, [currentState, euiTheme.colors.darkestShade, isReadOnly, listType, onCreateExceptionListItem]); + }, [ + currentState, + euiTheme.colors.darkestShade, + isReadOnly, + isEndpoint, + onCreateExceptionListItem, + ]); return ( { }, }); - (useFindExceptionListReferences as jest.Mock).mockReturnValue([false, null]); + (useFindExceptionListReferences as jest.Mock).mockReturnValue([ + false, + false, + { + list_id: { + _version: 'WzEzNjMzLDFd', + created_at: '2022-09-26T19:41:43.338Z', + created_by: 'elastic', + description: + 'Exception list containing exceptions for rule with id: 178c2e10-3dd3-11ed-81d7-37f31b5b97f6', + id: '3fa2c8a0-3dd3-11ed-81d7-37f31b5b97f6', + immutable: false, + list_id: 'list_id', + name: 'Exceptions for rule - My really good rule', + namespace_type: 'single', + os_types: [], + tags: ['default_rule_exception_list'], + tie_breaker_id: '83395c3e-76a0-466e-ba58-2f5a4b8b5444', + type: 'rule_default', + updated_at: '2022-09-26T19:41:43.342Z', + updated_by: 'elastic', + version: 1, + referenced_rules: [ + { + name: 'My really good rule', + id: '178c2e10-3dd3-11ed-81d7-37f31b5b97f6', + rule_id: 'cc604877-838b-438d-866b-8bce5237aa07', + exception_lists: [ + { + id: '3fa2c8a0-3dd3-11ed-81d7-37f31b5b97f6', + list_id: 'list_id', + type: 'rule_default', + namespace_type: 'single', + }, + ], + }, + ], + }, + }, + jest.fn(), + ]); }); it('it renders loading screen when "currentState" is "loading"', () => { @@ -108,7 +148,7 @@ describe('ExceptionsViewer', () => { }, ], }} - listType={ExceptionListTypeEnum.DETECTION} + listTypes={[ExceptionListTypeEnum.DETECTION]} isViewReadOnly={false} /> @@ -146,7 +186,7 @@ describe('ExceptionsViewer', () => { }, ], }} - listType={ExceptionListTypeEnum.DETECTION} + listTypes={[ExceptionListTypeEnum.DETECTION]} isViewReadOnly={false} /> @@ -157,7 +197,7 @@ describe('ExceptionsViewer', () => { ).toBeTruthy(); }); - it('it renders no endpoint items screen when "currentState" is "empty" and "listType" is "endpoint"', () => { + it('it renders no endpoint items screen when "currentState" is "empty" and "listTypes" includes only "endpoint"', () => { (useReducer as jest.Mock).mockReturnValue([ { exceptions: [], @@ -184,7 +224,7 @@ describe('ExceptionsViewer', () => { }, ], }} - listType={ExceptionListTypeEnum.ENDPOINT} + listTypes={[ExceptionListTypeEnum.ENDPOINT]} isViewReadOnly={false} /> @@ -197,11 +237,11 @@ describe('ExceptionsViewer', () => { i18n.EXCEPTION_EMPTY_PROMPT_ENDPOINT_BUTTON ); expect( - wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty-endpoint"]').exists() + wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty"]').exists() ).toBeTruthy(); }); - it('it renders no exception items screen when "currentState" is "empty" and "listType" is "detection"', () => { + it('it renders no exception items screen when "currentState" is "empty" and "listTypes" includes "detection"', () => { (useReducer as jest.Mock).mockReturnValue([ { exceptions: [], @@ -228,7 +268,7 @@ describe('ExceptionsViewer', () => { }, ], }} - listType={ExceptionListTypeEnum.DETECTION} + listTypes={[ExceptionListTypeEnum.DETECTION]} isViewReadOnly={false} /> @@ -241,7 +281,7 @@ describe('ExceptionsViewer', () => { i18n.EXCEPTION_EMPTY_PROMPT_BUTTON ); expect( - wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty-detection"]').exists() + wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty"]').exists() ).toBeTruthy(); }); @@ -271,7 +311,7 @@ describe('ExceptionsViewer', () => { }, ], }} - listType={ExceptionListTypeEnum.DETECTION} + listTypes={[ExceptionListTypeEnum.DETECTION]} isViewReadOnly={false} /> ); @@ -305,7 +345,7 @@ describe('ExceptionsViewer', () => { }, ], }} - listType={ExceptionListTypeEnum.DETECTION} + listTypes={[ExceptionListTypeEnum.DETECTION]} isViewReadOnly={false} /> ); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.tsx index 97de081738ffd2..6de23a76981b8e 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.tsx @@ -6,22 +6,24 @@ */ import React, { useCallback, useMemo, useEffect, useReducer } from 'react'; +import styled from 'styled-components'; + import { EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import type { ExceptionListItemSchema, UseExceptionListItemsSuccess, Pagination, + ExceptionListSchema, } from '@kbn/securitysolution-io-ts-list-types'; -import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import { transformInput } from '@kbn/securitysolution-list-hooks'; import { deleteExceptionListItemById, fetchExceptionListsItemsByListIds, } from '@kbn/securitysolution-list-api'; -import styled from 'styled-components'; -import { DEFAULT_INDEX_PATTERN } from '../../../../../common/constants'; + import { useUserData } from '../../../../detections/components/user_info'; import { useKibana, useToasts } from '../../../../common/lib/kibana'; import { ExceptionsViewerSearchBar } from './search_bar'; @@ -74,7 +76,7 @@ export interface GetExceptionItemProps { interface ExceptionsViewerProps { rule: Rule | null; - listType: ExceptionListTypeEnum; + listTypes: ExceptionListTypeEnum[]; /* Used for when displaying exceptions for a rule that has since been deleted, forcing read only view */ isViewReadOnly: boolean; onRuleChange?: () => void; @@ -82,7 +84,7 @@ interface ExceptionsViewerProps { const ExceptionsViewerComponent = ({ rule, - listType, + listTypes, isViewReadOnly, onRuleChange, }: ExceptionsViewerProps): JSX.Element => { @@ -92,9 +94,24 @@ const ExceptionsViewerComponent = ({ const exceptionListsToQuery = useMemo( () => rule != null && rule.exceptions_list != null - ? rule.exceptions_list.filter((list) => list.type === listType) + ? rule.exceptions_list.filter(({ type }) => + listTypes.includes(type as ExceptionListTypeEnum) + ) : [], - [listType, rule] + [listTypes, rule] + ); + const exceptionListsFormattedForReferenceQuery = useMemo( + () => + exceptionListsToQuery.map(({ id, list_id: listId, namespace_type: namespaceType }) => ({ + id, + listId, + namespaceType, + })), + [exceptionListsToQuery] + ); + const isEndpointSpecified = useMemo( + () => listTypes.length === 1 && listTypes[0] === ExceptionListTypeEnum.ENDPOINT, + [listTypes] ); // Reducer state @@ -166,13 +183,10 @@ const ExceptionsViewerComponent = ({ useFindExceptionListReferences(); useEffect(() => { - if (fetchReferences != null && exceptionListsToQuery.length) { - const listsToQuery = exceptionListsToQuery.map( - ({ id, list_id: listId, namespace_type: namespaceType }) => ({ id, listId, namespaceType }) - ); - fetchReferences(listsToQuery); + if (fetchReferences != null && exceptionListsFormattedForReferenceQuery.length) { + fetchReferences(exceptionListsFormattedForReferenceQuery); } - }, [exceptionListsToQuery, fetchReferences]); + }, [exceptionListsFormattedForReferenceQuery, fetchReferences]); useEffect(() => { if (isFetchReferencesError) { @@ -241,6 +255,7 @@ const ExceptionsViewerComponent = ({ async (options?: GetExceptionItemProps) => { try { const { pageIndex, itemsPerPage, total, data } = await handleFetchItems(options); + setViewerState(total > 0 ? null : 'empty'); setExceptions({ exceptions: data, @@ -306,15 +321,26 @@ const ExceptionsViewerComponent = ({ [setFlyoutType] ); - const handleCancelExceptionItemFlyout = useCallback((): void => { - setFlyoutType(null); - handleGetExceptionListItems(); - }, [setFlyoutType, handleGetExceptionListItems]); + const handleCancelExceptionItemFlyout = useCallback( + (didRuleChange: boolean): void => { + setFlyoutType(null); + if (didRuleChange && onRuleChange != null) { + onRuleChange(); + } + }, + [onRuleChange, setFlyoutType] + ); - const handleConfirmExceptionFlyout = useCallback((): void => { - setFlyoutType(null); - handleGetExceptionListItems(); - }, [setFlyoutType, handleGetExceptionListItems]); + const handleConfirmExceptionFlyout = useCallback( + (didRuleChange: boolean): void => { + setFlyoutType(null); + if (didRuleChange && onRuleChange != null) { + onRuleChange(); + } + handleGetExceptionListItems(); + }, + [setFlyoutType, handleGetExceptionListItems, onRuleChange] + ); const handleDeleteException = useCallback( async ({ id: itemId, name, namespaceType }: ExceptionListItemIdentifiers) => { @@ -360,49 +386,53 @@ const ExceptionsViewerComponent = ({ } }, [exceptionListsToQuery.length, handleGetExceptionListItems, setViewerState]); + const exceptionToEditList = useMemo( + (): ExceptionListSchema | null => + allReferences != null && exceptionToEdit != null + ? (allReferences[exceptionToEdit.list_id] as ExceptionListSchema) + : null, + [allReferences, exceptionToEdit] + ); + return ( <> - {currenFlyout === 'editException' && exceptionToEdit != null && rule != null && ( - - )} + {currenFlyout === 'editException' && + exceptionToEditList != null && + exceptionToEdit != null && + rule != null && ( + + )} {currenFlyout === 'addException' && rule != null && ( )} <> - {listType === ExceptionListTypeEnum.ENDPOINT - ? i18n.ENDPOINT_EXCEPTIONS_TAB_ABOUT - : i18n.EXCEPTIONS_TAB_ABOUT} + {isEndpointSpecified ? i18n.ENDPOINT_EXCEPTIONS_TAB_ABOUT : i18n.EXCEPTIONS_TAB_ABOUT} {!STATES_SEARCH_HIDDEN.includes(viewerState) && ( { it('it does not display add exception button if user is read only', () => { const wrapper = mount( { const wrapper = mount( { expect(wrapper.find('[data-test-subj="exceptionsHeaderAddExceptionBtn"]').at(0).text()).toEqual( 'Add rule exception' ); - expect(mockOnAddExceptionClick).toHaveBeenCalledWith('detection'); + expect(mockOnAddExceptionClick).toHaveBeenCalled(); }); it('it invokes "onAddExceptionClick" when user selects to add an endpoint exception item', () => { @@ -52,7 +50,7 @@ describe('ExceptionsViewerSearchBar', () => { const wrapper = mount( { expect(wrapper.find('[data-test-subj="exceptionsHeaderAddExceptionBtn"]').at(0).text()).toEqual( 'Add endpoint exception' ); - expect(mockOnAddExceptionClick).toHaveBeenCalledWith('endpoint'); + expect(mockOnAddExceptionClick).toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/search_bar.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/search_bar.tsx index ec85567baef420..36dac931265c23 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/search_bar.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/search_bar.tsx @@ -8,8 +8,6 @@ import React, { useCallback, useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiButton, EuiSearchBar } from '@elastic/eui'; -import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; -import * as sharedI18n from '../../utils/translations'; import * as i18n from './translations'; import type { GetExceptionItemProps } from '.'; @@ -47,10 +45,10 @@ interface ExceptionsViewerSearchBarProps { canAddException: boolean; // Exception list type used to determine what type of item is // being created when "onAddExceptionClick" is invoked - listType: ExceptionListTypeEnum; + isEndpoint: boolean; isSearching: boolean; onSearch: (arg: GetExceptionItemProps) => void; - onAddExceptionClick: (type: ExceptionListTypeEnum) => void; + onAddExceptionClick: () => void; } /** @@ -58,7 +56,7 @@ interface ExceptionsViewerSearchBarProps { */ const ExceptionsViewerSearchBarComponent = ({ canAddException, - listType, + isEndpoint, isSearching, onSearch, onAddExceptionClick, @@ -71,14 +69,12 @@ const ExceptionsViewerSearchBarComponent = ({ ); const handleAddException = useCallback(() => { - onAddExceptionClick(listType); - }, [onAddExceptionClick, listType]); + onAddExceptionClick(); + }, [onAddExceptionClick]); const addExceptionButtonText = useMemo(() => { - return listType === ExceptionListTypeEnum.ENDPOINT - ? sharedI18n.ADD_TO_ENDPOINT_LIST - : sharedI18n.ADD_TO_DETECTIONS_LIST; - }, [listType]); + return isEndpoint ? i18n.ADD_TO_ENDPOINT_LIST : i18n.ADD_TO_DETECTIONS_LIST; + }, [isEndpoint]); return ( diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/translations.ts index 221143a1e0b641..6e50d5d28fe3e0 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/translations.ts @@ -8,63 +8,63 @@ import { i18n } from '@kbn/i18n'; export const EXCEPTION_NO_SEARCH_RESULTS_PROMPT_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.noSearchResultsPromptTitle', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.noSearchResultsPromptTitle', { defaultMessage: 'No results match your search criteria', } ); export const EXCEPTION_NO_SEARCH_RESULTS_PROMPT_BODY = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.noSearchResultsPromptBody', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.noSearchResultsPromptBody', { defaultMessage: 'Try modifying your search.', } ); export const EXCEPTION_EMPTY_PROMPT_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.addExceptionsEmptyPromptTitle', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.addExceptionsEmptyPromptTitle', { defaultMessage: 'Add exceptions to this rule', } ); export const EXCEPTION_EMPTY_PROMPT_BODY = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.emptyPromptBody', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.emptyPromptBody', { defaultMessage: 'There are no exceptions for this rule. Create your first rule exception.', } ); export const EXCEPTION_EMPTY_ENDPOINT_PROMPT_BODY = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.endpoint.emptyPromptBody', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.endpoint.emptyPromptBody', { defaultMessage: 'There are no endpoint exceptions. Create your first endpoint exception.', } ); export const EXCEPTION_EMPTY_PROMPT_BUTTON = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.emptyPromptButtonLabel', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.emptyPromptButtonLabel', { defaultMessage: 'Add rule exception', } ); export const EXCEPTION_EMPTY_PROMPT_ENDPOINT_BUTTON = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.endpoint.emptyPromptButtonLabel', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.endpoint.emptyPromptButtonLabel', { defaultMessage: 'Add endpoint exception', } ); export const EXCEPTION_ERROR_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.exceptionItemsFetchError', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.exceptionItemsFetchError', { defaultMessage: 'Unable to load exception items', } ); export const EXCEPTION_ERROR_DESCRIPTION = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.exceptionItemsFetchErrorDescription', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.exceptionItemsFetchErrorDescription', { defaultMessage: 'There was an error loading the exception items. Contact your administrator for help.', @@ -72,48 +72,51 @@ export const EXCEPTION_ERROR_DESCRIPTION = i18n.translate( ); export const EXCEPTION_SEARCH_ERROR_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.exceptionItemSearchErrorTitle', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.exceptionItemSearchErrorTitle', { defaultMessage: 'Error searching', } ); export const EXCEPTION_SEARCH_ERROR_BODY = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.exceptionItemSearchErrorBody', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.exceptionItemSearchErrorBody', { defaultMessage: 'An error occurred searching for exception items. Please try again.', } ); export const EXCEPTION_DELETE_ERROR_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.exceptionDeleteErrorTitle', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.exceptionDeleteErrorTitle', { defaultMessage: 'Error deleting exception item', } ); export const EXCEPTION_ITEMS_PAGINATION_ARIA_LABEL = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.paginationAriaLabel', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.paginationAriaLabel', { defaultMessage: 'Exception item table pagination', } ); export const EXCEPTION_ITEM_DELETE_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.exceptionItemDeleteSuccessTitle', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.exceptionItemDeleteSuccessTitle', { defaultMessage: 'Exception deleted', } ); export const EXCEPTION_ITEM_DELETE_TEXT = (itemName: string) => - i18n.translate('xpack.securitySolution.exceptions.allItems.exceptionItemDeleteSuccessText', { - values: { itemName }, - defaultMessage: '"{itemName}" deleted successfully.', - }); + i18n.translate( + 'xpack.securitySolution.ruleExceptions.allExceptionItems.exceptionItemDeleteSuccessText', + { + values: { itemName }, + defaultMessage: '"{itemName}" deleted successfully.', + } + ); export const ENDPOINT_EXCEPTIONS_TAB_ABOUT = i18n.translate( - 'xpack.securitySolution.exceptions.allExceptionItems.exceptionEndpointDetailsDescription', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.exceptionEndpointDetailsDescription', { defaultMessage: 'Endpoint exceptions are added to both the detection rule and the Elastic Endpoint agent on your hosts.', @@ -121,15 +124,29 @@ export const ENDPOINT_EXCEPTIONS_TAB_ABOUT = i18n.translate( ); export const EXCEPTIONS_TAB_ABOUT = i18n.translate( - 'xpack.securitySolution.exceptions.allExceptionItems.exceptionDetectionDetailsDescription', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.exceptionDetectionDetailsDescription', { defaultMessage: 'Rule exceptions are added to the detection rule.', } ); export const SEARCH_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.exceptions.allExceptionItems.searchPlaceholder', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.searchPlaceholder', { defaultMessage: 'Filter exceptions using simple query syntax, for example, name:"my list"', } ); + +export const ADD_TO_ENDPOINT_LIST = i18n.translate( + 'xpack.securitySolution.ruleExceptions.allExceptionItems.addToEndpointListLabel', + { + defaultMessage: 'Add endpoint exception', + } +); + +export const ADD_TO_DETECTIONS_LIST = i18n.translate( + 'xpack.securitySolution.ruleExceptions.allExceptionItems.addToDetectionsListLabel', + { + defaultMessage: 'Add rule exception', + } +); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/utility_bar.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/utility_bar.test.tsx index aa604dbfbf0014..d9fe0218311c56 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/utility_bar.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/utility_bar.test.tsx @@ -6,14 +6,14 @@ */ import React from 'react'; -import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { mount } from 'enzyme'; import { ExceptionsViewerUtility } from './utility_bar'; import { TestProviders } from '../../../../common/mock'; describe('ExceptionsViewerUtility', () => { it('it renders correct item counts', () => { - const wrapper = mountWithIntl( + const wrapper = mount( { }); it('it renders last updated message', () => { - const wrapper = mountWithIntl( + const wrapper = mount( >; const mockUseSignalIndex = useSignalIndex as jest.Mock>>; -const mockUseAddOrUpdateException = useAddOrUpdateException as jest.Mock< - ReturnType ->; const mockUseFetchIndex = useFetchIndex as jest.Mock; const mockUseCurrentUser = useCurrentUser as jest.Mock>>; -const mockUseRuleAsync = useRuleAsync as jest.Mock; +const mockFetchIndexPatterns = useFetchIndexPatterns as jest.Mock< + ReturnType +>; +const mockUseAddOrUpdateException = useCreateOrUpdateException as jest.Mock< + ReturnType +>; +const mockUseFindExceptionListReferences = useFindExceptionListReferences as jest.Mock; describe('When the edit exception modal is opened', () => { - const ruleName = 'test rule'; - beforeEach(() => { const emptyComp = ; mockGetExceptionBuilderComponentLazy.mockReturnValue(emptyComp); @@ -66,19 +76,42 @@ describe('When the edit exception modal is opened', () => { loading: false, signalIndexName: 'test-signal', }); - mockUseAddOrUpdateException.mockImplementation(() => [{ isLoading: false }, jest.fn()]); + mockUseAddOrUpdateException.mockImplementation(() => [false, jest.fn()]); mockUseFetchIndex.mockImplementation(() => [ false, { indexPatterns: createStubIndexPattern({ spec: { id: '1234', - title: 'logstash-*', + title: 'filebeat-*', fields: { - response: { - name: 'response', - type: 'number', - esTypes: ['integer'], + 'event.code': { + name: 'event.code', + type: 'string', + aggregatable: true, + searchable: true, + }, + 'file.path.caseless': { + name: 'file.path.caseless', + type: 'string', + aggregatable: true, + searchable: true, + }, + subject_name: { + name: 'subject_name', + type: 'string', + aggregatable: true, + searchable: true, + }, + trusted: { + name: 'trusted', + type: 'string', + aggregatable: true, + searchable: true, + }, + 'file.hash.sha256': { + name: 'file.hash.sha256', + type: 'string', aggregatable: true, searchable: true, }, @@ -88,9 +121,40 @@ describe('When the edit exception modal is opened', () => { }, ]); mockUseCurrentUser.mockReturnValue({ username: 'test-username' }); - mockUseRuleAsync.mockImplementation(() => ({ - rule: getRulesSchemaMock(), + mockFetchIndexPatterns.mockImplementation(() => ({ + isLoading: false, + indexPatterns: stubIndexPattern, })); + mockUseFindExceptionListReferences.mockImplementation(() => [ + false, + false, + { + my_list_id: { + ...getExceptionListSchemaMock(), + id: '123', + list_id: 'my_list_id', + namespace_type: 'single', + type: ExceptionListTypeEnum.DETECTION, + name: 'My exception list', + referenced_rules: [ + { + id: '345', + name: 'My rule', + rule_id: 'my_rule_id', + exception_lists: [ + { + id: '1234', + list_id: 'my_list_id', + namespace_type: 'single', + type: ExceptionListTypeEnum.DETECTION, + }, + ], + }, + ], + }, + }, + jest.fn(), + ]); }); afterEach(() => { @@ -100,24 +164,22 @@ describe('When the edit exception modal is opened', () => { describe('when the modal is loading', () => { it('renders the loading spinner', async () => { - mockUseFetchIndex.mockImplementation(() => [ - true, - { - indexPatterns: stubIndexPattern, - }, - ]); + // Mocks one of the hooks as loading + mockFetchIndexPatterns.mockImplementation(() => ({ + isLoading: true, + indexPatterns: { fields: [], title: 'foo' }, + })); + const wrapper = mount( - + - + ); await waitFor(() => { expect(wrapper.find('[data-test-subj="loadingEditExceptionFlyout"]').exists()).toBeTruthy(); @@ -125,69 +187,198 @@ describe('When the edit exception modal is opened', () => { }); }); - describe('when an endpoint exception with exception data is passed', () => { - describe('when exception entry fields are included in the index pattern', () => { + describe('exception list type of "endpoint"', () => { + mockUseFindExceptionListReferences.mockImplementation(() => [ + false, + false, + { + endpoint_list: { + ...getExceptionListSchemaMock(), + id: '123', + list_id: 'endpoint_list', + namespace_type: 'agnostic', + type: ExceptionListTypeEnum.ENDPOINT, + name: 'My exception list', + referenced_rules: [ + { + id: '345', + name: 'My rule', + rule_id: 'my_rule_id', + exception_lists: [ + { + id: 'endpoint_list', + list_id: 'endpoint_list', + namespace_type: 'single', + type: ExceptionListTypeEnum.ENDPOINT, + }, + ], + }, + ], + }, + }, + jest.fn(), + ]); + + describe('common functionality to test', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + wrapper = mount( + + + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) + ); + }); + + it('displays proper flyout and button text', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutTitle"]').at(1).text()).toEqual( + i18n.EDIT_ENDPOINT_EXCEPTION_TITLE + ); + expect(wrapper.find('[data-test-subj="editExceptionConfirmButton"]').at(1).text()).toEqual( + i18n.EDIT_ENDPOINT_EXCEPTION_TITLE + ); + }); + + it('should render item name input', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutNameInput"]').exists()).toBeTruthy(); + }); + + it('should render OS info', () => { + expect(wrapper.find('[data-test-subj="exceptionItemSelectedOs"]').exists()).toBeTruthy(); + }); + + it('should render the exception builder', () => { + expect(wrapper.find('ExceptionsConditions').exists()).toBeTruthy(); + }); + + it('does NOT render section showing list or rule item assigned to', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemLinkedToListSection"]').exists() + ).toBeFalsy(); + expect( + wrapper.find('[data-test-subj="exceptionItemLinkedToRuleSection"]').exists() + ).toBeFalsy(); + }); + + it('should contain the endpoint specific documentation text', () => { + expect(wrapper.find('[data-test-subj="addExceptionEndpointText"]').exists()).toBeTruthy(); + }); + + it('should NOT display the eql sequence callout', () => { + expect(wrapper.find('[data-test-subj="eqlSequenceCallout"]').exists()).not.toBeTruthy(); + }); + }); + + describe('when exception entry fields and index allow user to bulk close', () => { let wrapper: ReactWrapper; beforeEach(async () => { const exceptionItemMock = { ...getExceptionListItemSchemaMock(), entries: [ - { field: 'response', operator: 'included', type: 'match', value: '3' }, + { field: 'file.hash.sha256', operator: 'included', type: 'match' }, ] as EntriesArray, }; wrapper = mount( ); const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; - await waitFor(() => { - callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }); - }); - }); - it('has the edit exception button enabled', () => { - expect( - wrapper.find('button[data-test-subj="edit-exception-confirm-button"]').getDOMNode() - ).not.toBeDisabled(); + await waitFor(() => + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) + ); }); + it('should have the bulk close checkbox enabled', () => { expect( - wrapper - .find('input[data-test-subj="close-alert-on-add-edit-exception-checkbox"]') - .getDOMNode() + wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() ).not.toBeDisabled(); }); - it('renders the exceptions builder', () => { - expect(wrapper.find('[data-test-subj="edit-exception-builder"]').exists()).toBeTruthy(); - }); - it('should contain the endpoint specific documentation text', () => { - expect( - wrapper.find('[data-test-subj="edit-exception-endpoint-text"]').exists() - ).toBeTruthy(); - }); }); - describe("when exception entry fields aren't included in the index pattern", () => { + describe('when entry has non ecs type', () => { let wrapper: ReactWrapper; beforeEach(async () => { wrapper = mount( ); @@ -196,182 +387,310 @@ describe('When the edit exception modal is opened', () => { callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }); }); }); - it('has the edit exception button enabled', () => { - expect( - wrapper.find('button[data-test-subj="edit-exception-confirm-button"]').getDOMNode() - ).not.toBeDisabled(); - }); + it('should have the bulk close checkbox disabled', () => { expect( - wrapper - .find('input[data-test-subj="close-alert-on-add-edit-exception-checkbox"]') - .getDOMNode() + wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() ).toBeDisabled(); }); - it('renders the exceptions builder', () => { - expect(wrapper.find('[data-test-subj="edit-exception-builder"]').exists()).toBeTruthy(); - }); - it('should contain the endpoint specific documentation text', () => { - expect( - wrapper.find('[data-test-subj="edit-exception-endpoint-text"]').exists() - ).toBeTruthy(); - }); }); }); - describe('when an exception assigned to a sequence eql rule type is passed', () => { + describe('exception list type of "detection"', () => { let wrapper: ReactWrapper; beforeEach(async () => { - (useRuleAsync as jest.Mock).mockImplementation(() => ({ - rule: { - ...getRulesEqlSchemaMock(), - query: - 'sequence [process where process.name = "test.exe"] [process where process.name = "explorer.exe"]', - }, - })); wrapper = mount( - + - + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) ); - const callProps = (getExceptionBuilderComponentLazy as jest.Mock).mock.calls[0][0]; - await waitFor(() => { - callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }); - }); }); - it('has the edit exception button enabled', () => { - expect( - wrapper.find('button[data-test-subj="edit-exception-confirm-button"]').getDOMNode() - ).not.toBeDisabled(); + + it('displays proper flyout and button text', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutTitle"]').at(1).text()).toEqual( + i18n.EDIT_EXCEPTION_TITLE + ); + expect(wrapper.find('[data-test-subj="editExceptionConfirmButton"]').at(1).text()).toEqual( + i18n.EDIT_EXCEPTION_TITLE + ); }); - it('renders the exceptions builder', () => { - expect(wrapper.find('[data-test-subj="edit-exception-builder"]').exists()).toBeTruthy(); + + it('should render item name input', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutNameInput"]').exists()).toBeTruthy(); }); - it('should not contain the endpoint specific documentation text', () => { - expect(wrapper.find('[data-test-subj="edit-exception-endpoint-text"]').exists()).toBeFalsy(); + + it('should not render OS info', () => { + expect(wrapper.find('[data-test-subj="exceptionItemSelectedOs"]').exists()).toBeFalsy(); }); - it('should have the bulk close checkbox disabled', () => { + + it('should render the exception builder', () => { + expect(wrapper.find('ExceptionsConditions').exists()).toBeTruthy(); + }); + + it('does render section showing list item is assigned to', () => { expect( - wrapper - .find('input[data-test-subj="close-alert-on-add-edit-exception-checkbox"]') - .getDOMNode() - ).toBeDisabled(); + wrapper.find('[data-test-subj="exceptionItemLinkedToListSection"]').exists() + ).toBeTruthy(); }); - it('should display the eql sequence callout', () => { - expect(wrapper.find('[data-test-subj="eql-sequence-callout"]').exists()).toBeTruthy(); + + it('does NOT render section showing rule item is assigned to', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemLinkedToRuleSection"]').exists() + ).toBeFalsy(); + }); + + it('should NOT contain the endpoint specific documentation text', () => { + expect(wrapper.find('[data-test-subj="addExceptionEndpointText"]').exists()).toBeFalsy(); + }); + + it('should NOT display the eql sequence callout', () => { + expect(wrapper.find('[data-test-subj="eqlSequenceCallout"]').exists()).toBeFalsy(); }); }); - describe('when a detection exception with entries is passed', () => { + describe('exception list type of "rule_default"', () => { + mockUseFindExceptionListReferences.mockImplementation(() => [ + false, + false, + { + my_list_id: { + ...getExceptionListSchemaMock(), + id: '123', + list_id: 'my_list_id', + namespace_type: 'single', + type: ExceptionListTypeEnum.RULE_DEFAULT, + name: 'My exception list', + referenced_rules: [ + { + id: '345', + name: 'My rule', + rule_id: 'my_rule_id', + exception_lists: [ + { + id: '1234', + list_id: 'my_list_id', + namespace_type: 'single', + type: ExceptionListTypeEnum.RULE_DEFAULT, + }, + ], + }, + ], + }, + }, + jest.fn(), + ]); + let wrapper: ReactWrapper; beforeEach(async () => { wrapper = mount( - + - + ); const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; - await waitFor(() => { - callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }); - }); + await waitFor(() => + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) + ); }); - it('has the edit exception button enabled', () => { - expect( - wrapper.find('button[data-test-subj="edit-exception-confirm-button"]').getDOMNode() - ).not.toBeDisabled(); + + it('displays proper flyout and button text', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutTitle"]').at(1).text()).toEqual( + i18n.EDIT_EXCEPTION_TITLE + ); + expect(wrapper.find('[data-test-subj="editExceptionConfirmButton"]').at(1).text()).toEqual( + i18n.EDIT_EXCEPTION_TITLE + ); }); - it('renders the exceptions builder', () => { - expect(wrapper.find('[data-test-subj="edit-exception-builder"]').exists()).toBeTruthy(); + + it('should render item name input', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutNameInput"]').exists()).toBeTruthy(); }); - it('should not contain the endpoint specific documentation text', () => { - expect(wrapper.find('[data-test-subj="edit-exception-endpoint-text"]').exists()).toBeFalsy(); + + it('should not render OS info', () => { + expect(wrapper.find('[data-test-subj="exceptionItemSelectedOs"]').exists()).toBeFalsy(); }); - it('should have the bulk close checkbox disabled', () => { + + it('should render the exception builder', () => { + expect(wrapper.find('ExceptionsConditions').exists()).toBeTruthy(); + }); + + it('does NOT render section showing list item is assigned to', () => { expect( - wrapper - .find('input[data-test-subj="close-alert-on-add-edit-exception-checkbox"]') - .getDOMNode() - ).toBeDisabled(); + wrapper.find('[data-test-subj="exceptionItemLinkedToListSection"]').exists() + ).toBeFalsy(); + }); + + it('does render section showing rule item is assigned to', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemLinkedToRuleSection"]').exists() + ).toBeTruthy(); }); - it('should not display the eql sequence callout', () => { - expect(wrapper.find('[data-test-subj="eql-sequence-callout"]').exists()).not.toBeTruthy(); + + it('should NOT contain the endpoint specific documentation text', () => { + expect(wrapper.find('[data-test-subj="addExceptionEndpointText"]').exists()).toBeFalsy(); + }); + + it('should NOT display the eql sequence callout', () => { + expect(wrapper.find('[data-test-subj="eqlSequenceCallout"]').exists()).toBeFalsy(); }); }); - describe('when an exception with no entries is passed', () => { + describe('when an exception assigned to a sequence eql rule type is passed', () => { let wrapper: ReactWrapper; beforeEach(async () => { - const exceptionItemMock = { ...getExceptionListItemSchemaMock(), entries: [] }; wrapper = mount( - + - + ); - const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + const callProps = (getExceptionBuilderComponentLazy as jest.Mock).mock.calls[0][0]; await waitFor(() => { callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }); }); }); - it('has the edit exception button disabled', () => { + + it('should have the bulk close checkbox disabled', () => { expect( - wrapper.find('button[data-test-subj="edit-exception-confirm-button"]').getDOMNode() + wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() ).toBeDisabled(); }); - it('renders the exceptions builder', () => { - expect(wrapper.find('[data-test-subj="edit-exception-builder"]').exists()).toBeTruthy(); + + it('should display the eql sequence callout', () => { + expect(wrapper.find('[data-test-subj="eqlSequenceCallout"]').exists()).toBeTruthy(); }); - it('should have the bulk close checkbox disabled', () => { + }); + + describe('error states', () => { + test('when there are exception builder errors has submit button disabled', async () => { + const wrapper = mount( + + + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => callProps.onChange({ exceptionItems: [], errorExists: true })); + expect( - wrapper - .find('input[data-test-subj="close-alert-on-add-edit-exception-checkbox"]') - .getDOMNode() + wrapper.find('button[data-test-subj="editExceptionConfirmButton"]').getDOMNode() ).toBeDisabled(); }); }); - - test('when there are exception builder errors has the add exception button disabled', async () => { - const wrapper = mount( - - - - ); - const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; - await waitFor(() => callProps.onChange({ exceptionItems: [], errorExists: true })); - - expect( - wrapper.find('button[data-test-subj="edit-exception-confirm-button"]').getDOMNode() - ).toBeDisabled(); - }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/index.tsx index 85a59f06281f1f..d6dbc402a92e63 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/index.tsx @@ -5,75 +5,61 @@ * 2.0. */ -// Component being re-implemented in 8.5 - -/* eslint-disable complexity */ - -import React, { memo, useState, useCallback, useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useReducer } from 'react'; import styled, { css } from 'styled-components'; import { EuiButton, EuiButtonEmpty, EuiHorizontalRule, - EuiCheckbox, EuiSpacer, - EuiFormRow, - EuiText, - EuiCallOut, EuiFlyoutHeader, EuiFlyoutBody, EuiFlexGroup, EuiTitle, EuiFlyout, EuiFlyoutFooter, + EuiLoadingContent, } from '@elastic/eui'; import type { - ExceptionListType, - OsTypeArray, - OsType, ExceptionListItemSchema, - CreateExceptionListItemSchema, + ExceptionListSchema, +} from '@kbn/securitysolution-io-ts-list-types'; +import { + ExceptionListTypeEnum, + exceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; -import { getExceptionBuilderComponentLazy } from '@kbn/lists-plugin/public'; -import type { DataViewBase } from '@kbn/es-query'; +import { isEmpty } from 'lodash/fp'; import type { ExceptionsBuilderReturnExceptionItem } from '@kbn/securitysolution-list-utils'; -import { useRuleIndices } from '../../../../detections/containers/detection_engine/rules/use_rule_indices'; -import { hasEqlSequenceQuery, isEqlRule } from '../../../../../common/detection_engine/utils'; -import { useFetchIndex } from '../../../../common/containers/source'; -import { useSignalIndex } from '../../../../detections/containers/detection_engine/alerts/use_signal_index'; -import { useRuleAsync } from '../../../../detections/containers/detection_engine/rules/use_rule_async'; - import * as i18n from './translations'; -import * as sharedI18n from '../../utils/translations'; -import { useKibana } from '../../../../common/lib/kibana'; -import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; -import { useAddOrUpdateException } from '../../logic/use_add_exception'; -import { ExceptionItemComments } from '../item_comments'; +import { ExceptionsFlyoutMeta } from '../flyout_components/item_meta_form'; +import { createExceptionItemsReducer } from './reducer'; +import { ExceptionsLinkedToLists } from '../flyout_components/linked_to_list'; +import { ExceptionsLinkedToRule } from '../flyout_components/linked_to_rule'; +import type { Rule } from '../../../../detections/containers/detection_engine/rules/types'; +import { ExceptionItemsFlyoutAlertsActions } from '../flyout_components/alerts_actions'; +import { ExceptionsConditions } from '../flyout_components/item_conditions'; import { - enrichExistingExceptionItemWithComments, - enrichExceptionItemsWithOS, - entryHasListType, - entryHasNonEcsType, - lowercaseHashValues, - filterIndexPatterns, -} from '../../utils/helpers'; -import { Loader } from '../../../../common/components/loader'; -import type { ErrorInfo } from '../error_callout'; -import { ErrorCallout } from '../error_callout'; -import { ruleTypesThatAllowLargeValueLists } from '../../utils/constants'; + isEqlRule, + isNewTermsRule, + isThresholdRule, +} from '../../../../../common/detection_engine/utils'; +import { useFetchIndexPatterns } from '../../logic/use_exception_flyout_data'; +import { filterIndexPatterns } from '../../utils/helpers'; +import { entrichExceptionItemsForUpdate } from '../flyout_components/utils'; +import { useEditExceptionItems } from './use_edit_exception'; +import { useCloseAlertsFromExceptions } from '../../logic/use_close_alerts'; +import { useFindExceptionListReferences } from '../../logic/use_find_references'; +import { ExceptionItemComments } from '../item_comments'; interface EditExceptionFlyoutProps { - ruleName: string; - ruleId: string; - ruleIndices: string[]; - dataViewId?: string; - exceptionItem: ExceptionListItemSchema; - exceptionListType: ExceptionListType; - onCancel: () => void; - onConfirm: () => void; - onRuleChange?: () => void; + list: ExceptionListSchema; + itemToEdit: ExceptionListItemSchema; + showAlertCloseOptions: boolean; + rule?: Rule; + onCancel: (arg: boolean) => void; + onConfirm: (arg: boolean) => void; } const FlyoutHeader = styled(EuiFlyoutHeader)` @@ -82,412 +68,335 @@ const FlyoutHeader = styled(EuiFlyoutHeader)` `} `; -const FlyoutSubtitle = styled.div` - ${({ theme }) => css` - color: ${theme.eui.euiColorMediumShade}; +const FlyoutBodySection = styled(EuiFlyoutBody)` + ${() => css` + &.builder-section { + overflow-y: scroll; + } `} `; -const FlyoutBodySection = styled.section` +const FlyoutFooterGroup = styled(EuiFlexGroup)` ${({ theme }) => css` - padding: ${theme.eui.euiSizeS} ${theme.eui.euiSizeL}; + padding: ${theme.eui.euiSizeS}; `} `; -const FlyoutCheckboxesSection = styled.section` - overflow-y: inherit; - height: auto; - .euiFlyoutBody__overflowContent { - padding-top: 0; - } -`; - -const FlyoutFooterGroup = styled(EuiFlexGroup)` - ${({ theme }) => css` - padding: ${theme.eui.euiSizeS}; +const SectionHeader = styled(EuiTitle)` + ${() => css` + font-weight: ${({ theme }) => theme.eui.euiFontWeightSemiBold}; `} `; -export const EditExceptionFlyout = memo(function EditExceptionFlyout({ - ruleName, - ruleId, - ruleIndices, - dataViewId, - exceptionItem, - exceptionListType, +const EditExceptionFlyoutComponent: React.FC = ({ + list, + itemToEdit, + rule, + showAlertCloseOptions, onCancel, onConfirm, - onRuleChange, -}: EditExceptionFlyoutProps) { - const { http, unifiedSearch, data } = useKibana().services; - const [comment, setComment] = useState(''); - const [errorsExist, setErrorExists] = useState(false); - const { rule: maybeRule, loading: isRuleLoading } = useRuleAsync(ruleId); - const [updateError, setUpdateError] = useState(null); - const [hasVersionConflict, setHasVersionConflict] = useState(false); - const [shouldBulkCloseAlert, setShouldBulkCloseAlert] = useState(false); - const [shouldDisableBulkClose, setShouldDisableBulkClose] = useState(false); - const [exceptionItemsToAdd, setExceptionItemsToAdd] = useState< - ExceptionsBuilderReturnExceptionItem[] - >([]); - const { addError, addSuccess } = useAppToasts(); - const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex(); - const memoSignalIndexName = useMemo( - () => (signalIndexName !== null ? [signalIndexName] : []), - [signalIndexName] - ); - const [isSignalIndexPatternLoading, { indexPatterns: signalIndexPatterns }] = - useFetchIndex(memoSignalIndexName); - - const { mlJobLoading, ruleIndices: memoRuleIndices } = useRuleIndices( - maybeRule?.machine_learning_job_id, - ruleIndices - ); +}): JSX.Element => { + const selectedOs = useMemo(() => itemToEdit.os_types, [itemToEdit]); + const rules = useMemo(() => (rule != null ? [rule] : null), [rule]); + const listType = useMemo((): ExceptionListTypeEnum => list.type as ExceptionListTypeEnum, [list]); - const hasDataViewId = dataViewId || maybeRule?.data_view_id || null; - const [dataViewIndexPatterns, setDataViewIndexPatterns] = useState(null); + const { isLoading, indexPatterns } = useFetchIndexPatterns(rules); + const [isSubmitting, submitEditExceptionItems] = useEditExceptionItems(); + const [isClosingAlerts, closeAlerts] = useCloseAlertsFromExceptions(); - useEffect(() => { - const fetchSingleDataView = async () => { - if (hasDataViewId) { - const dv = await data.dataViews.get(hasDataViewId); - setDataViewIndexPatterns(dv); - } - }; - - fetchSingleDataView(); - }, [hasDataViewId, data.dataViews, setDataViewIndexPatterns]); - - // Don't fetch indices if rule has data view id (currently rule can technically have - // both defined and in that case we'd be doing unnecessary work here if all we want is - // the data view fields) - const [isIndexPatternLoading, { indexPatterns: indexIndexPatterns }] = useFetchIndex( - hasDataViewId ? [] : memoRuleIndices - ); + const [ + { + exceptionItems, + exceptionItemMeta: { name: exceptionItemName }, + newComment, + bulkCloseAlerts, + disableBulkClose, + bulkCloseIndex, + entryErrorExists, + }, + dispatch, + ] = useReducer(createExceptionItemsReducer(), { + exceptionItems: [itemToEdit], + exceptionItemMeta: { name: itemToEdit.name }, + newComment: '', + bulkCloseAlerts: false, + disableBulkClose: true, + bulkCloseIndex: undefined, + entryErrorExists: false, + }); + + const allowLargeValueLists = useMemo((): boolean => { + if (rule != null) { + // We'll only block this when we know what rule we're dealing with. + // When editing an item outside the context of a specific rule, + // we won't block but should communicate to the user that large value lists + // won't be applied to all rule types. + return !isEqlRule(rule.type) && !isThresholdRule(rule.type) && !isNewTermsRule(rule.type); + } else { + return true; + } + }, [rule]); - const indexPattern = useMemo( - (): DataViewBase | null => (hasDataViewId ? dataViewIndexPatterns : indexIndexPatterns), - [hasDataViewId, dataViewIndexPatterns, indexIndexPatterns] - ); + const [isLoadingReferences, referenceFetchError, ruleReferences, fetchReferences] = + useFindExceptionListReferences(); - const handleExceptionUpdateError = useCallback( - (error: Error, statusCode: number | null, message: string | null) => { - if (error.message.includes('Conflict')) { - setHasVersionConflict(true); - } else { - setUpdateError({ - reason: error.message, - code: statusCode, - details: message, - listListId: exceptionItem.list_id, - }); - } + useEffect(() => { + if (fetchReferences != null) { + fetchReferences([ + { + id: list.id, + listId: list.list_id, + namespaceType: list.namespace_type, + }, + ]); + } + }, [list, fetchReferences]); + + /** + * Reducer action dispatchers + * */ + const setExceptionItemsToAdd = useCallback( + (items: ExceptionsBuilderReturnExceptionItem[]): void => { + dispatch({ + type: 'setExceptionItems', + items, + }); }, - [setUpdateError, setHasVersionConflict, exceptionItem.list_id] + [dispatch] ); - const handleDissasociationSuccess = useCallback( - (id: string): void => { - addSuccess(sharedI18n.DISSASOCIATE_LIST_SUCCESS(id)); - - if (onRuleChange) { - onRuleChange(); - } - - onCancel(); + const setExceptionItemMeta = useCallback( + (value: [string, string]): void => { + dispatch({ + type: 'setExceptionItemMeta', + value, + }); }, - [addSuccess, onCancel, onRuleChange] + [dispatch] ); - const handleDissasociationError = useCallback( - (error: Error): void => { - addError(error, { title: sharedI18n.DISSASOCIATE_EXCEPTION_LIST_ERROR }); - onCancel(); + const setComment = useCallback( + (comment: string): void => { + dispatch({ + type: 'setComment', + comment, + }); }, - [addError, onCancel] + [dispatch] ); - const handleExceptionUpdateSuccess = useCallback((): void => { - addSuccess(i18n.EDIT_EXCEPTION_SUCCESS); - onConfirm(); - }, [addSuccess, onConfirm]); - - const [{ isLoading: addExceptionIsLoading }, addOrUpdateExceptionItems] = useAddOrUpdateException( - { - http, - onSuccess: handleExceptionUpdateSuccess, - onError: handleExceptionUpdateError, - } + const setBulkCloseAlerts = useCallback( + (bulkClose: boolean): void => { + dispatch({ + type: 'setBulkCloseAlerts', + bulkClose, + }); + }, + [dispatch] ); - useEffect(() => { - if (isSignalIndexPatternLoading === false && isSignalIndexLoading === false) { - setShouldDisableBulkClose( - entryHasListType(exceptionItemsToAdd) || - entryHasNonEcsType(exceptionItemsToAdd, signalIndexPatterns) || - exceptionItemsToAdd.every((item) => item.entries.length === 0) - ); - } - }, [ - setShouldDisableBulkClose, - exceptionItemsToAdd, - isSignalIndexPatternLoading, - isSignalIndexLoading, - signalIndexPatterns, - ]); - - useEffect(() => { - if (shouldDisableBulkClose === true) { - setShouldBulkCloseAlert(false); - } - }, [shouldDisableBulkClose]); - - const isSubmitButtonDisabled = useMemo( - () => - exceptionItemsToAdd.every((item) => item.entries.length === 0) || - hasVersionConflict || - errorsExist, - [exceptionItemsToAdd, hasVersionConflict, errorsExist] + const setDisableBulkCloseAlerts = useCallback( + (disableBulkCloseAlerts: boolean): void => { + dispatch({ + type: 'setDisableBulkCloseAlerts', + disableBulkCloseAlerts, + }); + }, + [dispatch] ); - const handleBuilderOnChange = useCallback( - ({ - exceptionItems, - errorExists, - }: { - exceptionItems: ExceptionsBuilderReturnExceptionItem[]; - errorExists: boolean; - }) => { - setExceptionItemsToAdd(exceptionItems); - setErrorExists(errorExists); + const setBulkCloseIndex = useCallback( + (index: string[] | undefined): void => { + dispatch({ + type: 'setBulkCloseIndex', + bulkCloseIndex: index, + }); }, - [setExceptionItemsToAdd] + [dispatch] ); - const onCommentChange = useCallback( - (value: string) => { - setComment(value); + const setConditionsValidationError = useCallback( + (errorExists: boolean): void => { + dispatch({ + type: 'setConditionValidationErrorExists', + errorExists, + }); }, - [setComment] + [dispatch] ); - const onBulkCloseAlertCheckboxChange = useCallback( - (event: React.ChangeEvent) => { - setShouldBulkCloseAlert(event.currentTarget.checked); + const handleCloseFlyout = useCallback((): void => { + onCancel(false); + }, [onCancel]); + + const areItemsReadyForUpdate = useCallback( + (items: ExceptionsBuilderReturnExceptionItem[]): items is ExceptionListItemSchema[] => { + return items.every((item) => exceptionListItemSchema.is(item)); }, - [setShouldBulkCloseAlert] + [] ); - const enrichExceptionItems = useCallback(() => { - const [exceptionItemToEdit] = exceptionItemsToAdd; - let enriched: ExceptionsBuilderReturnExceptionItem[] = [ - { - ...enrichExistingExceptionItemWithComments(exceptionItemToEdit, [ - ...exceptionItem.comments, - ...(comment.trim() !== '' ? [{ comment }] : []), - ]), - }, - ]; - if (exceptionListType === 'endpoint') { - enriched = lowercaseHashValues(enrichExceptionItemsWithOS(enriched, exceptionItem.os_types)); - } - return enriched; - }, [exceptionItemsToAdd, exceptionItem, comment, exceptionListType]); - - const onEditExceptionConfirm = useCallback(() => { - if (addOrUpdateExceptionItems !== null) { - const bulkCloseIndex = - shouldBulkCloseAlert && signalIndexName !== null ? [signalIndexName] : undefined; - addOrUpdateExceptionItems( - maybeRule?.rule_id ?? '', - // This is being rewritten in https://github.com/elastic/kibana/pull/140643 - // As of now, flyout cannot yet create item of type CreateRuleExceptionListItemSchema - enrichExceptionItems() as Array, - undefined, - bulkCloseIndex - ); + const handleSubmit = useCallback(async (): Promise => { + if (submitEditExceptionItems == null) return; + + try { + const items = entrichExceptionItemsForUpdate({ + itemName: exceptionItemName, + commentToAdd: newComment, + listType, + selectedOs: itemToEdit.os_types, + items: exceptionItems, + }); + + if (areItemsReadyForUpdate(items)) { + await submitEditExceptionItems({ + itemsToUpdate: items, + }); + + const ruleDefaultRule = rule != null ? [rule.rule_id] : []; + const referencedRules = + ruleReferences != null + ? ruleReferences[list.list_id].referenced_rules.map(({ rule_id: ruleId }) => ruleId) + : []; + const ruleIdsForBulkClose = + listType === ExceptionListTypeEnum.RULE_DEFAULT ? ruleDefaultRule : referencedRules; + + if (closeAlerts != null && !isEmpty(ruleIdsForBulkClose) && bulkCloseAlerts) { + await closeAlerts(ruleIdsForBulkClose, items, undefined, bulkCloseIndex); + } + + onConfirm(true); + } + } catch (e) { + onCancel(false); } }, [ - addOrUpdateExceptionItems, - maybeRule, - enrichExceptionItems, - shouldBulkCloseAlert, - signalIndexName, + submitEditExceptionItems, + exceptionItemName, + newComment, + listType, + itemToEdit.os_types, + exceptionItems, + areItemsReadyForUpdate, + rule, + ruleReferences, + list.list_id, + closeAlerts, + bulkCloseAlerts, + onConfirm, + bulkCloseIndex, + onCancel, ]); - const isRuleEQLSequenceStatement = useMemo((): boolean => { - if (maybeRule != null) { - return isEqlRule(maybeRule.type) && hasEqlSequenceQuery(maybeRule.query); - } - return false; - }, [maybeRule]); - - const osDisplay = (osTypes: OsTypeArray): string => { - const translateOS = (currentOs: OsType): string => { - return currentOs === 'linux' - ? sharedI18n.OPERATING_SYSTEM_LINUX - : currentOs === 'macos' - ? sharedI18n.OPERATING_SYSTEM_MAC - : sharedI18n.OPERATING_SYSTEM_WINDOWS; - }; - return osTypes - .reduce((osString, currentOs) => { - return `${translateOS(currentOs)}, ${osString}`; - }, '') - .slice(0, -2); - }; - - const allowLargeValueLists = useMemo( - () => (maybeRule != null ? ruleTypesThatAllowLargeValueLists.includes(maybeRule.type) : false), - [maybeRule] + const editExceptionMessage = useMemo( + () => + listType === ExceptionListTypeEnum.ENDPOINT + ? i18n.EDIT_ENDPOINT_EXCEPTION_TITLE + : i18n.EDIT_EXCEPTION_TITLE, + [listType] + ); + + const isSubmitButtonDisabled = useMemo( + () => + isSubmitting || + isClosingAlerts || + exceptionItems.every((item) => item.entries.length === 0) || + isLoading || + entryErrorExists, + [isLoading, entryErrorExists, exceptionItems, isSubmitting, isClosingAlerts] ); return ( - + -

- {exceptionListType === 'endpoint' - ? i18n.EDIT_ENDPOINT_EXCEPTION_TITLE - : i18n.EDIT_EXCEPTION_TITLE} -

+

{editExceptionMessage}

- -
- {(addExceptionIsLoading || isIndexPatternLoading || isSignalIndexLoading) && ( - - )} - {!isSignalIndexLoading && - indexPattern != null && - !addExceptionIsLoading && - !isIndexPatternLoading && - !isRuleLoading && - !mlJobLoading && ( - - - {isRuleEQLSequenceStatement && ( - <> - - - - )} - {i18n.EXCEPTION_BUILDER_INFO} - - {exceptionListType === 'endpoint' && ( - <> - -
-
{sharedI18n.OPERATING_SYSTEM_LABEL}
-
{osDisplay(exceptionItem.os_types)}
-
-
- - - )} - {getExceptionBuilderComponentLazy({ - allowLargeValueLists, - httpService: http, - autocompleteService: unifiedSearch.autocomplete, - exceptionListItems: [exceptionItem], - listType: exceptionListType, - listId: exceptionItem.list_id, - listNamespaceType: exceptionItem.namespace_type, - listTypeSpecificIndexPatternFilter: filterIndexPatterns, - ruleName, - isOrDisabled: true, - isAndDisabled: false, - osTypes: exceptionItem.os_types, - isNestedDisabled: false, - dataTestSubj: 'edit-exception-builder', - idAria: 'edit-exception-builder', - onChange: handleBuilderOnChange, - indexPatterns: indexPattern, - })} - - - - -
+ {isLoading && } + + + + + {listType === ExceptionListTypeEnum.DETECTION && ( + <> - - - - - {exceptionListType === 'endpoint' && ( - <> - - - {i18n.ENDPOINT_QUARANTINE_TEXT} - - - )} - -
+ + )} - - - {hasVersionConflict && ( + {listType === ExceptionListTypeEnum.RULE_DEFAULT && rule != null && ( <> - -

{i18n.VERSION_CONFLICT_ERROR_DESCRIPTION}

-
- + + )} - {updateError != null && ( + + +

{i18n.COMMENTS_SECTION_TITLE(itemToEdit.comments.length ?? 0)}

+ + } + exceptionItemComments={itemToEdit.comments} + newCommentValue={newComment} + newCommentOnChange={setComment} + /> + {showAlertCloseOptions && ( <> - + - )} - {updateError === null && ( - - - {i18n.CANCEL} - - - - {i18n.EDIT_EXCEPTION_SAVE_BUTTON} - - - )} + + + + + {i18n.CANCEL} + + + + {editExceptionMessage} + +
); -}); +}; + +export const EditExceptionFlyout = React.memo(EditExceptionFlyoutComponent); + +EditExceptionFlyout.displayName = 'EditExceptionFlyout'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/reducer.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/reducer.ts new file mode 100644 index 00000000000000..22fefe760e4aa0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/reducer.ts @@ -0,0 +1,116 @@ +/* + * 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 { ExceptionsBuilderReturnExceptionItem } from '@kbn/securitysolution-list-utils'; + +export interface State { + exceptionItems: ExceptionsBuilderReturnExceptionItem[]; + exceptionItemMeta: { name: string }; + newComment: string; + bulkCloseAlerts: boolean; + disableBulkClose: boolean; + bulkCloseIndex: string[] | undefined; + entryErrorExists: boolean; +} + +export type Action = + | { + type: 'setExceptionItemMeta'; + value: [string, string]; + } + | { + type: 'setComment'; + comment: string; + } + | { + type: 'setBulkCloseAlerts'; + bulkClose: boolean; + } + | { + type: 'setDisableBulkCloseAlerts'; + disableBulkCloseAlerts: boolean; + } + | { + type: 'setBulkCloseIndex'; + bulkCloseIndex: string[] | undefined; + } + | { + type: 'setExceptionItems'; + items: ExceptionsBuilderReturnExceptionItem[]; + } + | { + type: 'setConditionValidationErrorExists'; + errorExists: boolean; + }; + +export const createExceptionItemsReducer = + () => + (state: State, action: Action): State => { + switch (action.type) { + case 'setExceptionItemMeta': { + const { value } = action; + + return { + ...state, + exceptionItemMeta: { + ...state.exceptionItemMeta, + [value[0]]: value[1], + }, + }; + } + case 'setComment': { + const { comment } = action; + + return { + ...state, + newComment: comment, + }; + } + case 'setBulkCloseAlerts': { + const { bulkClose } = action; + + return { + ...state, + bulkCloseAlerts: bulkClose, + }; + } + case 'setDisableBulkCloseAlerts': { + const { disableBulkCloseAlerts } = action; + + return { + ...state, + disableBulkClose: disableBulkCloseAlerts, + }; + } + case 'setBulkCloseIndex': { + const { bulkCloseIndex } = action; + + return { + ...state, + bulkCloseIndex, + }; + } + case 'setExceptionItems': { + const { items } = action; + + return { + ...state, + exceptionItems: items, + }; + } + case 'setConditionValidationErrorExists': { + const { errorExists } = action; + + return { + ...state, + entryErrorExists: errorExists, + }; + } + default: + return state; + } + }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/translations.ts index 6a5fd6f44810ca..9839fa5ddc9de1 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/translations.ts @@ -7,87 +7,50 @@ import { i18n } from '@kbn/i18n'; -export const CANCEL = i18n.translate('xpack.securitySolution.exceptions.editException.cancel', { +export const CANCEL = i18n.translate('xpack.securitySolution.ruleExceptions.editException.cancel', { defaultMessage: 'Cancel', }); -export const EDIT_EXCEPTION_SAVE_BUTTON = i18n.translate( - 'xpack.securitySolution.exceptions.editException.editExceptionSaveButton', - { - defaultMessage: 'Save', - } -); - export const EDIT_EXCEPTION_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.editException.editExceptionTitle', + 'xpack.securitySolution.ruleExceptions.editException.editExceptionTitle', { - defaultMessage: 'Edit Rule Exception', + defaultMessage: 'Edit rule exception', } ); export const EDIT_ENDPOINT_EXCEPTION_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.editException.editEndpointExceptionTitle', - { - defaultMessage: 'Edit Endpoint Exception', - } -); - -export const EDIT_EXCEPTION_SUCCESS = i18n.translate( - 'xpack.securitySolution.exceptions.editException.success', - { - defaultMessage: 'Successfully updated exception', - } -); - -export const BULK_CLOSE_LABEL = i18n.translate( - 'xpack.securitySolution.exceptions.editException.bulkCloseLabel', - { - defaultMessage: 'Close all alerts that match this exception and were generated by this rule', - } -); - -export const BULK_CLOSE_LABEL_DISABLED = i18n.translate( - 'xpack.securitySolution.exceptions.editException.bulkCloseLabel.disabled', + 'xpack.securitySolution.ruleExceptions.editException.editEndpointExceptionTitle', { - defaultMessage: - 'Close all alerts that match this exception and were generated by this rule (Lists and non-ECS fields are not supported)', + defaultMessage: 'Edit endpoint exception', } ); -export const ENDPOINT_QUARANTINE_TEXT = i18n.translate( - 'xpack.securitySolution.exceptions.editException.endpointQuarantineText', +export const EDIT_RULE_EXCEPTION_SUCCESS_TITLE = i18n.translate( + 'xpack.securitySolution.ruleExceptions.editException.editRuleExceptionToastSuccessTitle', { - defaultMessage: - 'On all Endpoint hosts, quarantined files that match the exception are automatically restored to their original locations. This exception applies to all rules using Endpoint exceptions.', + defaultMessage: 'Rule exception updated', } ); -export const EXCEPTION_BUILDER_INFO = i18n.translate( - 'xpack.securitySolution.exceptions.editException.infoLabel', - { - defaultMessage: "Alerts are generated when the rule's conditions are met, except when:", - } -); +export const EDIT_RULE_EXCEPTION_SUCCESS_TEXT = (exceptionItemName: string, numItems: number) => + i18n.translate( + 'xpack.securitySolution.ruleExceptions.editException.editRuleExceptionToastSuccessText', + { + values: { exceptionItemName, numItems }, + defaultMessage: + '{numItems, plural, =1 {Exception} other {Exceptions}} - {exceptionItemName} - {numItems, plural, =1 {has} other {have}} been updated.', + } + ); -export const VERSION_CONFLICT_ERROR_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.editException.versionConflictTitle', +export const EDIT_RULE_EXCEPTION_ERROR_TITLE = i18n.translate( + 'xpack.securitySolution.ruleExceptions.editException.editRuleExceptionToastErrorTitle', { - defaultMessage: 'Sorry, there was an error', + defaultMessage: 'Error updating exception', } ); -export const VERSION_CONFLICT_ERROR_DESCRIPTION = i18n.translate( - 'xpack.securitySolution.exceptions.editException.versionConflictDescription', - { - defaultMessage: - "It appears this exception was updated since you first selected to edit it. Try clicking 'Cancel' and editing the exception again.", - } -); - -export const EDIT_EXCEPTION_SEQUENCE_WARNING = i18n.translate( - 'xpack.securitySolution.exceptions.editException.sequenceWarning', - { - defaultMessage: - "This rule's query contains an EQL sequence statement. The exception modified will apply to all events in the sequence.", - } -); +export const COMMENTS_SECTION_TITLE = (comments: number) => + i18n.translate('xpack.securitySolution.ruleExceptions.editExceptionFlyout.commentsTitle', { + values: { comments }, + defaultMessage: 'Add comments ({comments})', + }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/use_edit_exception.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/use_edit_exception.tsx new file mode 100644 index 00000000000000..e5dd0dc4844dce --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/use_edit_exception.tsx @@ -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 { useEffect, useRef, useState } from 'react'; +import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; + +import * as i18n from './translations'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { useCreateOrUpdateException } from '../../logic/use_create_update_exception'; + +export interface EditExceptionItemHookProps { + itemsToUpdate: ExceptionListItemSchema[]; +} + +export type EditExceptionItemHookFuncProps = (arg: EditExceptionItemHookProps) => Promise; + +export type ReturnUseEditExceptionItems = [boolean, EditExceptionItemHookFuncProps | null]; + +/** + * Hook for editing exception items from flyout + * + */ +export const useEditExceptionItems = (): ReturnUseEditExceptionItems => { + const { addSuccess, addError, addWarning } = useAppToasts(); + const [isAddingExceptions, updateExceptions] = useCreateOrUpdateException(); + + const [isLoading, setIsLoading] = useState(false); + const updateExceptionsRef = useRef(null); + + useEffect(() => { + let isSubscribed = true; + const abortCtrl = new AbortController(); + + const updateExceptionItem = async ({ itemsToUpdate }: EditExceptionItemHookProps) => { + if (updateExceptions == null) return; + + try { + setIsLoading(true); + + await updateExceptions(itemsToUpdate); + + addSuccess({ + title: i18n.EDIT_RULE_EXCEPTION_SUCCESS_TITLE, + text: i18n.EDIT_RULE_EXCEPTION_SUCCESS_TEXT( + itemsToUpdate.map(({ name }) => name).join(', '), + itemsToUpdate.length + ), + }); + + if (isSubscribed) { + setIsLoading(false); + } + } catch (e) { + if (isSubscribed) { + setIsLoading(false); + addError(e, { title: i18n.EDIT_RULE_EXCEPTION_ERROR_TITLE }); + throw new Error(e); + } + } + }; + + updateExceptionsRef.current = updateExceptionItem; + return (): void => { + isSubscribed = false; + abortCtrl.abort(); + }; + }, [addSuccess, addError, addWarning, updateExceptions]); + + return [isLoading || isAddingExceptions, updateExceptionsRef.current]; +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/index.test.tsx index f80372dc112835..1697a5d5b0bc7e 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/index.test.tsx @@ -8,13 +8,13 @@ import React from 'react'; import { mount } from 'enzyme'; -import { ExceptionItemCard } from '.'; import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; import { getCommentsArrayMock } from '@kbn/lists-plugin/common/schemas/types/comment.mock'; -import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; -import { TestProviders } from '../../../../common/mock'; import { getExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_schema.mock'; +import { TestProviders } from '../../../../common/mock'; +import { ExceptionItemCard } from '.'; + jest.mock('../../../../common/lib/kibana'); describe('ExceptionItemCard', () => { @@ -28,8 +28,8 @@ describe('ExceptionItemCard', () => { onDeleteException={jest.fn()} onEditException={jest.fn()} exceptionItem={exceptionItem} - listType={ExceptionListTypeEnum.DETECTION} - ruleReferences={{ + isEndpoint={false} + listAndReferences={{ ...getExceptionListSchemaMock(), referenced_rules: [ { @@ -77,8 +77,8 @@ describe('ExceptionItemCard', () => { onEditException={jest.fn()} exceptionItem={exceptionItem} dataTestSubj="item" - listType={ExceptionListTypeEnum.DETECTION} - ruleReferences={{ + isEndpoint={false} + listAndReferences={{ ...getExceptionListSchemaMock(), referenced_rules: [ { @@ -125,8 +125,8 @@ describe('ExceptionItemCard', () => { onEditException={jest.fn()} exceptionItem={exceptionItem} dataTestSubj="item" - listType={ExceptionListTypeEnum.DETECTION} - ruleReferences={{ + isEndpoint={false} + listAndReferences={{ ...getExceptionListSchemaMock(), referenced_rules: [ { @@ -169,8 +169,8 @@ describe('ExceptionItemCard', () => { onEditException={mockOnEditException} exceptionItem={exceptionItem} dataTestSubj="item" - listType={ExceptionListTypeEnum.DETECTION} - ruleReferences={{ + isEndpoint={false} + listAndReferences={{ ...getExceptionListSchemaMock(), referenced_rules: [ { @@ -203,6 +203,69 @@ describe('ExceptionItemCard', () => { .find('button[data-test-subj="exceptionItemCardHeader-actionButton"]') .at(0) .simulate('click'); + + expect( + wrapper.find('button[data-test-subj="exceptionItemCardHeader-actionItem-edit"]').text() + ).toEqual('Edit rule exception'); + + wrapper + .find('button[data-test-subj="exceptionItemCardHeader-actionItem-edit"]') + .simulate('click'); + + expect(mockOnEditException).toHaveBeenCalledWith(getExceptionListItemSchemaMock()); + }); + + it('it invokes "onEditException" when edit button clicked when "isEndpoint" is "true"', () => { + const mockOnEditException = jest.fn(); + const exceptionItem = getExceptionListItemSchemaMock(); + + const wrapper = mount( + + + + ); + + // click on popover + wrapper + .find('button[data-test-subj="exceptionItemCardHeader-actionButton"]') + .at(0) + .simulate('click'); + + expect( + wrapper.find('button[data-test-subj="exceptionItemCardHeader-actionItem-edit"]').text() + ).toEqual('Edit endpoint exception'); + wrapper .find('button[data-test-subj="exceptionItemCardHeader-actionItem-edit"]') .simulate('click'); @@ -222,8 +285,8 @@ describe('ExceptionItemCard', () => { onEditException={jest.fn()} exceptionItem={exceptionItem} dataTestSubj="item" - listType={ExceptionListTypeEnum.DETECTION} - ruleReferences={{ + isEndpoint={false} + listAndReferences={{ ...getExceptionListSchemaMock(), referenced_rules: [ { @@ -256,6 +319,73 @@ describe('ExceptionItemCard', () => { .find('button[data-test-subj="exceptionItemCardHeader-actionButton"]') .at(0) .simulate('click'); + + expect( + wrapper.find('button[data-test-subj="exceptionItemCardHeader-actionItem-delete"]').text() + ).toEqual('Delete rule exception'); + + wrapper + .find('button[data-test-subj="exceptionItemCardHeader-actionItem-delete"]') + .simulate('click'); + + expect(mockOnDeleteException).toHaveBeenCalledWith({ + id: '1', + name: 'some name', + namespaceType: 'single', + }); + }); + + it('it invokes "onDeleteException" when delete button clicked when "isEndpoint" is "true"', () => { + const mockOnDeleteException = jest.fn(); + const exceptionItem = getExceptionListItemSchemaMock(); + + const wrapper = mount( + + + + ); + + // click on popover + wrapper + .find('button[data-test-subj="exceptionItemCardHeader-actionButton"]') + .at(0) + .simulate('click'); + + expect( + wrapper.find('button[data-test-subj="exceptionItemCardHeader-actionItem-delete"]').text() + ).toEqual('Delete endpoint exception'); + wrapper .find('button[data-test-subj="exceptionItemCardHeader-actionItem-delete"]') .simulate('click'); @@ -278,8 +408,8 @@ describe('ExceptionItemCard', () => { onEditException={jest.fn()} exceptionItem={exceptionItem} dataTestSubj="item" - listType={ExceptionListTypeEnum.DETECTION} - ruleReferences={{ + isEndpoint={false} + listAndReferences={{ ...getExceptionListSchemaMock(), referenced_rules: [ { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/index.tsx index 4bfa09e96486ed..233e11b0709eba 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/index.tsx @@ -9,7 +9,6 @@ import type { EuiCommentProps } from '@elastic/eui'; import { EuiPanel, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React, { useMemo, useCallback } from 'react'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import { getFormattedComments } from '../../utils/helpers'; import type { ExceptionListItemIdentifiers } from '../../utils/types'; @@ -22,9 +21,9 @@ import { ExceptionItemCardComments } from './comments'; export interface ExceptionItemProps { exceptionItem: ExceptionListItemSchema; - listType: ExceptionListTypeEnum; + isEndpoint: boolean; disableActions: boolean; - ruleReferences: ExceptionListRuleReferencesSchema | null; + listAndReferences: ExceptionListRuleReferencesSchema | null; onDeleteException: (arg: ExceptionListItemIdentifiers) => void; onEditException: (item: ExceptionListItemSchema) => void; dataTestSubj: string; @@ -33,8 +32,8 @@ export interface ExceptionItemProps { const ExceptionItemCardComponent = ({ disableActions, exceptionItem, - listType, - ruleReferences, + isEndpoint, + listAndReferences, onDeleteException, onEditException, dataTestSubj, @@ -65,19 +64,17 @@ const ExceptionItemCardComponent = ({ { key: 'edit', icon: 'controlsHorizontal', - label: - listType === ExceptionListTypeEnum.ENDPOINT - ? i18n.ENDPOINT_EXCEPTION_ITEM_EDIT_BUTTON - : i18n.EXCEPTION_ITEM_EDIT_BUTTON, + label: isEndpoint + ? i18n.ENDPOINT_EXCEPTION_ITEM_EDIT_BUTTON + : i18n.EXCEPTION_ITEM_EDIT_BUTTON, onClick: handleEdit, }, { key: 'delete', icon: 'trash', - label: - listType === ExceptionListTypeEnum.ENDPOINT - ? i18n.ENDPOINT_EXCEPTION_ITEM_DELETE_BUTTON - : i18n.EXCEPTION_ITEM_DELETE_BUTTON, + label: isEndpoint + ? i18n.ENDPOINT_EXCEPTION_ITEM_DELETE_BUTTON + : i18n.EXCEPTION_ITEM_DELETE_BUTTON, onClick: handleDelete, }, ]} @@ -88,7 +85,7 @@ const ExceptionItemCardComponent = ({ diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/meta.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/meta.test.tsx index 8892117f1616e9..0a355a4567a8d2 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/meta.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/meta.test.tsx @@ -6,193 +6,233 @@ */ import React from 'react'; +import type { ReactWrapper } from 'enzyme'; import { mount } from 'enzyme'; import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; import { getExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_schema.mock'; +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import { ExceptionItemCardMetaInfo } from './meta'; import { TestProviders } from '../../../../common/mock'; describe('ExceptionItemCardMetaInfo', () => { - it('it renders item creation info', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper.find('[data-test-subj="exceptionItemMeta-createdBy-value1"]').at(0).text() - ).toEqual('Apr 20, 2020 @ 15:25:31.830'); - expect( - wrapper.find('[data-test-subj="exceptionItemMeta-createdBy-value2"]').at(0).text() - ).toEqual('some user'); - }); + describe('general functionality', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount( + + + + ); + }); + + it('it renders item creation info', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-createdBy-value1"]').at(0).text() + ).toEqual('Apr 20, 2020 @ 15:25:31.830'); + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-createdBy-value2"]').at(0).text() + ).toEqual('some user'); + }); + + it('it renders item update info', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-updatedBy-value1"]').at(0).text() + ).toEqual('Apr 20, 2020 @ 15:25:31.830'); + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-updatedBy-value2"]').at(0).text() + ).toEqual('some user'); + }); + + it('it renders references info', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-affectedRulesButton"]').at(0).text() + ).toEqual('Affects 1 rule'); + }); + + it('it renders affected shared list info', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-affectedListsButton"]').at(0).text() + ).toEqual('Affects shared list'); + }); + + it('it renders references info when multiple references exist', () => { + wrapper = mount( + + + + ); - it('it renders item update info', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper.find('[data-test-subj="exceptionItemMeta-updatedBy-value1"]').at(0).text() - ).toEqual('Apr 20, 2020 @ 15:25:31.830'); - expect( - wrapper.find('[data-test-subj="exceptionItemMeta-updatedBy-value2"]').at(0).text() - ).toEqual('some user'); + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-affectedRulesButton"]').at(0).text() + ).toEqual('Affects 2 rules'); + }); }); - it('it renders references info', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper.find('[data-test-subj="exceptionItemMeta-affectedRulesButton"]').at(0).text() - ).toEqual('Affects 1 rule'); + describe('exception item for "rule_default" list', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount( + + + + ); + }); + + it('it renders references info', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-affectedRulesButton"]').at(0).text() + ).toEqual('Affects 1 rule'); + }); + + it('it does NOT render affected shared list info', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-affectedListsButton"]').exists() + ).toBeFalsy(); + }); }); - it('it renders references info when multiple references exist', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper.find('[data-test-subj="exceptionItemMeta-affectedRulesButton"]').at(0).text() - ).toEqual('Affects 2 rules'); + describe('exception item for "endpoint" list', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount( + + + + ); + }); + + it('it renders references info', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-affectedRulesButton"]').at(0).text() + ).toEqual('Affects 1 rule'); + }); + + it('it renders affected shared list info', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-affectedListsButton"]').at(0).text() + ).toEqual('Affects shared list'); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/meta.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/meta.tsx index 453e1542bfce87..8005264636bfa3 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/meta.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/meta.tsx @@ -19,6 +19,7 @@ import { EuiPopover, } from '@elastic/eui'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import styled from 'styled-components'; import * as i18n from './translations'; @@ -36,24 +37,27 @@ const StyledFlexItem = styled(EuiFlexItem)` export interface ExceptionItemCardMetaInfoProps { item: ExceptionListItemSchema; - references: ExceptionListRuleReferencesSchema | null; + listAndReferences: ExceptionListRuleReferencesSchema | null; dataTestSubj: string; } export const ExceptionItemCardMetaInfo = memo( - ({ item, references, dataTestSubj }) => { - const [isPopoverOpen, setIsPopoverOpen] = useState(false); + ({ item, listAndReferences, dataTestSubj }) => { + const [isListsPopoverOpen, setIsListsPopoverOpen] = useState(false); + const [isRulesPopoverOpen, setIsRulesPopoverOpen] = useState(false); - const onAffectedRulesClick = () => setIsPopoverOpen((isOpen) => !isOpen); - const onClosePopover = () => setIsPopoverOpen(false); + const onAffectedRulesClick = () => setIsRulesPopoverOpen((isOpen) => !isOpen); + const onAffectedListsClick = () => setIsListsPopoverOpen((isOpen) => !isOpen); + const onCloseRulesPopover = () => setIsRulesPopoverOpen(false); + const onClosListsPopover = () => setIsListsPopoverOpen(false); const itemActions = useMemo((): EuiContextMenuPanelProps['items'] => { - if (references == null) { + if (listAndReferences == null) { return []; } - return references.referenced_rules.map((reference) => ( + return listAndReferences.referenced_rules.map((reference) => ( @@ -61,13 +65,92 @@ export const ExceptionItemCardMetaInfo = memo( data-test-subj="ruleName" deepLinkId={SecurityPageName.rules} path={getRuleDetailsTabUrl(reference.id, RuleDetailTabs.alerts)} + external > {reference.name} )); - }, [references, dataTestSubj]); + }, [listAndReferences, dataTestSubj]); + + const rulesAffected = useMemo((): JSX.Element => { + if (listAndReferences == null) return <>; + + return ( + + + {i18n.AFFECTED_RULES(listAndReferences?.referenced_rules.length ?? 0)} + + } + panelPaddingSize="none" + isOpen={isRulesPopoverOpen} + closePopover={onCloseRulesPopover} + data-test-subj={`${dataTestSubj}-rulesPopover`} + id={'rulesPopover'} + > + + + + ); + }, [listAndReferences, dataTestSubj, isRulesPopoverOpen, itemActions]); + + const listsAffected = useMemo((): JSX.Element => { + if (listAndReferences == null) return <>; + + if (listAndReferences.type !== ExceptionListTypeEnum.RULE_DEFAULT) { + return ( + + + {i18n.AFFECTED_LIST} + + } + panelPaddingSize="none" + isOpen={isListsPopoverOpen} + closePopover={onClosListsPopover} + data-test-subj={`${dataTestSubj}-listsPopover`} + id={'listsPopover'} + > + + + + {listAndReferences.name} + + + , + ]} + /> + + + ); + } else { + return <>; + } + }, [listAndReferences, dataTestSubj, isListsPopoverOpen]); return ( ( dataTestSubj={`${dataTestSubj}-updatedBy`} /> - {references != null && ( - - - {i18n.AFFECTED_RULES(references?.referenced_rules.length ?? 0)} - - } - panelPaddingSize="none" - isOpen={isPopoverOpen} - closePopover={onClosePopover} - data-test-subj={`${dataTestSubj}-items`} - > - - - + {listAndReferences != null && ( + <> + {rulesAffected} + {listsAffected} + )} ); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/translations.ts index ccdd5eebf3b8cd..75d0b098c297e1 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/translations.ts @@ -8,174 +8,181 @@ import { i18n } from '@kbn/i18n'; export const EXCEPTION_ITEM_EDIT_BUTTON = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.editItemButton', + 'xpack.securitySolution.ruleExceptions.exceptionItem.editItemButton', { defaultMessage: 'Edit rule exception', } ); export const EXCEPTION_ITEM_DELETE_BUTTON = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.deleteItemButton', + 'xpack.securitySolution.ruleExceptions.exceptionItem.deleteItemButton', { defaultMessage: 'Delete rule exception', } ); export const ENDPOINT_EXCEPTION_ITEM_EDIT_BUTTON = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.endpoint.editItemButton', + 'xpack.securitySolution.ruleExceptions.exceptionItem.endpoint.editItemButton', { defaultMessage: 'Edit endpoint exception', } ); export const ENDPOINT_EXCEPTION_ITEM_DELETE_BUTTON = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.endpoint.deleteItemButton', + 'xpack.securitySolution.ruleExceptions.exceptionItem.endpoint.deleteItemButton', { defaultMessage: 'Delete endpoint exception', } ); export const EXCEPTION_ITEM_CREATED_LABEL = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.createdLabel', + 'xpack.securitySolution.ruleExceptions.exceptionItem.createdLabel', { defaultMessage: 'Created', } ); export const EXCEPTION_ITEM_UPDATED_LABEL = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.updatedLabel', + 'xpack.securitySolution.ruleExceptions.exceptionItem.updatedLabel', { defaultMessage: 'Updated', } ); export const EXCEPTION_ITEM_META_BY = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.metaDetailsBy', + 'xpack.securitySolution.ruleExceptions.exceptionItem.metaDetailsBy', { defaultMessage: 'by', } ); export const exceptionItemCommentsAccordion = (comments: number) => - i18n.translate('xpack.securitySolution.exceptions.exceptionItem.showCommentsLabel', { + i18n.translate('xpack.securitySolution.ruleExceptions.exceptionItem.showCommentsLabel', { values: { comments }, defaultMessage: 'Show {comments, plural, =1 {comment} other {comments}} ({comments})', }); export const CONDITION_OPERATOR_TYPE_MATCH = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.matchOperator', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.matchOperator', { defaultMessage: 'IS', } ); export const CONDITION_OPERATOR_TYPE_NOT_MATCH = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.matchOperator.not', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.matchOperator.not', { defaultMessage: 'IS NOT', } ); export const CONDITION_OPERATOR_TYPE_WILDCARD_MATCHES = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.wildcardMatchesOperator', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.wildcardMatchesOperator', { defaultMessage: 'MATCHES', } ); export const CONDITION_OPERATOR_TYPE_WILDCARD_DOES_NOT_MATCH = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.wildcardDoesNotMatchOperator', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.wildcardDoesNotMatchOperator', { defaultMessage: 'DOES NOT MATCH', } ); export const CONDITION_OPERATOR_TYPE_NESTED = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.nestedOperator', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.nestedOperator', { defaultMessage: 'has', } ); export const CONDITION_OPERATOR_TYPE_MATCH_ANY = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.matchAnyOperator', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.matchAnyOperator', { defaultMessage: 'is one of', } ); export const CONDITION_OPERATOR_TYPE_NOT_MATCH_ANY = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.matchAnyOperator.not', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.matchAnyOperator.not', { defaultMessage: 'is not one of', } ); export const CONDITION_OPERATOR_TYPE_EXISTS = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.existsOperator', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.existsOperator', { defaultMessage: 'exists', } ); export const CONDITION_OPERATOR_TYPE_DOES_NOT_EXIST = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.existsOperator.not', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.existsOperator.not', { defaultMessage: 'does not exist', } ); export const CONDITION_OPERATOR_TYPE_LIST = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.listOperator', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.listOperator', { defaultMessage: 'included in', } ); export const CONDITION_OPERATOR_TYPE_NOT_IN_LIST = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.listOperator.not', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.listOperator.not', { defaultMessage: 'is not included in', } ); export const CONDITION_AND = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.and', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.and', { defaultMessage: 'AND', } ); export const CONDITION_OS = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.os', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.os', { defaultMessage: 'OS', } ); export const OS_WINDOWS = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.windows', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.windows', { defaultMessage: 'Windows', } ); export const OS_LINUX = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.linux', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.linux', { defaultMessage: 'Linux', } ); export const OS_MAC = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.macos', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.macos', { defaultMessage: 'Mac', } ); export const AFFECTED_RULES = (numRules: number) => - i18n.translate('xpack.securitySolution.exceptions.exceptionItem.affectedRules', { + i18n.translate('xpack.securitySolution.ruleExceptions.exceptionItem.affectedRules', { values: { numRules }, defaultMessage: 'Affects {numRules} {numRules, plural, =1 {rule} other {rules}}', }); + +export const AFFECTED_LIST = i18n.translate( + 'xpack.securitySolution.ruleExceptions.exceptionItem.affectedList', + { + defaultMessage: 'Affects shared list', + } +); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/item_conditions/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/item_conditions/index.tsx index 46f16ec4688735..4869c80c172a29 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/item_conditions/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/item_conditions/index.tsx @@ -266,6 +266,7 @@ const ExceptionsConditionsComponent: React.FC ); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.test.tsx index 214d0041fb9a23..47933db0b35223 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.test.tsx @@ -110,7 +110,6 @@ describe('ExceptionItemComments', () => { ); - expect(wrapper.find('[data-test-subj="exceptionItemCommentsAccordion"]').exists()).toBeFalsy(); expect( wrapper.find('[data-test-subj="newExceptionItemCommentTextArea"]').at(1).props().value ).toEqual('This is a new comment'); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.tsx index fa514850e30b45..a3553c78f8b303 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.tsx @@ -82,10 +82,6 @@ export const ExceptionItemComments = memo(function ExceptionItemComments({ setShouldShowComments(isOpen); }, []); - const exceptionItemsExist: boolean = useMemo(() => { - return exceptionItemComments != null && exceptionItemComments.length > 0; - }, [exceptionItemComments]); - const commentsAccordionTitle = useMemo(() => { if (exceptionItemComments && exceptionItemComments.length > 0) { return ( @@ -110,32 +106,30 @@ export const ExceptionItemComments = memo(function ExceptionItemComments({ return (
- {exceptionItemsExist && ( - handleTriggerOnClick(isOpen)} - > - - - )} - - - - - - - - + handleTriggerOnClick(isOpen)} + > + + + + + + + + + +
); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/translations.ts new file mode 100644 index 00000000000000..8c1ceb9f639fb3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/translations.ts @@ -0,0 +1,22 @@ +/* + * 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 CLOSE_ALERTS_SUCCESS = (numAlerts: number) => + i18n.translate('xpack.securitySolution.ruleExceptions.logic.closeAlerts.success', { + values: { numAlerts }, + defaultMessage: + 'Successfully updated {numAlerts} {numAlerts, plural, =1 {alert} other {alerts}}', + }); + +export const CLOSE_ALERTS_ERROR = i18n.translate( + 'xpack.securitySolution.ruleExceptions.logic.closeAlerts.error', + { + defaultMessage: 'Failed to close alerts', + } +); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_exception.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_exception.test.tsx deleted file mode 100644 index e882e05f6e085d..00000000000000 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_exception.test.tsx +++ /dev/null @@ -1,423 +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 type { RenderHookResult } from '@testing-library/react-hooks'; -import { act, renderHook } from '@testing-library/react-hooks'; -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { coreMock } from '@kbn/core/public/mocks'; -import { KibanaServices } from '../../../common/lib/kibana'; - -import * as alertsApi from '../../../detections/containers/detection_engine/alerts/api'; -import * as listsApi from '@kbn/securitysolution-list-api'; -import * as getQueryFilterHelper from '../../../detections/containers/detection_engine/exceptions/get_es_query_filter'; -import * as buildFilterHelpers from '../../../detections/components/alerts_table/default_config'; -import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; -import { getCreateExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/request/create_exception_list_item_schema.mock'; -import { getUpdateExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/request/update_exception_list_item_schema.mock'; -import type { - ExceptionListItemSchema, - CreateExceptionListItemSchema, - UpdateExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; -import { TestProviders } from '../../../common/mock'; -import type { - UseAddOrUpdateExceptionProps, - ReturnUseAddOrUpdateException, - AddOrUpdateExceptionItemsFunc, -} from './use_add_exception'; -import { useAddOrUpdateException } from './use_add_exception'; - -const mockKibanaHttpService = coreMock.createStart().http; -const mockKibanaServices = KibanaServices.get as jest.Mock; -jest.mock('../../../common/lib/kibana'); -jest.mock('@kbn/securitysolution-list-api'); - -const fetchMock = jest.fn(); -mockKibanaServices.mockReturnValue({ http: { fetch: fetchMock } }); - -describe('useAddOrUpdateException', () => { - let updateAlertStatus: jest.SpyInstance>; - let addExceptionListItem: jest.SpyInstance>; - let updateExceptionListItem: jest.SpyInstance>; - let getQueryFilter: jest.SpyInstance>; - let buildAlertStatusesFilter: jest.SpyInstance< - ReturnType - >; - let buildAlertsFilter: jest.SpyInstance>; - let addOrUpdateItemsArgs: Parameters; - let render: () => RenderHookResult; - const onError = jest.fn(); - const onSuccess = jest.fn(); - const ruleStaticId = 'rule-id'; - const alertIdToClose = 'idToClose'; - const bulkCloseIndex = ['.custom']; - const itemsToAdd: CreateExceptionListItemSchema[] = [ - { - ...getCreateExceptionListItemSchemaMock(), - name: 'item to add 1', - }, - { - ...getCreateExceptionListItemSchemaMock(), - name: 'item to add 2', - }, - ]; - const itemsToUpdate: ExceptionListItemSchema[] = [ - { - ...getExceptionListItemSchemaMock(), - name: 'item to update 1', - }, - { - ...getExceptionListItemSchemaMock(), - name: 'item to update 2', - }, - ]; - const itemsToUpdateFormatted: UpdateExceptionListItemSchema[] = itemsToUpdate.map( - (item: ExceptionListItemSchema) => { - const formatted: UpdateExceptionListItemSchema = getUpdateExceptionListItemSchemaMock(); - const newObj = (Object.keys(formatted) as Array).reduce( - (acc, key) => { - return { - ...acc, - [key]: item[key], - }; - }, - {} as UpdateExceptionListItemSchema - ); - return newObj; - } - ); - - const itemsToAddOrUpdate = [...itemsToAdd, ...itemsToUpdate]; - - const waitForAddOrUpdateFunc: (arg: { - waitForNextUpdate: RenderHookResult< - UseAddOrUpdateExceptionProps, - ReturnUseAddOrUpdateException - >['waitForNextUpdate']; - rerender: RenderHookResult< - UseAddOrUpdateExceptionProps, - ReturnUseAddOrUpdateException - >['rerender']; - result: RenderHookResult['result']; - }) => Promise = async ({ - waitForNextUpdate, - rerender, - result, - }) => { - await waitForNextUpdate(); - rerender(); - expect(result.current[1]).not.toBeNull(); - return Promise.resolve(result.current[1]); - }; - - beforeEach(() => { - updateAlertStatus = jest.spyOn(alertsApi, 'updateAlertStatus'); - - addExceptionListItem = jest - .spyOn(listsApi, 'addExceptionListItem') - .mockResolvedValue(getExceptionListItemSchemaMock()); - - updateExceptionListItem = jest - .spyOn(listsApi, 'updateExceptionListItem') - .mockResolvedValue(getExceptionListItemSchemaMock()); - - getQueryFilter = jest - .spyOn(getQueryFilterHelper, 'getEsQueryFilter') - .mockResolvedValue({ bool: { must_not: [], must: [], filter: [], should: [] } }); - - buildAlertStatusesFilter = jest.spyOn(buildFilterHelpers, 'buildAlertStatusesFilter'); - - buildAlertsFilter = jest.spyOn(buildFilterHelpers, 'buildAlertsFilter'); - - addOrUpdateItemsArgs = [ruleStaticId, itemsToAddOrUpdate]; - render = () => - renderHook( - () => - useAddOrUpdateException({ - http: mockKibanaHttpService, - onError, - onSuccess, - }), - { - wrapper: TestProviders, - } - ); - }); - - afterEach(() => { - jest.clearAllMocks(); - jest.restoreAllMocks(); - }); - - it('initializes hook', async () => { - await act(async () => { - const { result, waitForNextUpdate } = render(); - await waitForNextUpdate(); - expect(result.current).toEqual([{ isLoading: false }, result.current[1]]); - }); - }); - - it('invokes "onError" if call to add exception item fails', async () => { - const mockError = new Error('error adding item'); - - addExceptionListItem = jest - .spyOn(listsApi, 'addExceptionListItem') - .mockRejectedValue(mockError); - - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(onError).toHaveBeenCalledWith(mockError, null, null); - }); - }); - - it('invokes "onError" if call to update exception item fails', async () => { - const mockError = new Error('error updating item'); - - updateExceptionListItem = jest - .spyOn(listsApi, 'updateExceptionListItem') - .mockRejectedValue(mockError); - - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(onError).toHaveBeenCalledWith(mockError, null, null); - }); - }); - - describe('when alertIdToClose is not passed in', () => { - it('should not update the alert status', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(updateAlertStatus).not.toHaveBeenCalled(); - }); - }); - - it('creates new items', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(addExceptionListItem).toHaveBeenCalledTimes(2); - expect(addExceptionListItem.mock.calls[1][0].listItem).toEqual(itemsToAdd[1]); - }); - }); - it('updates existing items', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(updateExceptionListItem).toHaveBeenCalledTimes(2); - expect(updateExceptionListItem.mock.calls[1][0].listItem).toEqual( - itemsToUpdateFormatted[1] - ); - }); - }); - }); - - describe('when alertIdToClose is passed in', () => { - beforeEach(() => { - addOrUpdateItemsArgs = [ruleStaticId, itemsToAddOrUpdate, alertIdToClose]; - }); - it('should update the alert status', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(updateAlertStatus).toHaveBeenCalledTimes(1); - }); - }); - it('creates new items', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(addExceptionListItem).toHaveBeenCalledTimes(2); - expect(addExceptionListItem.mock.calls[1][0].listItem).toEqual(itemsToAdd[1]); - }); - }); - it('updates existing items', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(updateExceptionListItem).toHaveBeenCalledTimes(2); - expect(updateExceptionListItem.mock.calls[1][0].listItem).toEqual( - itemsToUpdateFormatted[1] - ); - }); - }); - }); - - describe('when bulkCloseIndex is passed in', () => { - beforeEach(() => { - addOrUpdateItemsArgs = [ruleStaticId, itemsToAddOrUpdate, undefined, bulkCloseIndex]; - }); - it('should update the status of only alerts that are open', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(buildAlertStatusesFilter).toHaveBeenCalledTimes(1); - expect(buildAlertStatusesFilter.mock.calls[0][0]).toEqual([ - 'open', - 'acknowledged', - 'in-progress', - ]); - }); - }); - it('should update the status of only alerts generated by the provided rule', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(buildAlertsFilter).toHaveBeenCalledTimes(1); - expect(buildAlertsFilter.mock.calls[0][0]).toEqual(ruleStaticId); - }); - }); - it('should generate the query filter using exceptions', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(getQueryFilter).toHaveBeenCalledTimes(1); - expect(getQueryFilter.mock.calls[0][4]).toEqual(itemsToAddOrUpdate); - expect(getQueryFilter.mock.calls[0][5]).toEqual(false); - }); - }); - it('should update the alert status', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(updateAlertStatus).toHaveBeenCalledTimes(1); - }); - }); - it('creates new items', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(addExceptionListItem).toHaveBeenCalledTimes(2); - expect(addExceptionListItem.mock.calls[1][0].listItem).toEqual(itemsToAdd[1]); - }); - }); - it('updates existing items', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(updateExceptionListItem).toHaveBeenCalledTimes(2); - expect(updateExceptionListItem.mock.calls[1][0].listItem).toEqual( - itemsToUpdateFormatted[1] - ); - }); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_exception.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_exception.tsx deleted file mode 100644 index a6149f366dfafa..00000000000000 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_exception.tsx +++ /dev/null @@ -1,192 +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 { useEffect, useRef, useState, useCallback } from 'react'; -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import type { - ExceptionListItemSchema, - CreateExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; -import { useApi, removeIdFromExceptionItemsEntries } from '@kbn/securitysolution-list-hooks'; -import type { HttpStart } from '@kbn/core/public'; - -import { updateAlertStatus } from '../../../detections/containers/detection_engine/alerts/api'; -import { getUpdateAlertsQuery } from '../../../detections/components/alerts_table/actions'; -import { - buildAlertsFilter, - buildAlertStatusesFilter, -} from '../../../detections/components/alerts_table/default_config'; -import { getEsQueryFilter } from '../../../detections/containers/detection_engine/exceptions/get_es_query_filter'; -import type { Index } from '../../../../common/detection_engine/schemas/common/schemas'; -import { formatExceptionItemForUpdate, prepareExceptionItemsForBulkClose } from '../utils/helpers'; -import { useKibana } from '../../../common/lib/kibana'; - -/** - * Adds exception items to the list. Also optionally closes alerts. - * - * @param ruleStaticId static id of the rule (rule.ruleId, not rule.id) where the exception updates will be applied - * @param exceptionItemsToAddOrUpdate array of ExceptionListItemSchema to add or update - * @param alertIdToClose - optional string representing alert to close - * @param bulkCloseIndex - optional index used to create bulk close query - * - */ -export type AddOrUpdateExceptionItemsFunc = ( - ruleStaticId: string, - exceptionItemsToAddOrUpdate: Array, - alertIdToClose?: string, - bulkCloseIndex?: Index -) => Promise; - -export type ReturnUseAddOrUpdateException = [ - { isLoading: boolean }, - AddOrUpdateExceptionItemsFunc | null -]; - -export interface UseAddOrUpdateExceptionProps { - http: HttpStart; - onError: (arg: Error, code: number | null, message: string | null) => void; - onSuccess: (updated: number, conficts: number) => void; -} - -/** - * Hook for adding and updating an exception item - * - * @param http Kibana http service - * @param onError error callback - * @param onSuccess callback when all lists fetched successfully - * - */ -export const useAddOrUpdateException = ({ - http, - onError, - onSuccess, -}: UseAddOrUpdateExceptionProps): ReturnUseAddOrUpdateException => { - const { services } = useKibana(); - const [isLoading, setIsLoading] = useState(false); - const addOrUpdateExceptionRef = useRef(null); - const { addExceptionListItem, updateExceptionListItem } = useApi(services.http); - const addOrUpdateException = useCallback( - async (ruleStaticId, exceptionItemsToAddOrUpdate, alertIdToClose, bulkCloseIndex) => { - if (addOrUpdateExceptionRef.current != null) { - addOrUpdateExceptionRef.current( - ruleStaticId, - exceptionItemsToAddOrUpdate, - alertIdToClose, - bulkCloseIndex - ); - } - }, - [] - ); - - useEffect(() => { - let isSubscribed = true; - const abortCtrl = new AbortController(); - - const onUpdateExceptionItemsAndAlertStatus: AddOrUpdateExceptionItemsFunc = async ( - ruleStaticId, - exceptionItemsToAddOrUpdate, - alertIdToClose, - bulkCloseIndex - ) => { - const addOrUpdateItems = async ( - exceptionListItems: Array - ): Promise => { - await Promise.all( - exceptionListItems.map( - (item: ExceptionListItemSchema | CreateExceptionListItemSchema) => { - if ('id' in item && item.id != null) { - const formattedExceptionItem = formatExceptionItemForUpdate(item); - return updateExceptionListItem({ - listItem: formattedExceptionItem, - }); - } else { - return addExceptionListItem({ - listItem: item, - }); - } - } - ) - ); - }; - - try { - setIsLoading(true); - let alertIdResponse: estypes.UpdateByQueryResponse | undefined; - let bulkResponse: estypes.UpdateByQueryResponse | undefined; - if (alertIdToClose != null) { - alertIdResponse = await updateAlertStatus({ - query: getUpdateAlertsQuery([alertIdToClose]), - status: 'closed', - signal: abortCtrl.signal, - }); - } - - if (bulkCloseIndex != null) { - const alertStatusFilter = buildAlertStatusesFilter([ - 'open', - 'acknowledged', - 'in-progress', - ]); - - const exceptionsToFilter = exceptionItemsToAddOrUpdate.map((exception) => - removeIdFromExceptionItemsEntries(exception) - ); - - const filter = await getEsQueryFilter( - '', - 'kuery', - [...buildAlertsFilter(ruleStaticId), ...alertStatusFilter], - bulkCloseIndex, - prepareExceptionItemsForBulkClose(exceptionsToFilter), - false - ); - - bulkResponse = await updateAlertStatus({ - query: { - query: filter, - }, - status: 'closed', - signal: abortCtrl.signal, - }); - } - - await addOrUpdateItems(exceptionItemsToAddOrUpdate); - - // NOTE: there could be some overlap here... it's possible that the first response had conflicts - // but that the alert was closed in the second call. In this case, a conflict will be reported even - // though it was already resolved. I'm not sure that there's an easy way to solve this, but it should - // have minimal impact on the user... they'd see a warning that indicates a possible conflict, but the - // state of the alerts and their representation in the UI would be consistent. - const updated = (alertIdResponse?.updated ?? 0) + (bulkResponse?.updated ?? 0); - const conflicts = - alertIdResponse?.version_conflicts ?? 0 + (bulkResponse?.version_conflicts ?? 0); - if (isSubscribed) { - setIsLoading(false); - onSuccess(updated, conflicts); - } - } catch (error) { - if (isSubscribed) { - setIsLoading(false); - if (error.body != null) { - onError(error, error.body.status_code, error.body.message); - } else { - onError(error, null, null); - } - } - } - }; - - addOrUpdateExceptionRef.current = onUpdateExceptionItemsAndAlertStatus; - return (): void => { - isSubscribed = false; - abortCtrl.abort(); - }; - }, [addExceptionListItem, http, onSuccess, onError, updateExceptionListItem]); - - return [{ isLoading }, addOrUpdateException]; -}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_rule_exception.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_rule_exception.tsx new file mode 100644 index 00000000000000..7cf4ff2d8417d2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_rule_exception.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 type { + CreateRuleExceptionListItemSchema, + ExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; +import { useEffect, useRef, useState } from 'react'; + +import { addRuleExceptions } from '../../../detections/containers/detection_engine/rules/api'; +import type { Rule } from '../../../detections/containers/detection_engine/rules/types'; + +/** + * Adds exception items to rules default exception list + * + * @param exceptions exception items to be added + * @param ruleId `id` of rule to add exceptions to + * + */ +export type AddRuleExceptionsFunc = ( + exceptions: CreateRuleExceptionListItemSchema[], + rules: Rule[] +) => Promise; + +export type ReturnUseAddRuleException = [boolean, AddRuleExceptionsFunc | null]; + +/** + * Hook for adding exceptions to a rule default exception list + * + */ +export const useAddRuleDefaultException = (): ReturnUseAddRuleException => { + const [isLoading, setIsLoading] = useState(false); + const addRuleExceptionFunc = useRef(null); + + useEffect(() => { + const abortCtrl = new AbortController(); + + const addExceptionItemsToRule: AddRuleExceptionsFunc = async ( + exceptions: CreateRuleExceptionListItemSchema[], + rules: Rule[] + ): Promise => { + setIsLoading(true); + + // TODO: Update once bulk route is added + const result = await Promise.all( + rules.map(async (rule) => + addRuleExceptions({ + items: exceptions, + ruleId: rule.id, + signal: abortCtrl.signal, + }) + ) + ); + + setIsLoading(false); + + return result.flatMap((r) => r); + }; + addRuleExceptionFunc.current = addExceptionItemsToRule; + + return (): void => { + setIsLoading(false); + abortCtrl.abort(); + }; + }, []); + + return [isLoading, addRuleExceptionFunc.current]; +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_close_alerts.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_close_alerts.tsx new file mode 100644 index 00000000000000..5dc960cb96e2ca --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_close_alerts.tsx @@ -0,0 +1,133 @@ +/* + * 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 { useEffect, useRef, useState } from 'react'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; + +import { updateAlertStatus } from '../../../detections/containers/detection_engine/alerts/api'; +import { getUpdateAlertsQuery } from '../../../detections/components/alerts_table/actions'; +import { + buildAlertStatusesFilter, + buildAlertsFilter, +} from '../../../detections/components/alerts_table/default_config'; +import { getEsQueryFilter } from '../../../detections/containers/detection_engine/exceptions/get_es_query_filter'; +import type { Index } from '../../../../common/detection_engine/schemas/common/schemas'; +import { prepareExceptionItemsForBulkClose } from '../utils/helpers'; +import * as i18nCommon from '../../../common/translations'; +import * as i18n from './translations'; +import { useAppToasts } from '../../../common/hooks/use_app_toasts'; + +/** + * Closes alerts. + * + * @param ruleStaticIds static id of the rules (rule.ruleId, not rule.id) where the exception updates will be applied + * @param exceptionItems array of ExceptionListItemSchema to add or update + * @param alertIdToClose - optional string representing alert to close + * @param bulkCloseIndex - optional index used to create bulk close query + * + */ +export type AddOrUpdateExceptionItemsFunc = ( + ruleStaticIds: string[], + exceptionItems: ExceptionListItemSchema[], + alertIdToClose?: string, + bulkCloseIndex?: Index +) => Promise; + +export type ReturnUseCloseAlertsFromExceptions = [boolean, AddOrUpdateExceptionItemsFunc | null]; + +/** + * Hook for closing alerts from exceptions + */ +export const useCloseAlertsFromExceptions = (): ReturnUseCloseAlertsFromExceptions => { + const { addSuccess, addError, addWarning } = useAppToasts(); + + const [isLoading, setIsLoading] = useState(false); + const closeAlertsRef = useRef(null); + + useEffect(() => { + let isSubscribed = true; + const abortCtrl = new AbortController(); + + const onUpdateAlerts: AddOrUpdateExceptionItemsFunc = async ( + ruleStaticIds, + exceptionItems, + alertIdToClose, + bulkCloseIndex + ) => { + try { + setIsLoading(true); + let alertIdResponse: estypes.UpdateByQueryResponse | undefined; + let bulkResponse: estypes.UpdateByQueryResponse | undefined; + if (alertIdToClose != null) { + alertIdResponse = await updateAlertStatus({ + query: getUpdateAlertsQuery([alertIdToClose]), + status: 'closed', + signal: abortCtrl.signal, + }); + } + + if (bulkCloseIndex != null) { + const alertStatusFilter = buildAlertStatusesFilter([ + 'open', + 'acknowledged', + 'in-progress', + ]); + + const filter = await getEsQueryFilter( + '', + 'kuery', + [...ruleStaticIds.flatMap((id) => buildAlertsFilter(id)), ...alertStatusFilter], + bulkCloseIndex, + prepareExceptionItemsForBulkClose(exceptionItems), + false + ); + + bulkResponse = await updateAlertStatus({ + query: { + query: filter, + }, + status: 'closed', + signal: abortCtrl.signal, + }); + } + + // NOTE: there could be some overlap here... it's possible that the first response had conflicts + // but that the alert was closed in the second call. In this case, a conflict will be reported even + // though it was already resolved. I'm not sure that there's an easy way to solve this, but it should + // have minimal impact on the user... they'd see a warning that indicates a possible conflict, but the + // state of the alerts and their representation in the UI would be consistent. + const updated = (alertIdResponse?.updated ?? 0) + (bulkResponse?.updated ?? 0); + const conflicts = + alertIdResponse?.version_conflicts ?? 0 + (bulkResponse?.version_conflicts ?? 0); + if (isSubscribed) { + setIsLoading(false); + addSuccess(i18n.CLOSE_ALERTS_SUCCESS(updated)); + if (conflicts > 0) { + addWarning({ + title: i18nCommon.UPDATE_ALERT_STATUS_FAILED(conflicts), + text: i18nCommon.UPDATE_ALERT_STATUS_FAILED_DETAILED(updated, conflicts), + }); + } + } + } catch (error) { + if (isSubscribed) { + setIsLoading(false); + addError(error, { title: i18n.CLOSE_ALERTS_ERROR }); + } + } + }; + + closeAlertsRef.current = onUpdateAlerts; + return (): void => { + isSubscribed = false; + abortCtrl.abort(); + }; + }, [addSuccess, addError, addWarning]); + + return [isLoading, closeAlertsRef.current]; +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_create_update_exception.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_create_update_exception.tsx new file mode 100644 index 00000000000000..fbe9c0d46e6b3d --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_create_update_exception.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useRef, useState } from 'react'; +import type { + CreateExceptionListItemSchema, + ExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; +import { useApi } from '@kbn/securitysolution-list-hooks'; + +import { formatExceptionItemForUpdate } from '../utils/helpers'; +import { useKibana } from '../../../common/lib/kibana'; + +export type CreateOrUpdateExceptionItemsFunc = ( + args: Array +) => Promise; + +export type ReturnUseCreateOrUpdateException = [boolean, CreateOrUpdateExceptionItemsFunc | null]; + +/** + * Hook for adding and/or updating an exception item + */ +export const useCreateOrUpdateException = (): ReturnUseCreateOrUpdateException => { + const { + services: { http }, + } = useKibana(); + const [isLoading, setIsLoading] = useState(false); + const addOrUpdateExceptionRef = useRef(null); + const { addExceptionListItem, updateExceptionListItem } = useApi(http); + + useEffect(() => { + const abortCtrl = new AbortController(); + + const onCreateOrUpdateExceptionItem: CreateOrUpdateExceptionItemsFunc = async (items) => { + setIsLoading(true); + const itemsAdded = await Promise.all( + items.map((item: ExceptionListItemSchema | CreateExceptionListItemSchema) => { + if ('id' in item && item.id != null) { + const formattedExceptionItem = formatExceptionItemForUpdate(item); + return updateExceptionListItem({ + listItem: formattedExceptionItem, + }); + } else { + return addExceptionListItem({ + listItem: item, + }); + } + }) + ); + + setIsLoading(false); + + return itemsAdded; + }; + + addOrUpdateExceptionRef.current = onCreateOrUpdateExceptionItem; + return (): void => { + setIsLoading(false); + abortCtrl.abort(); + }; + }, [updateExceptionListItem, http, addExceptionListItem]); + + return [isLoading, addOrUpdateExceptionRef.current]; +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_exception_flyout_data.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_exception_flyout_data.tsx new file mode 100644 index 00000000000000..57cfda994d37c5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_exception_flyout_data.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useState, useMemo } from 'react'; +import type { DataViewBase } from '@kbn/es-query'; + +import type { Rule } from '../../../detections/containers/detection_engine/rules/types'; +import { useGetInstalledJob } from '../../../common/components/ml/hooks/use_get_jobs'; +import { useKibana } from '../../../common/lib/kibana'; +import { useFetchIndex } from '../../../common/containers/source'; + +export interface ReturnUseFetchExceptionFlyoutData { + isLoading: boolean; + indexPatterns: DataViewBase; +} + +/** + * Hook for fetching the fields to be used for populating the exception + * item conditions options. + * + */ +export const useFetchIndexPatterns = (rules: Rule[] | null): ReturnUseFetchExceptionFlyoutData => { + const { data } = useKibana().services; + const [dataViewLoading, setDataViewLoading] = useState(false); + const isSingleRule = useMemo(() => rules != null && rules.length === 1, [rules]); + const isMLRule = useMemo( + () => rules != null && isSingleRule && rules[0].type === 'machine_learning', + [isSingleRule, rules] + ); + // If data view is defined, it superceeds use of rule defined index patterns. + // If no rule is available, use fields from default data view id. + const memoDataViewId = useMemo( + () => + rules != null && isSingleRule ? rules[0].data_view_id || null : 'security-solution-default', + [isSingleRule, rules] + ); + + const memoNonDataViewIndexPatterns = useMemo( + () => + !memoDataViewId && rules != null && isSingleRule && rules[0].index != null + ? rules[0].index + : [], + [memoDataViewId, isSingleRule, rules] + ); + + // Index pattern logic for ML + const memoMlJobIds = useMemo( + () => (isMLRule && isSingleRule && rules != null ? rules[0].machine_learning_job_id ?? [] : []), + [isMLRule, isSingleRule, rules] + ); + const { loading: mlJobLoading, jobs } = useGetInstalledJob(memoMlJobIds); + + // We only want to provide a non empty array if it's an ML rule and we were able to fetch + // the index patterns, or if it's a rule not using data views. Otherwise, return an empty + // empty array to avoid making the `useFetchIndex` call + const memoRuleIndices = useMemo(() => { + if (isMLRule && jobs.length > 0) { + return jobs[0].results_index_name ? [`.ml-anomalies-${jobs[0].results_index_name}`] : []; + } else if (memoDataViewId != null) { + return []; + } else { + return memoNonDataViewIndexPatterns; + } + }, [jobs, isMLRule, memoDataViewId, memoNonDataViewIndexPatterns]); + + const [isIndexPatternLoading, { indexPatterns: indexIndexPatterns }] = + useFetchIndex(memoRuleIndices); + + // Data view logic + const [dataViewIndexPatterns, setDataViewIndexPatterns] = useState(null); + useEffect(() => { + const fetchSingleDataView = async () => { + if (memoDataViewId) { + setDataViewLoading(true); + const dv = await data.dataViews.get(memoDataViewId); + setDataViewLoading(false); + setDataViewIndexPatterns(dv); + } + }; + + fetchSingleDataView(); + }, [memoDataViewId, data.dataViews, setDataViewIndexPatterns]); + + // Determine whether to use index patterns or data views + const indexPatternsToUse = useMemo( + (): DataViewBase => + memoDataViewId && dataViewIndexPatterns != null ? dataViewIndexPatterns : indexIndexPatterns, + [memoDataViewId, dataViewIndexPatterns, indexIndexPatterns] + ); + + return { + isLoading: isIndexPatternLoading || mlJobLoading || dataViewLoading, + indexPatterns: indexPatternsToUse, + }; +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.test.tsx index c88a7d16b6c4df..a9a43ef2e47aa9 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.test.tsx @@ -16,8 +16,6 @@ import { enrichNewExceptionItemsWithComments, enrichExistingExceptionItemWithComments, enrichExceptionItemsWithOS, - entryHasListType, - entryHasNonEcsType, prepareExceptionItemsForBulkClose, lowercaseHashValues, getPrepopulatedEndpointException, @@ -34,7 +32,6 @@ import type { OsTypeArray, ExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; -import { ListOperatorTypeEnum as OperatorTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import type { DataViewBase } from '@kbn/es-query'; import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; @@ -317,32 +314,6 @@ describe('Exception helpers', () => { }); }); - describe('#entryHasListType', () => { - test('it should return false with an empty array', () => { - const payload: ExceptionListItemSchema[] = []; - const result = entryHasListType(payload); - expect(result).toEqual(false); - }); - - test("it should return false with exception items that don't contain a list type", () => { - const payload = [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()]; - const result = entryHasListType(payload); - expect(result).toEqual(false); - }); - - test('it should return true with exception items that do contain a list type', () => { - const payload = [ - { - ...getExceptionListItemSchemaMock(), - entries: [{ type: OperatorTypeEnum.LIST }] as EntriesArray, - }, - getExceptionListItemSchemaMock(), - ]; - const result = entryHasListType(payload); - expect(result).toEqual(true); - }); - }); - describe('#getCodeSignatureValue', () => { test('it should return empty string if code_signature nested value are undefined', () => { // Using the unsafe casting because with our types this shouldn't be possible but there have been issues with old data having undefined values in these fields @@ -354,47 +325,6 @@ describe('Exception helpers', () => { }); }); - describe('#entryHasNonEcsType', () => { - const mockEcsIndexPattern = { - title: 'testIndex', - fields: [ - { - name: 'some.parentField', - }, - { - name: 'some.not.nested.field', - }, - { - name: 'nested.field', - }, - ], - } as DataViewBase; - - test('it should return false with an empty array', () => { - const payload: ExceptionListItemSchema[] = []; - const result = entryHasNonEcsType(payload, mockEcsIndexPattern); - expect(result).toEqual(false); - }); - - test("it should return false with exception items that don't contain a non ecs type", () => { - const payload = [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()]; - const result = entryHasNonEcsType(payload, mockEcsIndexPattern); - expect(result).toEqual(false); - }); - - test('it should return true with exception items that do contain a non ecs type', () => { - const payload = [ - { - ...getExceptionListItemSchemaMock(), - entries: [{ field: 'some.nonEcsField' }] as EntriesArray, - }, - getExceptionListItemSchemaMock(), - ]; - const result = entryHasNonEcsType(payload, mockEcsIndexPattern); - expect(result).toEqual(true); - }); - }); - describe('#prepareExceptionItemsForBulkClose', () => { test('it should return no exceptionw when passed in an empty array', () => { const payload: ExceptionListItemSchema[] = []; @@ -509,7 +439,7 @@ describe('Exception helpers', () => { test('it returns prepopulated fields with empty values', () => { const prepopulatedItem = getPrepopulatedEndpointException({ listId: 'some_id', - ruleName: 'my rule', + name: 'my rule', codeSignature: { subjectName: '', trusted: '' }, eventCode: '', alertEcsData: { ...alertDataMock, file: { path: '', hash: { sha256: '' } } }, @@ -534,7 +464,7 @@ describe('Exception helpers', () => { test('it returns prepopulated items with actual values', () => { const prepopulatedItem = getPrepopulatedEndpointException({ listId: 'some_id', - ruleName: 'my rule', + name: 'my rule', codeSignature: { subjectName: 'someSubjectName', trusted: 'false' }, eventCode: 'some-event-code', alertEcsData: alertDataMock, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.tsx index f876f11be9e3d6..41d588a23763ae 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.tsx @@ -21,26 +21,19 @@ import type { OsTypeArray, ExceptionListType, ExceptionListItemSchema, - CreateExceptionListItemSchema, UpdateExceptionListItemSchema, ExceptionListSchema, } from '@kbn/securitysolution-io-ts-list-types'; -import { - comment, - osType, - ListOperatorTypeEnum as OperatorTypeEnum, -} from '@kbn/securitysolution-io-ts-list-types'; +import { comment, osType } from '@kbn/securitysolution-io-ts-list-types'; import type { ExceptionsBuilderExceptionItem, ExceptionsBuilderReturnExceptionItem, } from '@kbn/securitysolution-list-utils'; -import { - getOperatorType, - getNewExceptionItem, - addIdToEntries, -} from '@kbn/securitysolution-list-utils'; +import { getNewExceptionItem, addIdToEntries } from '@kbn/securitysolution-list-utils'; import type { DataViewBase } from '@kbn/es-query'; +import { removeIdFromExceptionItemsEntries } from '@kbn/securitysolution-list-hooks'; + import * as i18n from './translations'; import type { AlertData, Flattened } from './types'; @@ -145,9 +138,9 @@ export const formatExceptionItemForUpdate = ( * @param exceptionItems new or existing ExceptionItem[] */ export const prepareExceptionItemsForBulkClose = ( - exceptionItems: Array -): Array => { - return exceptionItems.map((item: ExceptionListItemSchema | CreateExceptionListItemSchema) => { + exceptionItems: ExceptionListItemSchema[] +): ExceptionListItemSchema[] => { + return exceptionItems.map((item: ExceptionListItemSchema) => { if (item.entries !== undefined) { const newEntries = item.entries.map((itemEntry: Entry | EntryNested) => { return { @@ -285,17 +278,6 @@ export const lowercaseHashValues = ( }); }; -export const entryHasListType = (exceptionItems: ExceptionsBuilderReturnExceptionItem[]) => { - for (const { entries } of exceptionItems) { - for (const exceptionEntry of entries ?? []) { - if (getOperatorType(exceptionEntry) === OperatorTypeEnum.LIST) { - return true; - } - } - } - return false; -}; - /** * Returns the value for `file.Ext.code_signature` which * can be an object or array of objects @@ -377,7 +359,7 @@ function filterEmptyExceptionEntries(entries: T[]): T[ */ export const getPrepopulatedEndpointException = ({ listId, - ruleName, + name, codeSignature, eventCode, listNamespace = 'agnostic', @@ -385,7 +367,7 @@ export const getPrepopulatedEndpointException = ({ }: { listId: string; listNamespace?: NamespaceType; - ruleName: string; + name: string; codeSignature: { subjectName: string; trusted: string }; eventCode: string; alertEcsData: Flattened; @@ -449,7 +431,7 @@ export const getPrepopulatedEndpointException = ({ }; return { - ...getNewExceptionItem({ listId, namespaceType: listNamespace, ruleName }), + ...getNewExceptionItem({ listId, namespaceType: listNamespace, name }), entries: entriesToAdd(), }; }; @@ -459,7 +441,7 @@ export const getPrepopulatedEndpointException = ({ */ export const getPrepopulatedRansomwareException = ({ listId, - ruleName, + name, codeSignature, eventCode, listNamespace = 'agnostic', @@ -467,7 +449,7 @@ export const getPrepopulatedRansomwareException = ({ }: { listId: string; listNamespace?: NamespaceType; - ruleName: string; + name: string; codeSignature: { subjectName: string; trusted: string }; eventCode: string; alertEcsData: Flattened; @@ -477,7 +459,7 @@ export const getPrepopulatedRansomwareException = ({ const executable = process?.executable ?? ''; const ransomwareFeature = Ransomware?.feature ?? ''; return { - ...getNewExceptionItem({ listId, namespaceType: listNamespace, ruleName }), + ...getNewExceptionItem({ listId, namespaceType: listNamespace, name }), entries: addIdToEntries([ { field: 'process.Ext.code_signature', @@ -527,14 +509,14 @@ export const getPrepopulatedRansomwareException = ({ export const getPrepopulatedMemorySignatureException = ({ listId, - ruleName, + name, eventCode, listNamespace = 'agnostic', alertEcsData, }: { listId: string; listNamespace?: NamespaceType; - ruleName: string; + name: string; eventCode: string; alertEcsData: Flattened; }): ExceptionsBuilderExceptionItem => { @@ -566,20 +548,20 @@ export const getPrepopulatedMemorySignatureException = ({ }, ]); return { - ...getNewExceptionItem({ listId, namespaceType: listNamespace, ruleName }), + ...getNewExceptionItem({ listId, namespaceType: listNamespace, name }), entries: addIdToEntries(entries), }; }; export const getPrepopulatedMemoryShellcodeException = ({ listId, - ruleName, + name, eventCode, listNamespace = 'agnostic', alertEcsData, }: { listId: string; listNamespace?: NamespaceType; - ruleName: string; + name: string; eventCode: string; alertEcsData: Flattened; }): ExceptionsBuilderExceptionItem => { @@ -618,21 +600,21 @@ export const getPrepopulatedMemoryShellcodeException = ({ ]); return { - ...getNewExceptionItem({ listId, namespaceType: listNamespace, ruleName }), + ...getNewExceptionItem({ listId, namespaceType: listNamespace, name }), entries: addIdToEntries(entries), }; }; export const getPrepopulatedBehaviorException = ({ listId, - ruleName, + name, eventCode, listNamespace = 'agnostic', alertEcsData, }: { listId: string; listNamespace?: NamespaceType; - ruleName: string; + name: string; eventCode: string; alertEcsData: Flattened; }): ExceptionsBuilderExceptionItem => { @@ -748,47 +730,17 @@ export const getPrepopulatedBehaviorException = ({ }, ]); return { - ...getNewExceptionItem({ listId, namespaceType: listNamespace, ruleName }), + ...getNewExceptionItem({ listId, namespaceType: listNamespace, name }), entries: addIdToEntries(entries), }; }; -/** - * Determines whether or not any entries within the given exceptionItems contain values not in the specified ECS mapping - */ -export const entryHasNonEcsType = ( - exceptionItems: ExceptionsBuilderReturnExceptionItem[], - indexPatterns: DataViewBase -): boolean => { - const doesFieldNameExist = (exceptionEntry: Entry): boolean => { - return indexPatterns.fields.some(({ name }) => name === exceptionEntry.field); - }; - - if (exceptionItems.length === 0) { - return false; - } - for (const { entries } of exceptionItems) { - for (const exceptionEntry of entries ?? []) { - if (exceptionEntry.type === 'nested') { - for (const nestedExceptionEntry of exceptionEntry.entries) { - if (doesFieldNameExist(nestedExceptionEntry) === false) { - return true; - } - } - } else if (doesFieldNameExist(exceptionEntry) === false) { - return true; - } - } - } - return false; -}; - /** * Returns the default values from the alert data to autofill new endpoint exceptions */ export const defaultEndpointExceptionItems = ( listId: string, - ruleName: string, + name: string, alertEcsData: Flattened & { 'event.code'?: string } ): ExceptionsBuilderExceptionItem[] => { const eventCode = alertEcsData['event.code'] ?? alertEcsData.event?.code; @@ -798,7 +750,7 @@ export const defaultEndpointExceptionItems = ( return [ getPrepopulatedBehaviorException({ listId, - ruleName, + name, eventCode, alertEcsData, }), @@ -807,7 +759,7 @@ export const defaultEndpointExceptionItems = ( return [ getPrepopulatedMemorySignatureException({ listId, - ruleName, + name, eventCode, alertEcsData, }), @@ -816,7 +768,7 @@ export const defaultEndpointExceptionItems = ( return [ getPrepopulatedMemoryShellcodeException({ listId, - ruleName, + name, eventCode, alertEcsData, }), @@ -825,7 +777,7 @@ export const defaultEndpointExceptionItems = ( return getProcessCodeSignature(alertEcsData).map((codeSignature) => getPrepopulatedRansomwareException({ listId, - ruleName, + name, eventCode, codeSignature, alertEcsData, @@ -836,7 +788,7 @@ export const defaultEndpointExceptionItems = ( return getFileCodeSignature(alertEcsData).map((codeSignature) => getPrepopulatedEndpointException({ listId, - ruleName, + name, eventCode: eventCode ?? '', codeSignature, alertEcsData, @@ -872,7 +824,7 @@ export const enrichRuleExceptions = ( ): ExceptionsBuilderReturnExceptionItem[] => { return exceptionItems.map((item: ExceptionsBuilderReturnExceptionItem) => { return { - ...item, + ...removeIdFromExceptionItemsEntries(item), list_id: undefined, namespace_type: 'single', }; @@ -891,7 +843,7 @@ export const enrichSharedExceptions = ( return lists.flatMap((list) => { return exceptionItems.map((item) => { return { - ...item, + ...removeIdFromExceptionItemsEntries(item), list_id: list.list_id, namespace_type: list.namespace_type, }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index 25853c932a1857..03141dfb02f570 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -11,7 +11,7 @@ import { EuiButtonIcon, EuiContextMenuPanel, EuiPopover, EuiToolTip } from '@ela import { indexOf } from 'lodash'; import type { ConnectedProps } from 'react-redux'; import { connect } from 'react-redux'; -import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types'; +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import { get } from 'lodash/fp'; import { DEFAULT_ACTION_BUTTON_WIDTH } from '@kbn/timelines-plugin/public'; import { isActiveTimeline } from '../../../../helpers'; @@ -42,6 +42,7 @@ import { ATTACH_ALERT_TO_CASE_FOR_ROW } from '../../../../timelines/components/t import { useEventFilterAction } from './use_event_filter_action'; import { useAddToCaseActions } from './use_add_to_case_actions'; import { isAlertFromEndpointAlert } from '../../../../common/utils/endpoint_alert_check'; +import type { Rule } from '../../../containers/detection_engine/rules/types'; interface AlertContextMenuProps { ariaLabel?: string; @@ -100,7 +101,6 @@ const AlertContextMenuComponent: React.FC indexOf(ecsRowData.event?.kind, 'event') !== -1, [ecsRowData]); const isAgentEndpoint = useMemo(() => ecsRowData.agent?.type?.includes('endpoint'), [ecsRowData]); - const isEndpointEvent = useMemo(() => isEvent && isAgentEndpoint, [isEvent, isAgentEndpoint]); const scopeIdAllowsAddEndpointEventFilter = useMemo( () => scopeId === TableId.hostsPageEvents || scopeId === TableId.usersPageEvents, @@ -147,16 +147,19 @@ const AlertContextMenuComponent: React.FC { + (type?: ExceptionListTypeEnum) => { onAddExceptionTypeClick(type); closePopover(); }, @@ -251,22 +254,19 @@ const AlertContextMenuComponent: React.FC
)} - {exceptionFlyoutType != null && - ruleId != null && - ruleName != null && - ecsRowData?._id != null && ( - - )} + {openAddExceptionFlyout && ruleId != null && ruleName != null && ecsRowData?._id != null && ( + + )} {isAddEventFilterModalOpen && ecsRowData != null && ( )} @@ -301,9 +301,19 @@ export const AlertContextMenu = connector(React.memo(AlertContextMenuComponent)) type AddExceptionFlyoutWrapperProps = Omit< AddExceptionFlyoutProps, - 'alertData' | 'isAlertDataLoading' + | 'alertData' + | 'isAlertDataLoading' + | 'isEndpointItem' + | 'rules' + | 'isBulkAction' + | 'showAlertCloseOptions' > & { eventId?: string; + ruleId: Rule['id']; + ruleIndices: Rule['index']; + ruleDataViewId: Rule['data_view_id']; + ruleName: Rule['name']; + exceptionListType: ExceptionListTypeEnum | null; }; /** @@ -312,15 +322,15 @@ type AddExceptionFlyoutWrapperProps = Omit< * we cannot use the fetch hook within the flyout component itself */ export const AddExceptionFlyoutWrapper: React.FC = ({ - ruleName, ruleId, ruleIndices, + ruleDataViewId, + ruleName, exceptionListType, eventId, onCancel, onConfirm, alertStatus, - onRuleChange, }) => { const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex(); @@ -354,8 +364,8 @@ export const AddExceptionFlyoutWrapper: React.FC ? enrichedAlert.signal.rule.index : [enrichedAlert.signal.rule.index]; } - return []; - }, [enrichedAlert]); + return ruleIndices; + }, [enrichedAlert, ruleIndices]); const memoDataViewId = useMemo(() => { if ( @@ -364,23 +374,51 @@ export const AddExceptionFlyoutWrapper: React.FC ) { return enrichedAlert['kibana.alert.rule.parameters'].data_view_id; } - }, [enrichedAlert]); - const isLoading = isLoadingAlertData && isSignalIndexLoading; + return ruleDataViewId; + }, [enrichedAlert, ruleDataViewId]); + + // TODO: Do we want to notify user when they are working off of an older version of a rule + // if they select to add an exception from an alert referencing an older rule version? + const memoRule = useMemo(() => { + if (enrichedAlert != null && enrichedAlert['kibana.alert.rule.parameters'] != null) { + return [ + { + ...enrichedAlert['kibana.alert.rule.parameters'], + id: ruleId, + name: ruleName, + index: memoRuleIndices, + data_view_id: memoDataViewId, + }, + ] as Rule[]; + } + + return [ + { + id: ruleId, + name: ruleName, + index: memoRuleIndices, + data_view_id: memoDataViewId, + }, + ] as Rule[]; + }, [enrichedAlert, memoDataViewId, memoRuleIndices, ruleId, ruleName]); + + const isLoading = + (isLoadingAlertData && isSignalIndexLoading) || + enrichedAlert == null || + memoRuleIndices == null; return ( ); }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_actions.tsx index e0a09be0873d60..e63cbcc4c22d8f 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_actions.tsx @@ -7,14 +7,14 @@ import React, { useCallback, useMemo } from 'react'; import { EuiContextMenuItem } from '@elastic/eui'; -import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types'; +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import { useUserData } from '../../user_info'; import { ACTION_ADD_ENDPOINT_EXCEPTION, ACTION_ADD_EXCEPTION } from '../translations'; interface UseExceptionActionProps { isEndpointAlert: boolean; - onAddExceptionTypeClick: (type: ExceptionListType) => void; + onAddExceptionTypeClick: (type?: ExceptionListTypeEnum) => void; } export const useExceptionActions = ({ @@ -24,11 +24,11 @@ export const useExceptionActions = ({ const [{ canUserCRUD, hasIndexWrite }] = useUserData(); const handleDetectionExceptionModal = useCallback(() => { - onAddExceptionTypeClick('detection'); + onAddExceptionTypeClick(); }, [onAddExceptionTypeClick]); const handleEndpointExceptionModal = useCallback(() => { - onAddExceptionTypeClick('endpoint'); + onAddExceptionTypeClick(ExceptionListTypeEnum.ENDPOINT); }, [onAddExceptionTypeClick]); const disabledAddEndpointException = !canUserCRUD || !hasIndexWrite || !isEndpointAlert; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_flyout.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_flyout.tsx index aff1c943110c01..85892f4ba5b536 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_flyout.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_flyout.tsx @@ -5,63 +5,66 @@ * 2.0. */ -import { useCallback, useMemo, useState } from 'react'; -import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types'; +import { useCallback, useState } from 'react'; +import type { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; -import { DEFAULT_INDEX_PATTERN } from '../../../../../common/constants'; import type { inputsModel } from '../../../../common/store'; interface UseExceptionFlyoutProps { - ruleIndex: string[] | null | undefined; refetch?: inputsModel.Refetch; + onRuleChange?: () => void; isActiveTimelines: boolean; } interface UseExceptionFlyout { - exceptionFlyoutType: ExceptionListType | null; - onAddExceptionTypeClick: (type: ExceptionListType) => void; - onAddExceptionCancel: () => void; - onAddExceptionConfirm: (didCloseAlert: boolean, didBulkCloseAlert: boolean) => void; - ruleIndices: string[]; + exceptionFlyoutType: ExceptionListTypeEnum | null; + openAddExceptionFlyout: boolean; + onAddExceptionTypeClick: (type?: ExceptionListTypeEnum) => void; + onAddExceptionCancel: (didRuleChange: boolean) => void; + onAddExceptionConfirm: ( + didRuleChange: boolean, + didCloseAlert: boolean, + didBulkCloseAlert: boolean + ) => void; } export const useExceptionFlyout = ({ - ruleIndex, refetch, + onRuleChange, isActiveTimelines, }: UseExceptionFlyoutProps): UseExceptionFlyout => { - const [exceptionFlyoutType, setOpenAddExceptionFlyout] = useState(null); - - const ruleIndices = useMemo((): string[] => { - if (ruleIndex != null) { - return ruleIndex; - } else { - return DEFAULT_INDEX_PATTERN; - } - }, [ruleIndex]); + const [openAddExceptionFlyout, setOpenAddExceptionFlyout] = useState(false); + const [exceptionFlyoutType, setExceptionFlyoutType] = useState( + null + ); - const onAddExceptionTypeClick = useCallback((exceptionListType: ExceptionListType): void => { - setOpenAddExceptionFlyout(exceptionListType); + const onAddExceptionTypeClick = useCallback((exceptionListType?: ExceptionListTypeEnum): void => { + setExceptionFlyoutType(exceptionListType ?? null); + setOpenAddExceptionFlyout(true); }, []); const onAddExceptionCancel = useCallback(() => { - setOpenAddExceptionFlyout(null); + setExceptionFlyoutType(null); + setOpenAddExceptionFlyout(false); }, []); const onAddExceptionConfirm = useCallback( - (didCloseAlert: boolean, didBulkCloseAlert) => { + (didRuleChange: boolean, didCloseAlert: boolean, didBulkCloseAlert) => { if (refetch && (isActiveTimelines === false || didBulkCloseAlert)) { refetch(); } - setOpenAddExceptionFlyout(null); + if (onRuleChange != null && didRuleChange) { + onRuleChange(); + } + setOpenAddExceptionFlyout(false); }, - [refetch, isActiveTimelines] + [onRuleChange, refetch, isActiveTimelines] ); return { exceptionFlyoutType, + openAddExceptionFlyout, onAddExceptionTypeClick, onAddExceptionCancel, onAddExceptionConfirm, - ruleIndices, }; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx index 32f2d9d889945e..c0c3b35a72ac6c 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx @@ -67,7 +67,6 @@ export const useAlertsActions = ({ setEventsDeleted, onUpdateSuccess: onStatusUpdate, onUpdateFailure: onStatusUpdate, - scopeId, }); return { diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx index ba641875b25005..f4364443a6dfa1 100644 --- a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx @@ -7,7 +7,7 @@ import React, { useCallback, useMemo, useState } from 'react'; import { EuiButton, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; -import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types'; +import type { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import { isActiveTimeline } from '../../../helpers'; import { TableId } from '../../../../common/types'; import { useResponderActionItem } from '../endpoint_responder'; @@ -45,7 +45,7 @@ export interface TakeActionDropdownProps { isHostIsolationPanelOpen: boolean; loadingEventDetails: boolean; onAddEventFilterClick: () => void; - onAddExceptionTypeClick: (type: ExceptionListType) => void; + onAddExceptionTypeClick: (type?: ExceptionListTypeEnum) => void; onAddIsolationStatusClick: (action: 'isolateHost' | 'unisolateHost') => void; refetch: (() => void) | undefined; refetchFlyoutData: () => Promise; @@ -144,7 +144,7 @@ export const TakeActionDropdown = React.memo( ); const handleOnAddExceptionTypeClick = useCallback( - (type: ExceptionListType) => { + (type?: ExceptionListTypeEnum) => { onAddExceptionTypeClick(type); setIsPopoverOpen(false); }, diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/exceptions/get_es_query_filter.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/exceptions/get_es_query_filter.ts index bab81d4625f0c6..2b280786e2905a 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/exceptions/get_es_query_filter.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/exceptions/get_es_query_filter.ts @@ -6,13 +6,11 @@ */ import type { Language } from '@kbn/securitysolution-io-ts-alerting-types'; -import type { - ExceptionListItemSchema, - CreateExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; import type { Filter, EsQueryConfig, DataViewBase } from '@kbn/es-query'; import { getExceptionFilterFromExceptions } from '@kbn/securitysolution-list-api'; import { buildEsQuery } from '@kbn/es-query'; + +import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import { KibanaServices } from '../../../../common/lib/kibana'; import type { Query, Index } from '../../../../../common/detection_engine/schemas/common'; @@ -23,7 +21,7 @@ export const getEsQueryFilter = async ( language: Language, filters: unknown, index: Index, - lists: Array, + lists: ExceptionListItemSchema[], excludeExceptions: boolean = true ): Promise => { const indexPattern: DataViewBase = { diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts index 1a1238c36ca34c..e2c1e0d31cd570 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts @@ -8,6 +8,10 @@ import { camelCase } from 'lodash'; import type { HttpStart } from '@kbn/core/public'; +import type { + CreateRuleExceptionListItemSchema, + ExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; import { DETECTION_ENGINE_RULES_URL, DETECTION_ENGINE_PREPACKAGED_URL, @@ -406,3 +410,30 @@ export const findRuleExceptionReferences = async ({ } ); }; + +/** + * Add exception items to default rule exception list + * + * @param ruleId `id` of rule to add items to + * @param items CreateRuleExceptionListItemSchema[] + * @param signal to cancel request + * + * @throws An error if response is not OK + */ +export const addRuleExceptions = async ({ + ruleId, + items, + signal, +}: { + ruleId: string; + items: CreateRuleExceptionListItemSchema[]; + signal: AbortSignal | undefined; +}): Promise => + KibanaServices.get().http.fetch( + `${DETECTION_ENGINE_RULES_URL}/${ruleId}/exceptions`, + { + method: 'POST', + body: JSON.stringify({ items }), + signal, + } + ); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx index 97c302d7038c5c..2989b77ec28ded 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx @@ -866,7 +866,10 @@ const RuleDetailsPageComponent: React.FC = ({ = ({ > { const alertId = detailsEcsData?.kibana?.alert ? detailsEcsData?._id : null; - const ruleIndex = useMemo( + const ruleIndexRaw = useMemo( () => find({ category: 'signal', field: 'signal.rule.index' }, detailsData)?.values ?? find({ category: 'kibana', field: 'kibana.alert.rule.parameters.index' }, detailsData) ?.values, [detailsData] ); + const ruleIndex = useMemo( + (): string[] | undefined => (Array.isArray(ruleIndexRaw) ? ruleIndexRaw : undefined), + [ruleIndexRaw] + ); + const ruleDataViewIdRaw = useMemo( + () => + find({ category: 'signal', field: 'signal.rule.data_view_id' }, detailsData)?.values ?? + find( + { category: 'kibana', field: 'kibana.alert.rule.parameters.data_view_id' }, + detailsData + )?.values, + [detailsData] + ); + const ruleDataViewId = useMemo( + (): string | undefined => + Array.isArray(ruleDataViewIdRaw) ? ruleDataViewIdRaw[0] : undefined, + [ruleDataViewIdRaw] + ); const addExceptionModalWrapperData = useMemo( () => @@ -102,12 +120,11 @@ export const FlyoutFooterComponent = React.memo( const { exceptionFlyoutType, + openAddExceptionFlyout, onAddExceptionTypeClick, onAddExceptionCancel, onAddExceptionConfirm, - ruleIndices, } = useExceptionFlyout({ - ruleIndex, refetch: refetchAll, isActiveTimelines: isActiveTimeline(scopeId), }); @@ -154,12 +171,13 @@ export const FlyoutFooterComponent = React.memo( {/* This is still wrong to do render flyout/modal inside of the flyout We need to completely refactor the EventDetails component to be correct */} - {exceptionFlyoutType != null && + {openAddExceptionFlyout && addExceptionModalWrapperData.ruleId != null && addExceptionModalWrapperData.eventId != null && ( ({ useCreateFieldButton: () => <>, })); -describe('Body', () => { +// SKIP: https://github.com/elastic/kibana/issues/143718 +describe.skip('Body', () => { const mount = useMountAppended(); const mockRefetch = jest.fn(); let appToastsMock: jest.Mocked>; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/action_responder/constants.ts b/x-pack/plugins/security_solution/scripts/endpoint/action_responder/constants.ts deleted file mode 100644 index 23c3292da66e70..00000000000000 --- a/x-pack/plugins/security_solution/scripts/endpoint/action_responder/constants.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const HORIZONTAL_LINE = '-'.repeat(80); - -export const SUPPORTED_TOKENS = `The following tokens can be used in the Action request 'comment' to drive - the type of response that is sent: - Token Description - --------------------------- ------------------------------------------------------- - RESPOND.STATE=SUCCESS Will ensure the Endpoint Action response is success - RESPOND.STATE=FAILURE Will ensure the Endpoint Action response is a failure - RESPOND.FLEET.STATE=SUCCESS Will ensure the Fleet Action response is success - RESPOND.FLEET.STATE=FAILURE Will ensure the Fleet Action response is a failure - -`; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/action_responder/index.ts b/x-pack/plugins/security_solution/scripts/endpoint/action_responder/index.ts deleted file mode 100644 index ae73a3a978d21d..00000000000000 --- a/x-pack/plugins/security_solution/scripts/endpoint/action_responder/index.ts +++ /dev/null @@ -1,68 +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 type { RunContext } from '@kbn/dev-cli-runner'; -import { run } from '@kbn/dev-cli-runner'; -import { HORIZONTAL_LINE, SUPPORTED_TOKENS } from './constants'; -import { runInAutoMode } from './run_in_auto_mode'; - -export const cli = () => { - run( - async (context: RunContext) => { - context.log.write(` -${HORIZONTAL_LINE} - Endpoint Action Responder -${HORIZONTAL_LINE} -`); - if (context.flags.mode === 'auto') { - return runInAutoMode(context); - } - - context.log.warning(`exiting... Nothing to do. use '--help' to see list of options`); - - context.log.write(` -${HORIZONTAL_LINE} -`); - }, - - { - description: `Respond to pending Endpoint actions. - ${SUPPORTED_TOKENS}`, - flags: { - string: ['mode', 'kibana', 'elastic', 'username', 'password', 'delay'], - boolean: ['asSuperuser'], - default: { - mode: 'auto', - kibana: 'http://localhost:5601', - elastic: 'http://localhost:9200', - username: 'elastic', - password: 'changeme', - asSuperuser: false, - delay: '', - }, - help: ` - --mode The mode for running the tool. (Default: 'auto'). - Value values are: - auto : tool will continue to run and checking for pending - actions periodically. - --username User name to be used for auth against elasticsearch and - kibana (Default: elastic). - **IMPORTANT:** This username's roles MUST have 'superuser'] - and 'kibana_system' roles - --password User name Password (Default: changeme) - --asSuperuser If defined, then a Security super user will be created using the - the credentials defined via 'username' and 'password' options. This - new user will then be used to run this utility. - --delay The delay (in milliseconds) that should be applied before responding - to an action. (Default: 40000 (40s)) - --kibana The url to Kibana (Default: http://localhost:5601) - --elastic The url to Elasticsearch (Default: http:localholst:9200) - `, - }, - } - ); -}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/action_responder/run_in_auto_mode.ts b/x-pack/plugins/security_solution/scripts/endpoint/action_responder/run_in_auto_mode.ts deleted file mode 100644 index 8e93cf7625b734..00000000000000 --- a/x-pack/plugins/security_solution/scripts/endpoint/action_responder/run_in_auto_mode.ts +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { RunContext } from '@kbn/dev-cli-runner'; -import { set } from 'lodash'; -import { SUPPORTED_TOKENS } from './constants'; -import type { ActionDetails } from '../../../common/endpoint/types'; -import type { RuntimeServices } from '../common/stack_services'; -import { createRuntimeServices } from '../common/stack_services'; - -import { - fetchEndpointActionList, - sendEndpointActionResponse, - sendFleetActionResponse, - sleep, -} from './utils'; - -const ACTION_RESPONSE_DELAY = 40_000; - -export const runInAutoMode = async ({ - log, - flags: { username, password, asSuperuser, kibana, elastic, delay: _delay }, -}: RunContext) => { - const runtimeServices = await createRuntimeServices({ - log, - password: password as string, - username: username as string, - asSuperuser: asSuperuser as boolean, - elasticsearchUrl: elastic as string, - kibanaUrl: kibana as string, - }); - - log.write(` ${SUPPORTED_TOKENS}`); - - const delay = Number(_delay) || ACTION_RESPONSE_DELAY; - - do { - await checkPendingActionsAndRespond(runtimeServices, { delay }); - await sleep(5_000); - } while (true); -}; - -const checkPendingActionsAndRespond = async ( - { kbnClient, esClient, log }: RuntimeServices, - { delay = ACTION_RESPONSE_DELAY }: { delay?: number } = {} -) => { - let hasMore = true; - let nextPage = 1; - - try { - while (hasMore) { - const { data: actions } = await fetchEndpointActionList(kbnClient, { - page: nextPage++, - pageSize: 100, - }); - - if (actions.length === 0) { - hasMore = false; - } - - for (const action of actions) { - if (action.isCompleted === false) { - if (Date.now() - new Date(action.startedAt).getTime() >= delay) { - log.info( - `[${new Date().toLocaleTimeString()}]: Responding to [${ - action.command - }] action [id: ${action.id}] agent: [${action.agents.join(', ')}]` - ); - - const tokens = parseCommentTokens(getActionComment(action)); - - log.verbose('tokens found in action:', tokens); - - const fleetResponse = await sendFleetActionResponse(esClient, action, { - // If an Endpoint state token was found, then force the Fleet response to `success` - // so that we can actually generate an endpoint response below. - state: tokens.state ? 'success' : tokens.fleet.state, - }); - - // If not a fleet response error, then also sent the Endpoint Response - if (!fleetResponse.error) { - await sendEndpointActionResponse(esClient, action, { state: tokens.state }); - } - } - } - } - } - } catch (e) { - log.error(`${e.message}. Run with '--verbose' option to see more`); - log.verbose(e); - } -}; - -interface CommentTokens { - state: 'success' | 'failure' | undefined; - fleet: { - state: 'success' | 'failure' | undefined; - }; -} - -const parseCommentTokens = (comment: string): CommentTokens => { - const response: CommentTokens = { - state: undefined, - fleet: { - state: undefined, - }, - }; - - if (comment) { - const findTokensRegExp = /(respond\.\S*=\S*)/gi; - let matches; - - while ((matches = findTokensRegExp.exec(comment)) !== null) { - const [key, value] = matches[0] - .toLowerCase() - .split('=') - .map((s) => s.trim()); - - set(response, key.split('.').slice(1), value); - } - } - return response; -}; - -const getActionComment = (action: ActionDetails): string => { - return action.comment ?? ''; -}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/agent_emulator.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/agent_emulator.ts new file mode 100644 index 00000000000000..637befd89e2f09 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/agent_emulator.ts @@ -0,0 +1,41 @@ +/* + * 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 { RunFn } from '@kbn/dev-cli-runner'; +import { MainScreen } from './screens/main'; +import { loadEndpointsIfNoneExist } from './services/endpoint_loader'; +import { EmulatorRunContext } from './services/emulator_run_context'; + +export const DEFAULT_CHECKIN_INTERVAL = 60_000; // 1m +export const DEFAULT_ACTION_DELAY = 5_000; // 5s + +export const agentEmulatorRunner: RunFn = async (cliContext) => { + const actionResponseDelay = Number(cliContext.flags.actionDelay) || DEFAULT_ACTION_DELAY; + const checkinInterval = Number(cliContext.flags.checkinInterval) || DEFAULT_CHECKIN_INTERVAL; + + const emulatorContext = new EmulatorRunContext({ + username: cliContext.flags.username as string, + password: cliContext.flags.password as string, + kibanaUrl: cliContext.flags.kibana as string, + elasticsearchUrl: cliContext.flags.elasticsearch as string, + asSuperuser: cliContext.flags.asSuperuser as boolean, + log: cliContext.log, + actionResponseDelay, + checkinInterval, + }); + await emulatorContext.start(); + + loadEndpointsIfNoneExist( + emulatorContext.getEsClient(), + emulatorContext.getKbnClient(), + emulatorContext.getLogger() + ); + + await new MainScreen(emulatorContext).show(); + + await emulatorContext.stop(); +}; diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/index.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/constants.ts similarity index 80% rename from x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/index.ts rename to x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/constants.ts index 7bbcc43230a443..50b825136532f6 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/index.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/constants.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { AlertsTableTGrid } from './alerts_table_t_grid'; +export const TOOL_TITLE = 'Endpoint Agent Emulator' as const; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/index.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/index.ts new file mode 100644 index 00000000000000..5daba7f613fa90 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/index.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { run } from '@kbn/dev-cli-runner'; +import { agentEmulatorRunner } from './agent_emulator'; + +const DEFAULT_CHECKIN_INTERVAL = 60_000; // 1m +const DEFAULT_ACTION_DELAY = 5_000; // 5s + +export const cli = () => { + run( + agentEmulatorRunner, + + // Options + { + description: `Endpoint agent emulator.`, + flags: { + string: ['kibana', 'elastic', 'username', 'password'], + boolean: ['asSuperuser'], + default: { + kibana: 'http://localhost:5601', + elasticsearch: 'http://localhost:9200', + username: 'elastic', + password: 'changeme', + asSuperuser: false, + actionDelay: DEFAULT_ACTION_DELAY, + checkinInterval: DEFAULT_CHECKIN_INTERVAL, + }, + help: ` + --username User name to be used for auth against elasticsearch and + kibana (Default: elastic). + **IMPORTANT:** if 'asSuperuser' option is not used, then the + user defined here MUST have 'superuser' AND 'kibana_system' roles + --password User name Password (Default: changeme) + --asSuperuser If defined, then a Security super user will be created using the + the credentials defined via 'username' and 'password' options. This + new user will then be used to run this utility. + --kibana The url to Kibana (Default: http://localhost:5601) + --elasticsearch The url to Elasticsearch (Default: http://localhost:9200) + --checkinInterval The interval between how often the Agent is checked into fleet and a + metadata document update is sent for the endpoint. Default is 1 minute + --actionDelay The delay (in milliseconds) that should be applied before responding + to an action. (Default: 5000 (5s)) + `, + }, + } + ); +}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/actions_responder.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/actions_responder.ts new file mode 100644 index 00000000000000..448f8907986fca --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/actions_responder.ts @@ -0,0 +1,71 @@ +/* + * 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 { blue } from 'chalk'; +import { HORIZONTAL_LINE } from '../../common/constants'; +import { RunServiceStatus } from './components/run_service_status_formatter'; +import { ColumnLayoutFormatter } from '../../common/screen/column_layout_formatter'; +import { TOOL_TITLE } from '../constants'; +import type { EmulatorRunContext } from '../services/emulator_run_context'; +import type { DataFormatter } from '../../common/screen'; +import { ChoiceMenuFormatter, ScreenBaseClass } from '../../common/screen'; + +export class ActionResponderScreen extends ScreenBaseClass { + constructor(private readonly emulatorContext: EmulatorRunContext) { + super(); + } + + protected header() { + return super.header(TOOL_TITLE, 'Actions Responder'); + } + + protected body(): string | DataFormatter { + const isServiceRunning = this.emulatorContext.getActionResponderService().isRunning; + const actionsAndStatus = new ColumnLayoutFormatter([ + new ChoiceMenuFormatter([isServiceRunning ? 'Stop Service' : 'Start Service']), + `Status: ${new RunServiceStatus(isServiceRunning).output}`, + ]); + + return `Service checks for new Endpoint Actions and automatically responds to them. + The following tokens can be used in the Action request 'comment' to drive + the type of response that is sent: + Token Description + --------------------------- ------------------------------------ + RESPOND.STATE=SUCCESS Respond with success + RESPOND.STATE=FAILURE Respond with failure + RESPOND.FLEET.STATE=SUCCESS Respond to Fleet Action with success + RESPOND.FLEET.STATE=FAILURE Respond to Fleet Action with failure + +${blue(HORIZONTAL_LINE.substring(0, HORIZONTAL_LINE.length - 2))} + ${actionsAndStatus.output}`; + } + + protected onEnterChoice(choice: string) { + const choiceValue = choice.trim().toUpperCase(); + + switch (choiceValue) { + case 'Q': + this.hide(); + return; + + case '1': + { + const actionsResponderService = this.emulatorContext.getActionResponderService(); + const isRunning = actionsResponderService.isRunning; + if (isRunning) { + actionsResponderService.stop(); + } else { + actionsResponderService.start(); + } + } + this.reRender(); + return; + } + + this.throwUnknownChoiceError(choice); + } +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/components/run_service_status_formatter.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/components/run_service_status_formatter.ts new file mode 100644 index 00000000000000..273c323e56e409 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/components/run_service_status_formatter.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 { bgCyan, red, dim } from 'chalk'; +import type { BaseRunningService } from '../../../common/base_running_service'; +import { DataFormatter } from '../../../common/screen'; + +export class RunServiceStatus extends DataFormatter { + constructor(private readonly serviceOrIsRunning: boolean | BaseRunningService) { + super(); + } + + protected getOutput(): string { + const isRunning = + typeof this.serviceOrIsRunning === 'boolean' + ? this.serviceOrIsRunning + : this.serviceOrIsRunning.isRunning; + + if (isRunning) { + return bgCyan(' Running '); + } + + return dim(red(' Stopped ')); + } +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/load_endpoints.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/load_endpoints.ts new file mode 100644 index 00000000000000..894a668b6fb72f --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/load_endpoints.ts @@ -0,0 +1,224 @@ +/* + * 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. + */ + +/* eslint-disable require-atomic-updates */ + +import { blue, green } from 'chalk'; +import type { DistinctQuestion } from 'inquirer'; +import type { LoadEndpointsConfig } from '../types'; +import { loadEndpoints } from '../services/endpoint_loader'; +import type { EmulatorRunContext } from '../services/emulator_run_context'; +import { ProgressFormatter } from '../../common/screen/progress_formatter'; +import type { DataFormatter } from '../../common/screen'; +import { ChoiceMenuFormatter, ScreenBaseClass } from '../../common/screen'; +import { TOOL_TITLE } from '../constants'; + +interface LoadOptions { + count: number; + progress: ProgressFormatter; + isRunning: boolean; + isDone: boolean; +} + +const promptQuestion = ( + options: DistinctQuestion +): DistinctQuestion => { + const question: DistinctQuestion = { + type: 'input', + name: 'Unknown?', + message: 'Unknown?', + // @ts-expect-error unclear why this is not defined in the definition file + askAnswered: true, + prefix: green(' ==> '), + ...options, + }; + + if (question.default === undefined) { + question.default = (answers: TAnswers) => { + return answers[(question.name ?? '-') as keyof TAnswers] ?? ''; + }; + } + + return question; +}; + +export class LoadEndpointsScreen extends ScreenBaseClass { + private runInfo: LoadOptions | undefined = undefined; + private choices: ChoiceMenuFormatter = new ChoiceMenuFormatter([ + { + title: 'Run', + key: '1', + }, + { + title: 'Configure', + key: '2', + }, + ]); + private config: LoadEndpointsConfig = { count: 2 }; + + constructor(private readonly emulatorContext: EmulatorRunContext) { + super(); + } + + private async loadSettings(): Promise { + const allSettings = await this.emulatorContext.getSettingsService().get(); + + this.config = allSettings.endpointLoader; + } + + private async saveSettings(): Promise { + const settingsService = this.emulatorContext.getSettingsService(); + + const allSettings = await settingsService.get(); + await settingsService.save({ + ...allSettings, + endpointLoader: this.config, + }); + } + + protected header() { + return super.header(TOOL_TITLE, 'Endpoint loader'); + } + + protected body(): string | DataFormatter { + if (this.runInfo) { + if (this.runInfo.isDone) { + return this.doneView(); + } + + return this.loadingView(); + } + + return this.mainView(); + } + + protected onEnterChoice(choice: string) { + const choiceValue = choice.trim().toUpperCase(); + + switch (choiceValue) { + case 'Q': + this.hide(); + return; + + case '1': + this.runInfo = { + count: this.config.count, + progress: new ProgressFormatter(), + isRunning: false, + isDone: false, + }; + + this.reRender(); + this.loadEndpoints(); + return; + + case '2': + this.configView(); + return; + + default: + if (!choiceValue) { + if (this.runInfo?.isDone) { + this.runInfo = undefined; + this.reRender(); + return; + } + } + + this.throwUnknownChoiceError(choice); + } + } + + private async configView() { + this.config = await this.prompt({ + questions: [ + promptQuestion({ + type: 'number', + name: 'count', + message: 'How many endpoints to load?', + validate(input: number, answers): boolean | string { + if (!Number.isFinite(input)) { + return 'Enter valid number'; + } + return true; + }, + filter(input: number): number | string { + if (Number.isNaN(input)) { + return ''; + } + return input; + }, + }), + ], + answers: this.config, + title: blue('Endpoint Loader Settings'), + }); + + await this.saveSettings(); + this.reRender(); + } + + private async loadEndpoints() { + const runInfo = this.runInfo; + + if (runInfo && !runInfo.isDone && !runInfo.isRunning) { + runInfo.isRunning = true; + + await loadEndpoints({ + count: runInfo.count, + esClient: this.emulatorContext.getEsClient(), + kbnClient: this.emulatorContext.getKbnClient(), + log: this.emulatorContext.getLogger(), + onProgress: (progress) => { + runInfo.progress.setProgress(progress.percent); + this.reRender(); + }, + }); + + runInfo.isDone = true; + runInfo.isRunning = false; + this.reRender(); + } + } + + private mainView(): string | DataFormatter { + return ` + Generate and load endpoints into elasticsearch along with associated + fleet agents. Current settings: + + Count: ${this.config.count} + + Options: + ${this.choices.output}`; + } + + private loadingView(): string | DataFormatter { + if (this.runInfo) { + return ` + + Creating ${this.runInfo.count} endpoint(s): + + ${this.runInfo.progress.output} +`; + } + + return 'Unknown state'; + } + + private doneView(): string { + return `${this.loadingView()} + + Done. Endpoint(s) have been loaded into Elastic/Kibana. + Press Enter to continue +`; + } + + async show(options: Partial<{ prompt: string; resume: boolean }> = {}): Promise { + await this.loadSettings(); + return super.show(options); + } +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/main.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/main.ts new file mode 100644 index 00000000000000..0c8722be6706a7 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/screens/main.ts @@ -0,0 +1,98 @@ +/* + * 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 { ActionResponderScreen } from './actions_responder'; +import { SCREEN_ROW_MAX_WIDTH } from '../../common/screen/constants'; +import { ColumnLayoutFormatter } from '../../common/screen/column_layout_formatter'; +import type { EmulatorRunContext } from '../services/emulator_run_context'; +import { LoadEndpointsScreen } from './load_endpoints'; +import { TOOL_TITLE } from '../constants'; +import { ScreenBaseClass, ChoiceMenuFormatter } from '../../common/screen'; +import type { DataFormatter } from '../../common/screen/data_formatter'; +import { RunServiceStatus } from './components/run_service_status_formatter'; + +export class MainScreen extends ScreenBaseClass { + private readonly loadEndpointsScreen: LoadEndpointsScreen; + private readonly actionsResponderScreen: ActionResponderScreen; + + private actionColumnWidthPrc = 30; + private runningStateColumnWidthPrc = 70; + + constructor(private readonly emulatorContext: EmulatorRunContext) { + super(); + this.loadEndpointsScreen = new LoadEndpointsScreen(this.emulatorContext); + this.actionsResponderScreen = new ActionResponderScreen(this.emulatorContext); + } + + protected header(title: string = '', subTitle: string = ''): string | DataFormatter { + return super.header(TOOL_TITLE); + } + + protected body(): string | DataFormatter { + return `\n${ + new ColumnLayoutFormatter([this.getMenuOptions(), this.runStateView()], { + widths: [this.actionColumnWidthPrc, this.runningStateColumnWidthPrc], + }).output + }`; + } + + private getMenuOptions(): ChoiceMenuFormatter { + return new ChoiceMenuFormatter(['Load endpoints', 'Actions Responder']); + } + + private runStateView(): ColumnLayoutFormatter { + const context = this.emulatorContext; + + return new ColumnLayoutFormatter( + [ + ['Agent Keep Alive Service', 'Actions Responder Service'].join('\n'), + [ + new RunServiceStatus(context.getAgentKeepAliveService()).output, + new RunServiceStatus(context.getActionResponderService()).output, + ].join('\n'), + ], + { + rowLength: Math.floor(SCREEN_ROW_MAX_WIDTH * (this.runningStateColumnWidthPrc / 100)), + separator: ': ', + widths: [70, 30], + } + ); + } + + protected footer(): string | DataFormatter { + return super.footer([ + { + key: 'E', + title: 'Exit', + }, + ]); + } + + protected onEnterChoice(choice: string) { + switch (choice.toUpperCase().trim()) { + // Load endpoints + case '1': + this.pause(); + this.loadEndpointsScreen.show({ resume: true }).then(() => { + this.show({ resume: true }); + }); + return; + + case '2': + this.pause(); + this.actionsResponderScreen.show({ resume: true }).then(() => { + this.show({ resume: true }); + }); + return; + case 'E': + this.hide(); + return; + } + + this.throwUnknownChoiceError(choice); + } +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/action_responder.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/action_responder.ts new file mode 100644 index 00000000000000..93816e06bb7fbd --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/action_responder.ts @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { set } from 'lodash'; +import type { Client } from '@elastic/elasticsearch'; +import type { ToolingLog } from '@kbn/tooling-log'; +import type { KbnClient } from '@kbn/test'; +import { BaseRunningService } from '../../common/base_running_service'; +import { + fetchEndpointActionList, + sendEndpointActionResponse, + sendFleetActionResponse, +} from './endpoint_response_actions'; +import type { ActionDetails } from '../../../../common/endpoint/types'; + +/** + * Base class for start/stopping background services + */ +export class ActionResponderService extends BaseRunningService { + private readonly delay: number; + + constructor( + esClient: Client, + kbnClient: KbnClient, + logger?: ToolingLog, + intervalMs?: number, + delay: number = 5_000 // 5s + ) { + super(esClient, kbnClient, logger, intervalMs); + this.delay = delay; + } + + protected async run(): Promise { + const { logger: log, kbnClient, esClient, delay } = this; + + let hasMore = true; + let nextPage = 1; + + try { + while (hasMore) { + const { data: actions } = await fetchEndpointActionList(kbnClient, { + page: nextPage++, + pageSize: 100, + }); + + if (actions.length === 0) { + hasMore = false; + return; + } + + for (const action of actions) { + if (action.isCompleted === false) { + if (Date.now() - new Date(action.startedAt).getTime() >= delay) { + log.verbose( + `${this.logPrefix}.run() [${new Date().toLocaleTimeString()}]: Responding to [${ + action.command + }] action [id: ${action.id}] agent: [${action.agents.join(', ')}]` + ); + + const tokens = parseCommentTokens(getActionComment(action)); + + log.verbose(`${this.logPrefix}.run() tokens found in action:`, tokens); + + const fleetResponse = await sendFleetActionResponse(esClient, action, { + // If an Endpoint state token was found, then force the Fleet response to `success` + // so that we can actually generate an endpoint response below. + state: tokens.state ? 'success' : tokens.fleet.state, + }); + + // If not a fleet response error, then also sent the Endpoint Response + if (!fleetResponse.error) { + await sendEndpointActionResponse(esClient, action, { state: tokens.state }); + } + } + } + } + } + } catch (e) { + log.error(`${this.logPrefix}.run() ${e.message}. Run with '--verbose' option to see more`); + log.verbose(e); + } + } +} + +interface CommentTokens { + state: 'success' | 'failure' | undefined; + fleet: { + state: 'success' | 'failure' | undefined; + }; +} + +const parseCommentTokens = (comment: string): CommentTokens => { + const response: CommentTokens = { + state: undefined, + fleet: { + state: undefined, + }, + }; + + if (comment) { + const findTokensRegExp = /(respond\.\S*=\S*)/gi; + let matches; + + while ((matches = findTokensRegExp.exec(comment)) !== null) { + const [key, value] = matches[0] + .toLowerCase() + .split('=') + .map((s) => s.trim()); + + set(response, key.split('.').slice(1), value); + } + } + return response; +}; + +const getActionComment = (action: ActionDetails): string => { + return action.comment ?? ''; +}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/agent_keep_alive.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/agent_keep_alive.ts new file mode 100644 index 00000000000000..1d0a94df9e7af9 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/agent_keep_alive.ts @@ -0,0 +1,71 @@ +/* + * 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 { checkInFleetAgent } from '../../common/fleet_services'; +import { + fetchEndpointMetadataList, + sendEndpointMetadataUpdate, +} from '../../common/endpoint_metadata_services'; +import { BaseRunningService } from '../../common/base_running_service'; + +export class AgentKeepAliveService extends BaseRunningService { + protected async run(): Promise { + const { logger: log, kbnClient, esClient } = this; + + let hasMore = true; + let page = 0; + let errorFound = 0; + + try { + do { + const endpoints = await fetchEndpointMetadataList(kbnClient, { + page: page++, + pageSize: 100, + }); + + if (endpoints.data.length === 0) { + hasMore = false; + } else { + if (endpoints.page === 0) { + log.verbose( + `${this.logPrefix}.run() Number of endpoints to process: ${endpoints.total}` + ); + } + + for (const endpoint of endpoints.data) { + await Promise.all([ + checkInFleetAgent(esClient, endpoint.metadata.elastic.agent.id, { + log, + }).catch((err) => { + log.verbose(err); + errorFound++; + return Promise.resolve(); + }), + sendEndpointMetadataUpdate(esClient, endpoint.metadata.agent.id).catch((err) => { + log.verbose(err); + errorFound++; + return Promise.resolve(); + }), + ]); + } + } + } while (hasMore); + } catch (err) { + log.error( + `${this.logPrefix}.run() Error: ${err.message}. Use the '--verbose' option to see more.` + ); + + log.verbose(err); + } + + if (errorFound > 0) { + log.error( + `${this.logPrefix}.run() Error: Encountered ${errorFound} error(s). Use the '--verbose' option to see more.` + ); + } + } +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/emulator_run_context.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/emulator_run_context.ts new file mode 100644 index 00000000000000..3c4978cd4447c1 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/emulator_run_context.ts @@ -0,0 +1,155 @@ +/* + * 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. + */ + +/* eslint-disable @typescript-eslint/no-non-null-assertion */ + +import type { KbnClient } from '@kbn/test'; +import type { Client } from '@elastic/elasticsearch'; +import type { ToolingLog } from '@kbn/tooling-log'; +import type { AgentEmulatorSettings } from '../types'; +import { SettingsStorage } from '../../common/settings_storage'; +import { AgentKeepAliveService } from './agent_keep_alive'; +import { ActionResponderService } from './action_responder'; +import { createRuntimeServices } from '../../common/stack_services'; + +export interface EmulatorRunContextConstructorOptions { + username: string; + password: string; + kibanaUrl: string; + elasticsearchUrl: string; + actionResponseDelay: number; + checkinInterval: number; + asSuperuser?: boolean; + log?: ToolingLog; +} + +export class EmulatorRunContext { + private esClient: Client | undefined = undefined; + private kbnClient: KbnClient | undefined = undefined; + private wasStarted: boolean = false; + private agentKeepAliveService: AgentKeepAliveService | undefined = undefined; + private actionResponderService: ActionResponderService | undefined = undefined; + + private readonly username: string; + private readonly password: string; + private readonly kibanaUrl: string; + private readonly elasticsearchUrl: string; + private readonly actionResponseDelay: number; + private readonly checkinInterval: number; + private readonly asSuperuser: boolean = false; + private log: ToolingLog | undefined = undefined; + private settings: SettingsStorage | undefined = undefined; + + constructor(options: EmulatorRunContextConstructorOptions) { + this.username = options.username; + this.password = options.password; + this.kibanaUrl = options.kibanaUrl; + this.elasticsearchUrl = options.elasticsearchUrl; + this.actionResponseDelay = options.actionResponseDelay; + this.checkinInterval = options.checkinInterval; + this.asSuperuser = options.asSuperuser ?? false; + this.log = options.log; + } + + async start() { + if (this.wasStarted) { + return; + } + + this.settings = new SettingsStorage('endpoint_agent_emulator.json', { + defaultSettings: { + version: 1, + endpointLoader: { + count: 2, + }, + }, + }); + + const { esClient, kbnClient, log } = await createRuntimeServices({ + kibanaUrl: this.kibanaUrl, + elasticsearchUrl: this.elasticsearchUrl, + username: this.username, + password: this.password, + asSuperuser: this.asSuperuser, + log: this.log, + }); + + this.esClient = esClient; + this.kbnClient = kbnClient; + this.log = log; + + this.agentKeepAliveService = new AgentKeepAliveService( + esClient, + kbnClient, + log, + this.checkinInterval + ); + this.agentKeepAliveService.start(); + + this.actionResponderService = new ActionResponderService( + esClient, + kbnClient, + log, + 5_000, // Check for actions every 5s + this.actionResponseDelay + ); + this.actionResponderService.start(); + + this.wasStarted = true; + } + + async stop(): Promise { + this.getAgentKeepAliveService().stop(); + this.getActionResponderService().stop(); + this.wasStarted = false; + } + + protected ensureStarted() { + if (!this.wasStarted) { + throw new Error('RunContext instance has not been `.start()`ed!'); + } + } + + public get whileRunning(): Promise { + this.ensureStarted(); + + return Promise.all([ + this.getActionResponderService().whileRunning, + this.getAgentKeepAliveService().whileRunning, + ]).then(() => {}); + } + + getSettingsService(): SettingsStorage { + this.ensureStarted(); + return this.settings!; + } + + getActionResponderService(): ActionResponderService { + this.ensureStarted(); + return this.actionResponderService!; + } + + getAgentKeepAliveService(): AgentKeepAliveService { + this.ensureStarted(); + return this.agentKeepAliveService!; + } + + getEsClient(): Client { + this.ensureStarted(); + return this.esClient!; + } + + getKbnClient(): KbnClient { + this.ensureStarted(); + return this.kbnClient!; + } + + getLogger(): ToolingLog { + this.ensureStarted(); + return this.log!; + } +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_loader.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_loader.ts new file mode 100644 index 00000000000000..ccf252c4d551ae --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_loader.ts @@ -0,0 +1,182 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable max-classes-per-file */ + +import type { Client } from '@elastic/elasticsearch'; +import type { KbnClient } from '@kbn/test'; +import pMap from 'p-map'; +import type { CreatePackagePolicyResponse } from '@kbn/fleet-plugin/common'; +import type { ToolingLog } from '@kbn/tooling-log'; +import type seedrandom from 'seedrandom'; +import { kibanaPackageJson } from '@kbn/utils'; +import { indexAlerts } from '../../../../common/endpoint/data_loaders/index_alerts'; +import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; +import { fetchEndpointMetadataList } from '../../common/endpoint_metadata_services'; +import { indexEndpointHostDocs } from '../../../../common/endpoint/data_loaders/index_endpoint_hosts'; +import { setupFleetForEndpoint } from '../../../../common/endpoint/data_loaders/setup_fleet_for_endpoint'; +import { enableFleetServerIfNecessary } from '../../../../common/endpoint/data_loaders/index_fleet_server'; +import { fetchEndpointPackageInfo } from '../../common/fleet_services'; +import { METADATA_DATASTREAM } from '../../../../common/endpoint/constants'; +import { EndpointMetadataGenerator } from '../../../../common/endpoint/data_generators/endpoint_metadata_generator'; +import { ENDPOINT_ALERTS_INDEX, ENDPOINT_EVENTS_INDEX } from '../../common/constants'; + +let WAS_FLEET_SETUP_DONE = false; + +const CurrentKibanaVersionDocGenerator = class extends EndpointDocGenerator { + constructor(seedValue: string | seedrandom.prng) { + const MetadataGenerator = class extends EndpointMetadataGenerator { + protected randomVersion(): string { + return kibanaPackageJson.version; + } + }; + + super(seedValue, MetadataGenerator); + } +}; + +export const loadEndpointsIfNoneExist = async ( + esClient: Client, + kbnClient: KbnClient, + log?: ToolingLog, + count: number = 2 +): Promise => { + if (!count || (await fetchEndpointMetadataList(kbnClient, { pageSize: 1 })).total > 0) { + if (log) { + log.verbose('loadEndpointsIfNoneExist(): Endpoints exist. Exiting (nothing was done)'); + } + + return; + } + + return loadEndpoints({ + count: 2, + esClient, + kbnClient, + log, + }); +}; + +interface LoadEndpointsProgress { + percent: number; + total: number; + created: number; +} + +interface LoadEndpointsOptions { + esClient: Client; + kbnClient: KbnClient; + count?: number; + log?: ToolingLog; + onProgress?: (percentDone: LoadEndpointsProgress) => void; + DocGeneratorClass?: typeof EndpointDocGenerator; +} + +/** + * Loads endpoints, including the corresponding fleet agent, into Kibana along with events and alerts + * + * @param count + * @param esClient + * @param kbnClient + * @param log + * @param onProgress + * @param DocGeneratorClass + */ +export const loadEndpoints = async ({ + esClient, + kbnClient, + log, + onProgress, + count = 2, + DocGeneratorClass = CurrentKibanaVersionDocGenerator, +}: LoadEndpointsOptions): Promise => { + if (log) { + log.verbose(`loadEndpoints(): Loading ${count} endpoints...`); + } + + if (!WAS_FLEET_SETUP_DONE) { + await setupFleetForEndpoint(kbnClient); + await enableFleetServerIfNecessary(esClient); + // eslint-disable-next-line require-atomic-updates + WAS_FLEET_SETUP_DONE = true; + } + + const endpointPackage = await fetchEndpointPackageInfo(kbnClient); + const realPolicies: Record = {}; + + let progress: LoadEndpointsProgress = { + total: count, + created: 0, + percent: 0, + }; + + const updateProgress = () => { + const created = progress.created + 1; + progress = { + ...progress, + created, + percent: Math.ceil((created / count) * 100), + }; + + if (onProgress) { + onProgress(progress); + } + }; + + await pMap( + Array.from({ length: count }), + async () => { + const endpointGenerator = new DocGeneratorClass(); + + await indexEndpointHostDocs({ + numDocs: 1, + client: esClient, + kbnClient, + realPolicies, + epmEndpointPackage: endpointPackage, + generator: endpointGenerator, + enrollFleet: true, + metadataIndex: METADATA_DATASTREAM, + policyResponseIndex: 'metrics-endpoint.policy-default', + }); + + await indexAlerts({ + client: esClient, + generator: endpointGenerator, + eventIndex: ENDPOINT_EVENTS_INDEX, + alertIndex: ENDPOINT_ALERTS_INDEX, + numAlerts: 1, + options: { + ancestors: 3, + generations: 3, + children: 3, + relatedEvents: 5, + relatedAlerts: 5, + percentWithRelated: 30, + percentTerminated: 30, + alwaysGenMaxChildrenPerNode: false, + ancestryArraySize: 2, + eventsDataStream: { + type: 'logs', + dataset: 'endpoint.events.process', + namespace: 'default', + }, + alertsDataStream: { type: 'logs', dataset: 'endpoint.alerts', namespace: 'default' }, + }, + }); + + updateProgress(); + }, + { + concurrency: 4, + } + ); + + if (log) { + log.verbose(`loadEndpoints(): ${count} endpoint(s) successfully loaded`); + } +}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/action_responder/utils.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_response_actions.ts similarity index 77% rename from x-pack/plugins/security_solution/scripts/endpoint/action_responder/utils.ts rename to x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_response_actions.ts index 57c8ecf7f7243f..8a1aa4050d07bc 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/action_responder/utils.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_response_actions.ts @@ -8,15 +8,16 @@ import type { KbnClient } from '@kbn/test'; import type { Client } from '@elastic/elasticsearch'; import { AGENT_ACTIONS_RESULTS_INDEX } from '@kbn/fleet-plugin/common'; -import type { UploadedFile } from '../../../common/endpoint/types/file_storage'; -import { sendEndpointMetadataUpdate } from '../common/endpoint_metadata_services'; -import { FleetActionGenerator } from '../../../common/endpoint/data_generators/fleet_action_generator'; +import type { UploadedFile } from '../../../../common/endpoint/types/file_storage'; +import { checkInFleetAgent } from '../../common/fleet_services'; +import { sendEndpointMetadataUpdate } from '../../common/endpoint_metadata_services'; +import { FleetActionGenerator } from '../../../../common/endpoint/data_generators/fleet_action_generator'; import { ENDPOINT_ACTION_RESPONSES_INDEX, ENDPOINTS_ACTION_LIST_ROUTE, FILE_STORAGE_DATA_INDEX, FILE_STORAGE_METADATA_INDEX, -} from '../../../common/endpoint/constants'; +} from '../../../../common/endpoint/constants'; import type { ActionDetails, ActionListApiResponse, @@ -26,9 +27,9 @@ import type { GetProcessesActionOutputContent, ResponseActionGetFileOutputContent, ResponseActionGetFileParameters, -} from '../../../common/endpoint/types'; -import type { EndpointActionListRequestQuery } from '../../../common/endpoint/schema/actions'; -import { EndpointActionGenerator } from '../../../common/endpoint/data_generators/endpoint_action_generator'; +} from '../../../../common/endpoint/types'; +import type { EndpointActionListRequestQuery } from '../../../../common/endpoint/schema/actions'; +import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator'; const ES_INDEX_OPTIONS = { headers: { 'X-elastic-product-origin': 'fleet' } }; @@ -42,13 +43,33 @@ export const fetchEndpointActionList = async ( kbn: KbnClient, options: EndpointActionListRequestQuery = {} ): Promise => { - return ( - await kbn.request({ - method: 'GET', - path: ENDPOINTS_ACTION_LIST_ROUTE, - query: options, - }) - ).data; + try { + return ( + await kbn.request({ + method: 'GET', + path: ENDPOINTS_ACTION_LIST_ROUTE, + query: options, + }) + ).data; + } catch (error) { + // FIXME: remove once the Action List API is fixed (task #5221) + if (error?.response?.status === 404) { + return { + data: [], + total: 0, + page: 1, + pageSize: 10, + startDate: undefined, + elasticAgentIds: undefined, + endDate: undefined, + userIds: undefined, + commands: undefined, + statuses: undefined, + }; + } + + throw error; + } }; export const sendFleetActionResponse = async ( @@ -125,26 +146,34 @@ export const sendEndpointActionResponse = async ( // For isolate, If the response is not an error, then also send a metadata update if (action.command === 'isolate' && !endpointResponse.error) { for (const agentId of action.agents) { - await sendEndpointMetadataUpdate(esClient, agentId, { - Endpoint: { - state: { - isolation: true, + await Promise.all([ + sendEndpointMetadataUpdate(esClient, agentId, { + Endpoint: { + state: { + isolation: true, + }, }, - }, - }); + }), + + checkInFleetAgent(esClient, agentId), + ]); } } // For UnIsolate, if response is not an Error, then also send metadata update if (action.command === 'unisolate' && !endpointResponse.error) { for (const agentId of action.agents) { - await sendEndpointMetadataUpdate(esClient, agentId, { - Endpoint: { - state: { - isolation: false, + await Promise.all([ + sendEndpointMetadataUpdate(esClient, agentId, { + Endpoint: { + state: { + isolation: false, + }, }, - }, - }); + }), + + checkInFleetAgent(esClient, agentId), + ]); } } diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/types.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/types.ts new file mode 100644 index 00000000000000..5713f28c4916d6 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/types.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface AgentEmulatorSettings { + /** Version of the settings. Can be used in the future if we need to do settings migration */ + version: number; + endpointLoader: LoadEndpointsConfig; +} + +export interface LoadEndpointsConfig { + count: number; +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/base_running_service.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/base_running_service.ts new file mode 100644 index 00000000000000..e195d7e1f60caa --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/base_running_service.ts @@ -0,0 +1,108 @@ +/* + * 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 { ToolingLog } from '@kbn/tooling-log'; +import type { KbnClient } from '@kbn/test'; +import type { Client } from '@elastic/elasticsearch'; +import moment from 'moment'; + +/** + * A base class for creating a service that runs on a interval + */ +export class BaseRunningService { + private nextRunId: ReturnType | undefined; + private markRunComplete: (() => void) | undefined; + + protected wasStarted = false; + + /** Promise that remains pending while the service is running */ + public whileRunning: Promise = Promise.resolve(); + + protected readonly logPrefix: string; + + constructor( + protected readonly esClient: Client, + protected readonly kbnClient: KbnClient, + protected readonly logger: ToolingLog = new ToolingLog(), + protected readonly intervalMs: number = 30_000 // 30s + ) { + this.logPrefix = this.constructor.name ?? 'BaseRunningService'; + this.logger.verbose(`${this.logPrefix} run interval: [ ${this.intervalMs} ]`); + } + + public get isRunning(): boolean { + return this.wasStarted; + } + + start() { + if (this.wasStarted) { + return; + } + + this.wasStarted = true; + this.whileRunning = new Promise((resolve) => { + this.markRunComplete = () => resolve(); + }); + + this.logger.verbose(`${this.logPrefix}: started at ${new Date().toISOString()}`); + + this.run().finally(() => { + this.scheduleNextRun(); + }); + } + + stop() { + if (this.wasStarted) { + this.clearNextRun(); + this.wasStarted = false; + + if (this.markRunComplete) { + this.markRunComplete(); + this.markRunComplete = undefined; + } + + this.logger.verbose(`${this.logPrefix}: stopped at ${new Date().toISOString()}`); + } + } + + protected scheduleNextRun() { + this.clearNextRun(); + + if (this.wasStarted) { + this.nextRunId = setTimeout(async () => { + const startedAt = new Date(); + + await this.run(); + + const endedAt = new Date(); + + this.logger.verbose( + `${this.logPrefix}.run(): completed in ${moment + .duration(moment(endedAt).diff(startedAt, 'seconds')) + .as('seconds')}s` + ); + this.logger.indent(4, () => { + this.logger.verbose(`started at: ${startedAt.toISOString()}`); + this.logger.verbose(`ended at: ${startedAt.toISOString()}`); + }); + + this.scheduleNextRun(); + }, this.intervalMs); + } + } + + protected clearNextRun() { + if (this.nextRunId) { + clearTimeout(this.nextRunId); + this.nextRunId = undefined; + } + } + + protected async run(): Promise { + throw new Error(`${this.logPrefix}.run() not implemented!`); + } +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/constants.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/constants.ts new file mode 100644 index 00000000000000..1d2b3d5f47784b --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/constants.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. + */ + +export const HORIZONTAL_LINE = '-'.repeat(80); + +export const ENDPOINT_EVENTS_INDEX = 'logs-endpoint.events.process-default'; + +export const ENDPOINT_ALERTS_INDEX = 'logs-endpoint.alerts-default'; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/endpoint_metadata_services.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/endpoint_metadata_services.ts index 2a51c57de8bc4f..a1f21b80567d62 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/common/endpoint_metadata_services.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/endpoint_metadata_services.ts @@ -6,31 +6,62 @@ */ import type { Client } from '@elastic/elasticsearch'; +import type { KbnClient } from '@kbn/test'; import type { WriteResponseBase } from '@elastic/elasticsearch/lib/api/types'; import { clone, merge } from 'lodash'; import type { DeepPartial } from 'utility-types'; -import { METADATA_DATASTREAM } from '../../../common/endpoint/constants'; -import type { HostMetadata } from '../../../common/endpoint/types'; +import type { GetMetadataListRequestQuery } from '../../../common/endpoint/schema/metadata'; +import { resolvePathVariables } from '../../../public/common/utils/resolve_path_variables'; +import { + HOST_METADATA_GET_ROUTE, + HOST_METADATA_LIST_ROUTE, + METADATA_DATASTREAM, +} from '../../../common/endpoint/constants'; +import type { HostInfo, HostMetadata, MetadataListResponse } from '../../../common/endpoint/types'; import { EndpointDocGenerator } from '../../../common/endpoint/generate_data'; -import { checkInFleetAgent } from './fleet_services'; const endpointGenerator = new EndpointDocGenerator(); +export const fetchEndpointMetadata = async ( + kbnClient: KbnClient, + agentId: string +): Promise => { + return ( + await kbnClient.request({ + method: 'GET', + path: resolvePathVariables(HOST_METADATA_GET_ROUTE, { id: agentId }), + }) + ).data; +}; + +export const fetchEndpointMetadataList = async ( + kbnClient: KbnClient, + { page = 0, pageSize = 100, ...otherOptions }: Partial = {} +): Promise => { + return ( + await kbnClient.request({ + method: 'GET', + path: HOST_METADATA_LIST_ROUTE, + query: { + page, + pageSize, + ...otherOptions, + }, + }) + ).data; +}; + export const sendEndpointMetadataUpdate = async ( esClient: Client, agentId: string, - overrides: DeepPartial = {}, - { checkInAgent = true }: Partial<{ checkInAgent: boolean }> = {} + overrides: DeepPartial = {} ): Promise => { const lastStreamedDoc = await fetchLastStreamedEndpointUpdate(esClient, agentId); if (!lastStreamedDoc) { - throw new Error(`An endpoint with agent.id of [${agentId}] not found!`); - } - - if (checkInAgent) { - // Trigger an agent checkin and just let it run - checkInFleetAgent(esClient, agentId); + throw new Error( + `An endpoint with agent.id of [${agentId}] not found! [sendEndpointMetadataUpdate()]` + ); } const generatedHostMetadataDoc = clone(endpointGenerator.generateHostMetadata()); diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/fleet_services.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/fleet_services.ts index 3d7d3c5a031728..c94b66d68a5da1 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/common/fleet_services.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/fleet_services.ts @@ -5,23 +5,81 @@ * 2.0. */ -import type { Client } from '@elastic/elasticsearch'; -import { AGENTS_INDEX } from '@kbn/fleet-plugin/common'; +import type { Client, estypes } from '@elastic/elasticsearch'; +import { AGENTS_INDEX, EPM_API_ROUTES } from '@kbn/fleet-plugin/common'; +import type { AgentStatus, GetPackagesResponse } from '@kbn/fleet-plugin/common'; +import { pick } from 'lodash'; +import { ToolingLog } from '@kbn/tooling-log'; +import type { AxiosResponse } from 'axios'; +import type { KbnClient } from '@kbn/test'; +import { FleetAgentGenerator } from '../../../common/endpoint/data_generators/fleet_agent_generator'; -export const checkInFleetAgent = async (esClient: Client, agentId: string) => { - const checkinNow = new Date().toISOString(); +const fleetGenerator = new FleetAgentGenerator(); - await esClient.update({ +export const checkInFleetAgent = async ( + esClient: Client, + agentId: string, + { + agentStatus = 'online', + log = new ToolingLog(), + }: Partial<{ + /** The agent status to be sent. If set to `random`, then one will be randomly generated */ + agentStatus: AgentStatus | 'random'; + log: ToolingLog; + }> = {} +): Promise => { + const fleetAgentStatus = + agentStatus === 'random' ? fleetGenerator.randomAgentStatus() : agentStatus; + + const update = pick(fleetGenerator.generateEsHitWithStatus(fleetAgentStatus)._source, [ + 'last_checkin_status', + 'last_checkin', + 'active', + 'unenrollment_started_at', + 'unenrolled_at', + 'upgrade_started_at', + 'upgraded_at', + ]); + + // WORKAROUND: Endpoint API will exclude metadata for any fleet agent whose status is `inactive`, + // which means once we update the Fleet agent with that status, the metadata api will no longer + // return the endpoint host info.'s. So - we avoid that here. + update.active = true; + + // Ensure any `undefined` value is set to `null` for the update + Object.entries(update).forEach(([key, value]) => { + if (value === undefined) { + // @ts-expect-error TS7053 Element implicitly has an 'any' type + update[key] = null; + } + }); + + log.verbose(`update to fleet agent [${agentId}][${agentStatus} / ${fleetAgentStatus}]: `, update); + + return esClient.update({ index: AGENTS_INDEX, id: agentId, refresh: 'wait_for', retry_on_conflict: 5, body: { - doc: { - active: true, - last_checkin: checkinNow, - updated_at: checkinNow, - }, + doc: update, }, }); }; + +export const fetchEndpointPackageInfo = async ( + kbnClient: KbnClient +): Promise => { + const endpointPackage = ( + (await kbnClient.request({ + path: `${EPM_API_ROUTES.LIST_PATTERN}?category=security`, + method: 'GET', + })) as AxiosResponse + ).data.items.find((epmPackage) => epmPackage.name === 'endpoint'); + + if (!endpointPackage) { + throw new Error('EPM Endpoint package was not found!'); + } + + return endpointPackage; +}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/screen/choice_menu_formatter.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/choice_menu_formatter.ts new file mode 100644 index 00000000000000..a1348ad719b034 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/choice_menu_formatter.ts @@ -0,0 +1,61 @@ +/* + * 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 { green } from 'chalk'; +import { isChoice } from './type_gards'; +import type { Choice } from './types'; +import { DataFormatter } from './data_formatter'; + +type ChoiceMenuFormatterItems = string[] | Choice[]; + +interface ChoiceMenuFormatterOptions { + layout: 'vertical' | 'horizontal'; +} + +const getDefaultOptions = (): ChoiceMenuFormatterOptions => { + return { + layout: 'vertical', + }; +}; + +/** + * Formatter for displaying lists of choices + */ +export class ChoiceMenuFormatter extends DataFormatter { + private readonly outputContent: string; + + constructor( + private readonly choiceList: ChoiceMenuFormatterItems, + private readonly options: ChoiceMenuFormatterOptions = getDefaultOptions() + ) { + super(); + + const list = this.buildList(); + + this.outputContent = `${list.join(this.options.layout === 'horizontal' ? ' ' : '\n')}`; + } + + protected getOutput(): string { + return this.outputContent; + } + + private buildList(): string[] { + return this.choiceList.map((choice, index) => { + let key: string = `${index + 1}`; + let title: string = ''; + + if (isChoice(choice)) { + key = choice.key; + title = choice.title; + } else { + title = choice; + } + + return green(`[${key}] `) + title; + }); + } +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/screen/column_layout_formatter.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/column_layout_formatter.ts new file mode 100644 index 00000000000000..ab07ddd5355342 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/column_layout_formatter.ts @@ -0,0 +1,87 @@ +/* + * 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 stripAnsi from 'strip-ansi'; +// eslint-disable-next-line import/no-extraneous-dependencies +import ansiRegex from 'ansi-regex'; // its a dependency of `strip-ansi` so it should be fine +import { blue } from 'chalk'; +import { DataFormatter } from './data_formatter'; +import { SCREEN_ROW_MAX_WIDTH } from './constants'; + +interface ColumnLayoutFormatterOptions { + /** + * The width (percentage) for each of the columns. Example: 80 (for 80%). + */ + widths?: number[]; + + /** The column separator */ + separator?: string; + + /** The max length for each screen row. Defaults to the overall screen width */ + rowLength?: number; +} + +export class ColumnLayoutFormatter extends DataFormatter { + private readonly defaultSeparator = ` ${blue('\u2506')} `; + + constructor( + private readonly columns: Array, + private readonly options: ColumnLayoutFormatterOptions = {} + ) { + super(); + } + + protected getOutput(): string { + const colSeparator = this.options.separator ?? this.defaultSeparator; + let rowCount = 0; + const columnData: string[][] = this.columns.map((item) => { + const itemOutput = (typeof item === 'string' ? item : item.output).split('\n'); + + rowCount = Math.max(rowCount, itemOutput.length); + + return itemOutput; + }); + const columnSizes = this.calculateColumnSizes(); + let output = ''; + + let row = 0; + while (row < rowCount) { + const rowIndex = row++; + + output += `${columnData + .map((columnDataRows, colIndex) => { + return this.fillColumnToWidth(columnDataRows[rowIndex] ?? '', columnSizes[colIndex]); + }) + .join(colSeparator)}`; + + if (row !== rowCount) { + output += '\n'; + } + } + + return output; + } + + private calculateColumnSizes(): number[] { + const maxWidth = this.options.rowLength ?? SCREEN_ROW_MAX_WIDTH; + const widths = this.options.widths ?? []; + const defaultWidthPrct = Math.floor(100 / this.columns.length); + + return this.columns.map((_, colIndex) => { + return Math.floor(maxWidth * ((widths[colIndex] ?? defaultWidthPrct) / 100)); + }); + } + + private fillColumnToWidth(colData: string, width: number) { + const countOfControlChar = (colData.match(ansiRegex()) || []).length; + const colDataNoControlChar = stripAnsi(colData); + const colDataFilled = colDataNoControlChar.padEnd(width).substring(0, width); + const fillCount = colDataFilled.length - colDataNoControlChar.length - countOfControlChar; + + return colData + (fillCount > 0 ? ' '.repeat(fillCount) : ''); + } +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/screen/common_choices.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/common_choices.ts new file mode 100644 index 00000000000000..12cd636e07d81c --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/common_choices.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Choice } from './types'; + +/** + * The Quit choice definition + */ +export const QuitChoice: Choice = { + key: 'Q', + title: 'Quit', +} as const; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/screen/constants.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/constants.ts new file mode 100644 index 00000000000000..a1c3eab88314bf --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/constants.ts @@ -0,0 +1,10 @@ +/* + * 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 { HORIZONTAL_LINE } from '../constants'; + +export const SCREEN_ROW_MAX_WIDTH = HORIZONTAL_LINE.length; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/screen/data_formatter.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/data_formatter.ts new file mode 100644 index 00000000000000..6f02d21d561279 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/data_formatter.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * Base class for screen data formatters + */ +export class DataFormatter { + /** + * Must be defiened by Subclasses + * @protected + */ + protected getOutput(): string { + throw new Error(`${this.constructor.name}.getOutput() not implemented!`); + } + + public get output(): string { + return this.getOutput(); + } +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/screen/index.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/index.ts new file mode 100644 index 00000000000000..29f289b4d241f0 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ScreenBaseClass } from './screen_base_class'; +export { ChoiceMenuFormatter } from './choice_menu_formatter'; +export { DataFormatter } from './data_formatter'; +export * from './types'; +export * from './type_gards'; +export * from './common_choices'; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/screen/progress_formatter.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/progress_formatter.ts new file mode 100644 index 00000000000000..c5a09d02b472c7 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/progress_formatter.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 { green } from 'chalk'; +import { SCREEN_ROW_MAX_WIDTH } from './constants'; +import { DataFormatter } from './data_formatter'; + +const MAX_WIDTH = SCREEN_ROW_MAX_WIDTH - 14; + +export class ProgressFormatter extends DataFormatter { + private percentDone: number = 0; + + public setProgress(percentDone: number) { + this.percentDone = percentDone; + } + + protected getOutput(): string { + const prctDone = Math.min(100, this.percentDone); + const repeatValue = Math.ceil(MAX_WIDTH * (prctDone / 100)); + const progressPrct = `${prctDone}%`; + + return `[ ${'='.repeat(repeatValue).padEnd(MAX_WIDTH)} ] ${ + prctDone === 100 ? green(progressPrct) : progressPrct + }`; + } +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/screen/screen_base_class.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/screen_base_class.ts new file mode 100644 index 00000000000000..32657bac374fe4 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/screen_base_class.ts @@ -0,0 +1,364 @@ +/* + * 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. + */ + +/* eslint-disable max-classes-per-file */ + +import type { WriteStream as TtyWriteStream } from 'tty'; +import { stdin, stdout } from 'node:process'; +import * as readline from 'node:readline'; +import { blue, green, red, bold, cyan } from 'chalk'; +import type { QuestionCollection } from 'inquirer'; +import inquirer from 'inquirer'; +import { QuitChoice } from './common_choices'; +import type { Choice } from './types'; +import { ChoiceMenuFormatter } from './choice_menu_formatter'; +import { DataFormatter } from './data_formatter'; +import { HORIZONTAL_LINE } from '../constants'; +import { SCREEN_ROW_MAX_WIDTH } from './constants'; + +const CONTENT_60_PERCENT = Math.floor(SCREEN_ROW_MAX_WIDTH * 0.6); +const CONTENT_40_PERCENT = Math.floor(SCREEN_ROW_MAX_WIDTH * 0.4); + +/** + * Base class for creating a CLI screen. + * + * @example + * + * // Screen definition + * export class FooScreen extends ScreenBaseClass { + * protected body() { + * return `this is a test screen` + * } + * + * protected onEnterChoice(choice) { + * if (choice.toUpperCase() === 'Q') { + * this.hide(); + * return; + * } + * + * this.throwUnknownChoiceError(choice); + * } + * } + * + * // Using the screen + * await new FooScreen().show() + */ +export class ScreenBaseClass { + private readonly ttyOut: TtyWriteStream = stdout; + private readlineInstance: readline.Interface | undefined = undefined; + private showPromise: Promise | undefined = undefined; + private endSession: (() => void) | undefined = undefined; + private screenRenderInfo: RenderedScreen | undefined; + private isPaused: boolean = false; + private isHidden: boolean = true; + private autoClearMessageId: undefined | NodeJS.Timeout = undefined; + + /** + * Provides content for the header of the screen. + * + * @param title Displayed on the left side of the header area + * @param subTitle Displayed to the right of the header + * @protected + */ + protected header(title: string = '', subTitle: string = ''): string | DataFormatter { + const paddedTitle = title ? ` ${title}`.padEnd(CONTENT_60_PERCENT) : ''; + const paddedSubTitle = subTitle ? `| ${`${subTitle} `.padStart(CONTENT_40_PERCENT - 2)}` : ''; + + return title || subTitle + ? `${blue(HORIZONTAL_LINE)}\n${blue(bold(paddedTitle))}${ + subTitle ? `${cyan(paddedSubTitle)}` : '' + }\n${blue(HORIZONTAL_LINE)}\n` + : `${blue(HORIZONTAL_LINE)}\n`; + } + + /** + * Provides content for the footer of the screen + * + * @param choices Optional list of choices for display above the footer. + * @protected + */ + protected footer(choices: Choice[] = [QuitChoice]): string | DataFormatter { + const displayChoices = + choices && choices.length + ? `${this.leftPad(new ChoiceMenuFormatter(choices, { layout: 'horizontal' }).output)}\n` + : ''; + + return ` +${displayChoices}${blue(HORIZONTAL_LINE)}`; + } + + /** + * Content for the Body area of the screen + * + * @protected + */ + protected body(): string | DataFormatter { + return '\n\n(This screen has no content)\n\n'; + } + + /** + * Should be defined by the subclass to handle user selections. If the user's + * selection is invalid, this method should `throw` and `Error` - the message + * will be displayed in the screen and the user will be asked for input again. + * + * @param choice + * @protected + */ + protected onEnterChoice(choice: string) { + if (choice.toUpperCase() === 'Q') { + this.hide(); + return; + } + + throw new Error(`${this.constructor.name}.onEnterChoice() not implemented!`); + } + + /** + * Throw an error indicating invalid choice was made by the user. + * @param choice + * @protected + */ + protected throwUnknownChoiceError(choice: string): never { + throw new Error(`Unknown choice: ${choice}`); + } + + protected getOutputContent(item: string | DataFormatter): string { + return item instanceof DataFormatter ? item.output : item; + } + + private closeReadline() { + if (this.readlineInstance) { + this.readlineInstance.close(); + this.readlineInstance = undefined; + } + } + + protected leftPad(content: string, padWith: string = ' ') { + return content + .split('\n') + .map((contentLine) => { + if (!contentLine.startsWith(padWith)) { + return padWith + contentLine; + } + return contentLine; + }) + .join('\n'); + } + + public showMessage( + message: string, + color: 'blue' | 'red' | 'green' = 'blue', + autoClear: boolean = false + ) { + const { screenRenderInfo, ttyOut } = this; + + if (this.autoClearMessageId) { + clearTimeout(this.autoClearMessageId); + this.autoClearMessageId = undefined; + } + + if (screenRenderInfo) { + ttyOut.cursorTo(0, screenRenderInfo.statusPos); + ttyOut.clearLine(0); + + let coloredMessage = message; + + switch (color) { + case 'green': + coloredMessage = green(`\u2713 ${message}`); + break; + case 'red': + coloredMessage = red(`\u24e7 ${message}`); + break; + + case 'blue': + coloredMessage = blue(`\u24d8 ${message}`); + break; + } + + ttyOut.write(` ${coloredMessage}`); + + if (autoClear) { + this.autoClearMessageId = setTimeout(() => { + this.showMessage(''); + }, 4000); + } + } + } + + private clearPromptOutput() { + const { ttyOut, screenRenderInfo } = this; + + if (screenRenderInfo) { + ttyOut.cursorTo(0, screenRenderInfo.promptPos ?? 0); + ttyOut.clearScreenDown(); + } + } + + private async askForChoice(prompt?: string): Promise { + this.closeReadline(); + this.clearPromptOutput(); + + return new Promise((resolve) => { + const rl = readline.createInterface({ input: stdin, output: stdout }); + this.readlineInstance = rl; + + // TODO:PT experiment with using `rl.prompt()` instead of `question()` and possibly only initialize `rl` once + + rl.question(green(prompt ?? 'Enter choice: '), (selection) => { + if (this.isPaused || this.isHidden) { + return; + } + + if (this.readlineInstance === rl) { + this.clearPromptOutput(); + this.closeReadline(); + + try { + this.onEnterChoice(selection); + } catch (error) { + this.showMessage(error.message, 'red'); + + resolve(this.askForChoice(prompt)); + + return; + } + + resolve(); + } + }); + }); + } + + private clearScreen() { + this.ttyOut.cursorTo(0, 0); + this.ttyOut.clearScreenDown(); + } + + /** + * Renders (or re-renders) the screen. Can be called multiple times + * + * @param prompt + */ + public reRender(prompt?: string) { + if (this.isHidden || this.isPaused) { + return; + } + + const { ttyOut } = this; + const headerContent = this.header(); + const bodyContent = this.body(); + const footerContent = this.footer(); + + const screenRenderInfo = new RenderedScreen( + this.getOutputContent(headerContent) + + this.leftPad(this.getOutputContent(bodyContent)) + + this.getOutputContent(footerContent) + ); + this.screenRenderInfo = screenRenderInfo; + + this.clearScreen(); + + ttyOut.write(screenRenderInfo.output); + + this.askForChoice(prompt); + } + + /** + * Will display the screen and return a promise that is resolved once the screen is hidden. + * + * @param prompt + * @param resume + */ + public show({ + prompt, + resume, + }: Partial<{ prompt: string; resume: boolean }> = {}): Promise { + if (resume) { + this.isPaused = false; + } + + if (this.isPaused) { + return Promise.resolve(undefined); + } + + this.isHidden = false; + this.reRender(prompt); + + // `show()` can be called multiple times, so only create the `showPromise` if one is not already present + if (!this.showPromise) { + this.showPromise = new Promise((resolve) => { + this.endSession = () => resolve(); + }); + } + + return this.showPromise; + } + + /** + * Will hide the screen and fulfill the promise returned by `.show()` + */ + public hide() { + this.closeReadline(); + this.clearScreen(); + this.screenRenderInfo = undefined; + this.isHidden = true; + this.isPaused = false; + + if (this.endSession) { + this.endSession(); + this.showPromise = undefined; + this.endSession = undefined; + } + } + + public pause() { + this.isPaused = true; + this.closeReadline(); + } + + public async prompt({ + questions, + answers = {}, + title = blue('Settings:'), + }: { + questions: QuestionCollection; + answers?: Partial; + title?: string; + }): Promise { + if (this.isPaused || this.isHidden) { + return answers as TAnswers; + } + + const screenRenderInfo = new RenderedScreen(this.getOutputContent(this.header())); + + this.screenRenderInfo = screenRenderInfo; + this.clearScreen(); + this.ttyOut.write(`${screenRenderInfo.output}${title ? `${this.leftPad(title)}\n` : ''}`); + + const ask = inquirer.createPromptModule(); + const newAnswers = await ask(questions, answers); + + return newAnswers; + } +} + +class RenderedScreen { + public statusPos: number = -1; + public promptPos: number = -1; + public statusMessage: string | undefined = undefined; + + constructor(private readonly screenOutput: string) { + const outputBottomPos = screenOutput.split('\n').length - 1; + this.statusPos = outputBottomPos + 1; + this.promptPos = this.statusPos + 1; + } + + public get output(): string { + return `${this.screenOutput}\n${this.statusMessage ?? ' '}\n`; + } +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/screen/type_gards.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/type_gards.ts new file mode 100644 index 00000000000000..e9fd23d56293d4 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/type_gards.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Choice } from './types'; + +/** + * Type guard that checks if a item is a `Choice` + * + * @param item + */ +export const isChoice = (item: string | object): item is Choice => { + return 'string' !== typeof item && 'key' in item && 'title' in item; +}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/screen/types.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/types.ts new file mode 100644 index 00000000000000..815403528dca9b --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/screen/types.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * An item representing a choice/item to be shown on a screen + */ +export interface Choice { + /** The keyboard key (or combination of keys) that the user will enter to select this choice */ + key: string; + /** The title of the choice */ + title: string; +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/settings_storage.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/settings_storage.ts new file mode 100644 index 00000000000000..d68da4bfc92b6f --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/settings_storage.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 { homedir } from 'os'; +import { join } from 'path'; +import { mkdir, writeFile, readFile, unlink } from 'fs/promises'; +import { existsSync } from 'fs'; + +interface SettingStorageOptions { + /** The default directory where settings will be saved. Defaults to `~/.kibanaSecuritySolutionCliTools` */ + directory?: string; + + /** The default settings object (used if file does not exist yet) */ + defaultSettings?: TSettingsDef; +} + +/** + * A generic service for persisting settings. By default, all settings are saved to a directory + * under `~/.kibanaSecuritySolutionCliTools` + */ +export class SettingsStorage { + private options: Required>; + private readonly settingsFileFullPath: string; + private dirExists: boolean = false; + + constructor(fileName: string, options: SettingStorageOptions = {}) { + const { + directory = join(homedir(), '.kibanaSecuritySolutionCliTools'), + defaultSettings = {} as TSettingsDef, + } = options; + + this.options = { + directory, + defaultSettings, + }; + + this.settingsFileFullPath = join(this.options.directory, fileName); + } + + private async ensureExists(): Promise { + if (!this.dirExists) { + await mkdir(this.options.directory, { recursive: true }); + this.dirExists = true; + + if (!existsSync(this.settingsFileFullPath)) { + await this.save(this.options.defaultSettings); + } + } + } + + /** Retrieve the content of the settings file */ + public async get(): Promise { + await this.ensureExists(); + const fileContent = await readFile(this.settingsFileFullPath); + return JSON.parse(fileContent.toString()) as TSettingsDef; + } + + /** Save a new version of the settings to disk */ + public async save(newSettings: TSettingsDef): Promise { + // FIXME: Enhance this method so that Partial `newSettings` can be provided and they are merged into the existing set. + await this.ensureExists(); + await writeFile(this.settingsFileFullPath, JSON.stringify(newSettings, null, 2)); + } + + /** Deletes the settings file from disk */ + public async delete(): Promise { + await this.ensureExists(); + await unlink(this.settingsFileFullPath); + } +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/endpoint_action_responder.js b/x-pack/plugins/security_solution/scripts/endpoint/endpoint_agent_emulator.js similarity index 89% rename from x-pack/plugins/security_solution/scripts/endpoint/endpoint_action_responder.js rename to x-pack/plugins/security_solution/scripts/endpoint/endpoint_agent_emulator.js index 3617b8d0d5b323..9eacefa57a2b9f 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/endpoint_action_responder.js +++ b/x-pack/plugins/security_solution/scripts/endpoint/endpoint_agent_emulator.js @@ -6,4 +6,4 @@ */ require('../../../../../src/setup_node_env'); -require('./action_responder').cli(); +require('./agent_emulator').cli(); diff --git a/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator_script.ts b/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator_script.ts index a871151ed0b0d3..b6c1c3a266d9b9 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator_script.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator_script.ts @@ -14,10 +14,12 @@ import { CA_CERT_PATH } from '@kbn/dev-utils'; import { ToolingLog } from '@kbn/tooling-log'; import type { KbnClientOptions } from '@kbn/test'; import { KbnClient } from '@kbn/test'; +import { METADATA_DATASTREAM } from '../../common/endpoint/constants'; import { EndpointMetadataGenerator } from '../../common/endpoint/data_generators/endpoint_metadata_generator'; import { indexHostsAndAlerts } from '../../common/endpoint/index_data'; import { ANCESTRY_LIMIT, EndpointDocGenerator } from '../../common/endpoint/generate_data'; import { fetchStackVersion } from './common/stack_services'; +import { ENDPOINT_ALERTS_INDEX, ENDPOINT_EVENTS_INDEX } from './common/constants'; main(); @@ -130,19 +132,19 @@ async function main() { eventIndex: { alias: 'ei', describe: 'index to store events in', - default: 'logs-endpoint.events.process-default', + default: ENDPOINT_EVENTS_INDEX, type: 'string', }, alertIndex: { alias: 'ai', describe: 'index to store alerts in', - default: 'logs-endpoint.alerts-default', + default: ENDPOINT_ALERTS_INDEX, type: 'string', }, metadataIndex: { alias: 'mi', describe: 'index to store host metadata in', - default: 'metrics-endpoint.metadata-default', + default: METADATA_DATASTREAM, type: 'string', }, policyIndex: { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rule_exceptions_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rule_exceptions_route.ts index 531a33b253084e..8d1dc2cc7c49e2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rule_exceptions_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rule_exceptions_route.ts @@ -56,14 +56,18 @@ export const findRuleExceptionReferencesRoute = (router: SecuritySolutionPluginR } const fetchExact = ids != null && listIds != null; - const foundExceptionLists = await listsClient?.findExceptionList({ filter: fetchExact ? `(${listIds - .map((listId) => `exception-list.attributes.list_id:${listId}`) + .map( + (listId, index) => + `${getSavedObjectType({ + namespaceType: namespaceTypes[index], + })}.attributes.list_id:${listId}` + ) .join(' OR ')})` : undefined, - namespaceType: ['agnostic', 'single'], + namespaceType: namespaceTypes, page: 1, perPage: 10000, sortField: undefined, diff --git a/x-pack/plugins/synthetics/common/constants/rest_api.ts b/x-pack/plugins/synthetics/common/constants/rest_api.ts index 0f43ff654c77e2..33bd608d8f5ec0 100644 --- a/x-pack/plugins/synthetics/common/constants/rest_api.ts +++ b/x-pack/plugins/synthetics/common/constants/rest_api.ts @@ -48,6 +48,6 @@ export enum API_URLS { SYNTHETICS_HAS_ZIP_URL_MONITORS = '/internal/uptime/fleet/has_zip_url_monitors', // Project monitor public endpoint - SYNTHETICS_MONITORS_PROJECT_LEGACY = '/api/synthetics/service/project/monitors', SYNTHETICS_MONITORS_PROJECT = '/api/synthetics/project/{projectName}/monitors', + SYNTHETICS_MONITORS_PROJECT_LEGACY = '/api/synthetics/service/project/monitors', } diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/last_ten_test_runs.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/last_ten_test_runs.tsx index a424a831c97ff9..8d48a45a391c3c 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/last_ten_test_runs.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/last_ten_test_runs.tsx @@ -118,6 +118,7 @@ export const LastTenTestRuns = () => { }, ]; + const historyIdParam = monitor?.[ConfigKey.CUSTOM_HEARTBEAT_ID] ?? monitor?.[ConfigKey.ID]; return ( @@ -133,7 +134,8 @@ export const LastTenTestRuns = () => { iconType="list" iconSide="left" data-test-subj="monitorSummaryViewLastTestRun" - href={`${basePath}/app/uptime/monitor/${btoa(monitor?.id ?? '')}`} + disabled={!historyIdParam} + href={`${basePath}/app/uptime/monitor/${btoa(historyIdParam ?? '')}`} > {i18n.translate('xpack.synthetics.monitorDetails.summary.viewHistory', { defaultMessage: 'View History', diff --git a/x-pack/plugins/synthetics/server/routes/common.ts b/x-pack/plugins/synthetics/server/routes/common.ts index 84a1a294f27e64..3ca580fb2da826 100644 --- a/x-pack/plugins/synthetics/server/routes/common.ts +++ b/x-pack/plugins/synthetics/server/routes/common.ts @@ -51,9 +51,9 @@ export const getMonitors = ( const locationFilter = parseLocationFilter(syntheticsService.locations, locations); const filters = - getFilter('tags', tags) + - getFilter('type', monitorType) + - getFilter('locations.id', locationFilter); + getKqlFilter('tags', tags) + + getKqlFilter('type', monitorType) + + getKqlFilter('locations.id', locationFilter); return savedObjectsClient.find({ type: syntheticsMonitorType, @@ -69,7 +69,7 @@ export const getMonitors = ( }); }; -const getFilter = (field: string, values?: string | string[], operator = 'OR') => { +export const getKqlFilter = (field: string, values?: string | string[], operator = 'OR') => { if (!values) { return ''; } diff --git a/x-pack/plugins/synthetics/server/routes/index.ts b/x-pack/plugins/synthetics/server/routes/index.ts index 8cff5dc88c9187..c1c06215c8c168 100644 --- a/x-pack/plugins/synthetics/server/routes/index.ts +++ b/x-pack/plugins/synthetics/server/routes/index.ts @@ -18,6 +18,7 @@ import { getSyntheticsMonitorOverviewRoute, getSyntheticsMonitorRoute, } from './monitor_cruds/get_monitor'; +import { deleteSyntheticsMonitorProjectRoute } from './monitor_cruds/delete_monitor_project'; import { getSyntheticsProjectMonitorsRoute } from './monitor_cruds/get_monitor_project'; import { runOnceSyntheticsMonitorRoute } from './synthetics_service/run_once_monitor'; import { getServiceAllowedRoute } from './synthetics_service/get_service_allowed'; @@ -38,6 +39,7 @@ export const syntheticsAppRestApiRoutes: SyntheticsRestApiRouteFactory[] = [ addSyntheticsMonitorRoute, getSyntheticsEnablementRoute, deleteSyntheticsMonitorRoute, + deleteSyntheticsMonitorProjectRoute, disableSyntheticsRoute, editSyntheticsMonitorRoute, enableSyntheticsRoute, diff --git a/x-pack/plugins/synthetics/server/routes/monitor_cruds/bulk_cruds/delete_monitor_bulk.ts b/x-pack/plugins/synthetics/server/routes/monitor_cruds/bulk_cruds/delete_monitor_bulk.ts index c156c73342cc10..862bab6915f36a 100644 --- a/x-pack/plugins/synthetics/server/routes/monitor_cruds/bulk_cruds/delete_monitor_bulk.ts +++ b/x-pack/plugins/synthetics/server/routes/monitor_cruds/bulk_cruds/delete_monitor_bulk.ts @@ -10,7 +10,13 @@ import { formatTelemetryDeleteEvent, sendTelemetryEvents, } from '../../telemetry/monitor_upgrade_sender'; -import { ConfigKey, MonitorFields, SyntheticsMonitor } from '../../../../common/runtime_types'; +import { + ConfigKey, + MonitorFields, + SyntheticsMonitor, + EncryptedSyntheticsMonitor, + EncryptedSyntheticsMonitorWithId, +} from '../../../../common/runtime_types'; import { UptimeServerSetup } from '../../../legacy_uptime/lib/adapters'; import { SyntheticsMonitorClient } from '../../../synthetics_service/synthetics_monitor/synthetics_monitor_client'; import { syntheticsMonitorType } from '../../../../common/types/saved_objects'; @@ -24,7 +30,7 @@ export const deleteMonitorBulk = async ({ }: { savedObjectsClient: SavedObjectsClientContract; server: UptimeServerSetup; - monitors: Array>; + monitors: Array>; syntheticsMonitorClient: SyntheticsMonitorClient; request: KibanaRequest; }) => { @@ -35,10 +41,8 @@ export const deleteMonitorBulk = async ({ const deleteSyncPromise = syntheticsMonitorClient.deleteMonitors( monitors.map((normalizedMonitor) => ({ ...normalizedMonitor.attributes, - id: - (normalizedMonitor.attributes as MonitorFields)[ConfigKey.CUSTOM_HEARTBEAT_ID] || - normalizedMonitor.id, - })), + id: normalizedMonitor.attributes[ConfigKey.CUSTOM_HEARTBEAT_ID] || normalizedMonitor.id, + })) as EncryptedSyntheticsMonitorWithId[], request, savedObjectsClient, spaceId diff --git a/x-pack/plugins/synthetics/server/routes/monitor_cruds/delete_monitor_project.ts b/x-pack/plugins/synthetics/server/routes/monitor_cruds/delete_monitor_project.ts new file mode 100644 index 00000000000000..3deb7a1be2170b --- /dev/null +++ b/x-pack/plugins/synthetics/server/routes/monitor_cruds/delete_monitor_project.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { schema } from '@kbn/config-schema'; +import { i18n } from '@kbn/i18n'; +import { ConfigKey } from '../../../common/runtime_types'; +import { SyntheticsRestApiRouteFactory } from '../../legacy_uptime/routes/types'; +import { API_URLS } from '../../../common/constants'; +import { syntheticsMonitorType } from '../../legacy_uptime/lib/saved_objects/synthetics_monitor'; +import { getMonitors, getKqlFilter } from '../common'; +import { INSUFFICIENT_FLEET_PERMISSIONS } from '../../synthetics_service/project_monitor/project_monitor_formatter'; +import { deleteMonitorBulk } from './bulk_cruds/delete_monitor_bulk'; + +export const deleteSyntheticsMonitorProjectRoute: SyntheticsRestApiRouteFactory = () => ({ + method: 'DELETE', + path: API_URLS.SYNTHETICS_MONITORS_PROJECT, + validate: { + body: schema.object({ + monitors: schema.arrayOf(schema.string()), + }), + params: schema.object({ + projectName: schema.string(), + }), + }, + handler: async ({ + request, + response, + savedObjectsClient, + server, + syntheticsMonitorClient, + }): Promise => { + const { projectName } = request.params; + const { monitors: monitorsToDelete } = request.body; + const decodedProjectName = decodeURI(projectName); + if (monitorsToDelete.length > 250) { + return response.badRequest({ + body: { + message: REQUEST_TOO_LARGE, + }, + }); + } + + const { saved_objects: monitors } = await getMonitors( + { + filter: `${syntheticsMonitorType}.attributes.${ + ConfigKey.PROJECT_ID + }: "${decodedProjectName}" AND ${getKqlFilter( + 'journey_id', + monitorsToDelete.map((id: string) => `"${id}"`) + )}`, + fields: [], + perPage: 500, + }, + syntheticsMonitorClient.syntheticsService, + savedObjectsClient + ); + + const { + integrations: { writeIntegrationPolicies }, + } = await server.fleet.authz.fromRequest(request); + + const hasPrivateMonitor = monitors.some((monitor) => + monitor.attributes.locations.some((location) => !location.isServiceManaged) + ); + + if (!writeIntegrationPolicies && hasPrivateMonitor) { + return response.forbidden({ + body: { + message: INSUFFICIENT_FLEET_PERMISSIONS, + }, + }); + } + + await deleteMonitorBulk({ + monitors, + server, + savedObjectsClient, + syntheticsMonitorClient, + request, + }); + + return { + deleted_monitors: monitorsToDelete, + }; + }, +}); + +export const REQUEST_TOO_LARGE = i18n.translate('xpack.synthetics.server.project.delete.toolarge', { + defaultMessage: + 'Delete request payload is too large. Please send a max of 250 monitors to delete per request', +}); diff --git a/x-pack/plugins/synthetics/server/synthetics_service/project_monitor/project_monitor_formatter.ts b/x-pack/plugins/synthetics/server/synthetics_service/project_monitor/project_monitor_formatter.ts index 16cbf3f33a8aaa..6139b2fbe7959b 100644 --- a/x-pack/plugins/synthetics/server/synthetics_service/project_monitor/project_monitor_formatter.ts +++ b/x-pack/plugins/synthetics/server/synthetics_service/project_monitor/project_monitor_formatter.ts @@ -6,13 +6,14 @@ */ import type { Subject } from 'rxjs'; import { isEqual } from 'lodash'; +import pMap from 'p-map'; import { KibanaRequest } from '@kbn/core/server'; import { SavedObjectsUpdateResponse, SavedObjectsClientContract, SavedObjectsFindResult, } from '@kbn/core/server'; -import pMap from 'p-map'; +import { i18n } from '@kbn/i18n'; import { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; import { syncNewMonitorBulk } from '../../routes/monitor_cruds/bulk_cruds/add_monitor_bulk'; import { deleteMonitorBulk } from '../../routes/monitor_cruds/bulk_cruds/delete_monitor_bulk'; @@ -50,8 +51,13 @@ interface StaleMonitor { type StaleMonitorMap = Record; type FailedError = Array<{ id?: string; reason: string; details: string; payload?: object }>; -export const INSUFFICIENT_FLEET_PERMISSIONS = - 'Insufficient permissions. In order to configure private locations, you must have Fleet and Integrations write permissions. To resolve, please generate a new API key with a user who has Fleet and Integrations write permissions.'; +export const INSUFFICIENT_FLEET_PERMISSIONS = i18n.translate( + 'xpack.synthetics.service.projectMonitors.insufficientFleetPermissions', + { + defaultMessage: + 'Insufficient permissions. In order to configure private locations, you must have Fleet and Integrations write permissions. To resolve, please generate a new API key with a user who has Fleet and Integrations write permissions.', + } +); export class ProjectMonitorFormatter { private projectId: string; diff --git a/x-pack/plugins/synthetics/server/synthetics_service/synthetics_monitor/synthetics_monitor_client.ts b/x-pack/plugins/synthetics/server/synthetics_service/synthetics_monitor/synthetics_monitor_client.ts index 8af7fca704ab05..8218a6f0b3baf1 100644 --- a/x-pack/plugins/synthetics/server/synthetics_service/synthetics_monitor/synthetics_monitor_client.ts +++ b/x-pack/plugins/synthetics/server/synthetics_service/synthetics_monitor/synthetics_monitor_client.ts @@ -13,6 +13,7 @@ import { ConfigKey, MonitorFields, SyntheticsMonitorWithId, + EncryptedSyntheticsMonitorWithId, HeartbeatConfig, PrivateLocation, EncryptedSyntheticsMonitor, @@ -120,19 +121,23 @@ export class SyntheticsMonitorClient { } } async deleteMonitors( - monitors: SyntheticsMonitorWithId[], + monitors: Array, request: KibanaRequest, savedObjectsClient: SavedObjectsClientContract, spaceId: string ) { + /* Type cast encrypted saved objects to decrypted saved objects for delete flow only. + * Deletion does not require all monitor fields */ const privateDeletePromise = this.privateLocationAPI.deleteMonitors( - monitors, + monitors as SyntheticsMonitorWithId[], request, savedObjectsClient, spaceId ); - const publicDeletePromise = this.syntheticsService.deleteConfigs(monitors); + const publicDeletePromise = this.syntheticsService.deleteConfigs( + monitors as SyntheticsMonitorWithId[] + ); const [pubicResponse] = await Promise.all([publicDeletePromise, privateDeletePromise]); return pubicResponse; diff --git a/x-pack/plugins/timelines/common/types/timeline/actions/index.ts b/x-pack/plugins/timelines/common/types/timeline/actions/index.ts index 4cac68268abcc9..cde9b04d0e707f 100644 --- a/x-pack/plugins/timelines/common/types/timeline/actions/index.ts +++ b/x-pack/plugins/timelines/common/types/timeline/actions/index.ts @@ -92,7 +92,6 @@ export interface BulkActionsProps { onUpdateSuccess?: OnUpdateAlertStatusSuccess; onUpdateFailure?: OnUpdateAlertStatusError; customBulkActions?: CustomBulkActionProp[]; - scopeId?: string; } export interface HeaderActionProps { diff --git a/x-pack/plugins/timelines/public/components/index.tsx b/x-pack/plugins/timelines/public/components/index.tsx index 296e7841aa3791..cc8446eea14113 100644 --- a/x-pack/plugins/timelines/public/components/index.tsx +++ b/x-pack/plugins/timelines/public/components/index.tsx @@ -6,18 +6,16 @@ */ import React from 'react'; -import { Provider } from 'react-redux'; import { I18nProvider } from '@kbn/i18n-react'; import type { Store } from 'redux'; import { Storage } from '@kbn/kibana-utils-plugin/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import { createStore } from '../store/t_grid'; +import { Provider } from 'react-redux'; import { TGrid as TGridComponent } from './t_grid'; import type { TGridProps } from '../types'; import { DragDropContextWrapper } from './drag_and_drop'; -import { initialTGridState } from '../store/t_grid/reducer'; import type { TGridIntegratedProps } from './t_grid/integrated'; const EMPTY_BROWSER_FIELDS = {}; @@ -31,18 +29,13 @@ type TGridComponent = TGridProps & { export const TGrid = (props: TGridComponent) => { const { store, storage, setStore, ...tGridProps } = props; - let tGridStore = store; - if (!tGridStore && props.type === 'standalone') { - tGridStore = createStore(initialTGridState, storage); - setStore(tGridStore); - } let browserFields = EMPTY_BROWSER_FIELDS; if ((tGridProps as TGridIntegratedProps).browserFields != null) { browserFields = (tGridProps as TGridIntegratedProps).browserFields; } return ( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - + diff --git a/x-pack/plugins/timelines/public/components/t_grid/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/index.tsx index 058261e6386cc7..7512edb776398f 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/index.tsx @@ -9,13 +9,10 @@ import React from 'react'; import type { TGridProps } from '../../types'; import { TGridIntegrated, TGridIntegratedProps } from './integrated'; -import { TGridStandalone, TGridStandaloneProps } from './standalone'; export const TGrid = (props: TGridProps) => { const { type, ...componentsProps } = props; - if (type === 'standalone') { - return ; - } else if (type === 'embedded') { + if (type === 'embedded') { return ; } return null; diff --git a/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx deleted file mode 100644 index 035027294f30f2..00000000000000 --- a/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx +++ /dev/null @@ -1,394 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; -import { isEmpty } from 'lodash/fp'; -import React, { useEffect, useMemo } from 'react'; -import styled from 'styled-components'; -import { useDispatch } from 'react-redux'; -import { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import type { Filter, Query } from '@kbn/es-query'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; -import type { CoreStart } from '@kbn/core/public'; -import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import { getEsQueryConfig } from '@kbn/data-plugin/common'; - -import type { Ecs } from '../../../../common/ecs'; -import { Direction, EntityType } from '../../../../common/search_strategy'; -import { TGridCellAction } from '../../../../common/types/timeline'; -import type { - CellValueElementProps, - ColumnHeaderOptions, - ControlColumnProps, - DataProvider, - RowRenderer, - SortColumnTable, - BulkActionsProp, - AlertStatus, -} from '../../../../common/types/timeline'; -import { useDeepEqualSelector } from '../../../hooks/use_selector'; -import { defaultHeaders } from '../body/column_headers/default_headers'; -import { getCombinedFilterQuery } from '../helpers'; -import { tGridActions, tGridSelectors } from '../../../store/t_grid'; -import type { State } from '../../../store/t_grid'; -import { useTimelineEvents } from '../../../container'; -import { StatefulBody } from '../body'; -import { LastUpdatedAt } from '../..'; -import { SELECTOR_TIMELINE_GLOBAL_CONTAINER, UpdatedFlexItem, UpdatedFlexGroup } from '../styles'; -import { InspectButton, InspectButtonContainer } from '../../inspect'; -import { useFetchIndex } from '../../../container/source'; -import { TGridLoading, TGridEmpty, TableContext } from '../shared'; - -const FullWidthFlexGroup = styled(EuiFlexGroup)<{ $visible: boolean }>` - overflow: hidden; - margin: 0; - display: ${({ $visible }) => ($visible ? 'flex' : 'none')}; -`; - -export const EVENTS_VIEWER_HEADER_HEIGHT = 90; // px -export const STANDALONE_ID = 'standalone-t-grid'; -const EMPTY_DATA_PROVIDERS: DataProvider[] = []; - -const TitleText = styled.span` - margin-right: 12px; -`; - -const AlertsTableWrapper = styled.div` - width: 100%; - height: 100%; -`; - -const EventsContainerLoading = styled.div.attrs(({ className = '' }) => ({ - className: `${SELECTOR_TIMELINE_GLOBAL_CONTAINER} ${className}`, -}))` - position: relative; - width: 100%; - overflow: hidden; - flex: 1; - display: flex; - flex-direction: column; -`; - -const ScrollableFlexItem = styled(EuiFlexItem)` - overflow: auto; -`; - -export interface TGridStandaloneProps { - columns: ColumnHeaderOptions[]; - dataViewId?: string | null; - defaultCellActions?: TGridCellAction[]; - deletedEventIds: Readonly; - disabledCellActions: string[]; - end: string; - entityType?: EntityType; - loadingText: React.ReactNode; - filters: Filter[]; - filterStatus?: AlertStatus; - getRowRenderer?: ({ - data, - rowRenderers, - }: { - data: Ecs; - rowRenderers: RowRenderer[]; - }) => RowRenderer | null; - hasAlertsCrudPermissions: ({ - ruleConsumer, - ruleProducer, - }: { - ruleConsumer: string; - ruleProducer?: string; - }) => boolean; - height?: number; - indexNames: string[]; - itemsPerPage?: number; - itemsPerPageOptions: number[]; - query: Query; - onRuleChange?: () => void; - onStateChange?: (state: State) => void; - renderCellValue: (props: CellValueElementProps) => React.ReactNode; - rowRenderers: RowRenderer[]; - runtimeMappings: MappingRuntimeFields; - setRefetch: (ref: () => void) => void; - start: string; - sort: SortColumnTable[]; - graphEventId?: string; - leadingControlColumns: ControlColumnProps[]; - trailingControlColumns: ControlColumnProps[]; - bulkActions?: BulkActionsProp; - data?: DataPublicPluginStart; - unit?: (total: number) => React.ReactNode; - showCheckboxes?: boolean; - queryFields?: string[]; -} - -const TGridStandaloneComponent: React.FC = ({ - columns, - dataViewId = null, - defaultCellActions, - deletedEventIds, - disabledCellActions, - end, - entityType = 'alerts', - loadingText, - filters, - filterStatus, - getRowRenderer, - hasAlertsCrudPermissions, - indexNames, - itemsPerPage, - itemsPerPageOptions, - onRuleChange, - query, - renderCellValue, - rowRenderers, - runtimeMappings, - setRefetch, - start, - sort, - graphEventId, - leadingControlColumns, - trailingControlColumns, - data, - unit, - showCheckboxes = true, - bulkActions = {}, - queryFields = [], -}) => { - const dispatch = useDispatch(); - const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; - const { uiSettings } = useKibana().services; - const [indexPatternsLoading, { browserFields, indexPatterns }] = useFetchIndex(indexNames); - - const getTGrid = useMemo(() => tGridSelectors.getTGridByIdSelector(), []); - const { - itemsPerPage: itemsPerPageStore, - itemsPerPageOptions: itemsPerPageOptionsStore, - queryFields: queryFieldsFromState, - sort: sortStore, - title, - } = useDeepEqualSelector((state) => getTGrid(state, STANDALONE_ID ?? '')); - - const justTitle = useMemo(() => {title}, [title]); - const esQueryConfig = getEsQueryConfig(uiSettings); - - const filterQuery = useMemo( - () => - getCombinedFilterQuery({ - config: esQueryConfig, - browserFields, - dataProviders: EMPTY_DATA_PROVIDERS, - filters, - from: start, - indexPattern: indexPatterns, - kqlMode: 'search', - kqlQuery: query, - to: end, - }), - [esQueryConfig, indexPatterns, browserFields, filters, start, end, query] - ); - - const canQueryTimeline = useMemo( - () => - filterQuery != null && - indexPatternsLoading != null && - !indexPatternsLoading && - !isEmpty(start) && - !isEmpty(end), - [indexPatternsLoading, filterQuery, start, end] - ); - - const fields = useMemo( - () => [ - ...columnsHeader.reduce( - (acc, c) => (c.linkField != null ? [...acc, c.id, c.linkField] : [...acc, c.id]), - [] - ), - ...(queryFieldsFromState ?? []), - ], - [columnsHeader, queryFieldsFromState] - ); - - const sortField = useMemo( - () => - sortStore.map(({ columnId, columnType, esTypes, sortDirection }) => ({ - field: columnId, - type: columnType, - direction: sortDirection as Direction, - esTypes: esTypes ?? [], - })), - [sortStore] - ); - - const [ - loading, - { consumers, events, updatedAt, loadPage, pageInfo, refetch, totalCount = 0, inspect }, - ] = useTimelineEvents({ - dataViewId, - entityType, - excludeEcsData: true, - fields, - filterQuery, - id: STANDALONE_ID, - indexNames, - limit: itemsPerPageStore, - runtimeMappings, - sort: sortField, - startDate: start, - endDate: end, - skip: !canQueryTimeline, - data, - }); - setRefetch(refetch); - - useEffect(() => { - dispatch(tGridActions.updateIsLoading({ id: STANDALONE_ID, isLoading: loading })); - }, [dispatch, loading]); - - const { hasAlertsCrud, totalSelectAllAlerts } = useMemo(() => { - return Object.entries(consumers).reduce<{ - hasAlertsCrud: boolean; - totalSelectAllAlerts: number; - }>( - (acc, [ruleConsumer, nbrAlerts]) => { - const featureHasPermission = hasAlertsCrudPermissions({ ruleConsumer }); - return { - hasAlertsCrud: featureHasPermission || acc.hasAlertsCrud, - totalSelectAllAlerts: featureHasPermission - ? nbrAlerts + acc.totalSelectAllAlerts - : acc.totalSelectAllAlerts, - }; - }, - { - hasAlertsCrud: false, - totalSelectAllAlerts: 0, - } - ); - }, [consumers, hasAlertsCrudPermissions]); - - const totalCountMinusDeleted = useMemo( - () => (totalCount > 0 ? totalCount - deletedEventIds.length : 0), - [deletedEventIds.length, totalCount] - ); - const hasAlerts = totalCountMinusDeleted > 0; - - // Only show the table-spanning loading indicator when the query is loading and we - // don't have data (e.g. for the initial fetch). - // Subsequent fetches (e.g. for pagination) will show a small loading indicator on - // top of the table and the table will display the current page until the next page - // is fetched. This prevents a flicker when paginating. - const showFullLoading = loading && !hasAlerts; - - const nonDeletedEvents = useMemo( - () => events.filter((e) => !deletedEventIds.includes(e._id)), - [deletedEventIds, events] - ); - - useEffect(() => { - dispatch( - tGridActions.createTGrid({ - id: STANDALONE_ID, - columns, - indexNames, - itemsPerPage: itemsPerPage || itemsPerPageStore, - itemsPerPageOptions, - showCheckboxes, - defaultColumns: columns, - sort, - }) - ); - dispatch( - tGridActions.initializeTGridSettings({ - id: STANDALONE_ID, - defaultColumns: columns, - sort, - loadingText, - unit, - queryFields, - }) - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const tableContext = { tableId: STANDALONE_ID }; - - // Clear checkbox selection when new events are fetched - useEffect(() => { - dispatch(tGridActions.clearSelected({ id: STANDALONE_ID })); - dispatch( - tGridActions.setTGridSelectAll({ - id: STANDALONE_ID, - selectAll: false, - }) - ); - }, [nonDeletedEvents, dispatch]); - - return ( - - - {showFullLoading && } - {canQueryTimeline ? ( - - - - - - - - - - - - {!hasAlerts && !loading && } - - {hasAlerts && ( - - - - - - )} - - - ) : null} - - - ); -}; - -export const TGridStandalone = React.memo(TGridStandaloneComponent); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/bulk_actions/alert_bulk_actions.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/bulk_actions/alert_bulk_actions.tsx index ab77bd06e93e1a..f03c9802d44b2c 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/bulk_actions/alert_bulk_actions.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/bulk_actions/alert_bulk_actions.tsx @@ -127,7 +127,6 @@ export const AlertBulkActionsComponent = React.memo { - const { updateAlertStatus } = useUpdateAlertsStatus(scopeId !== STANDALONE_ID); + const { updateAlertStatus } = useUpdateAlertsStatus(true); const { addSuccess, addError, addWarning } = useAppToasts(); const { startTransaction } = useStartTransaction(); diff --git a/x-pack/plugins/timelines/public/methods/index.tsx b/x-pack/plugins/timelines/public/methods/index.tsx index 3605f07cc09a2c..92d6bc5a8bd3b7 100644 --- a/x-pack/plugins/timelines/public/methods/index.tsx +++ b/x-pack/plugins/timelines/public/methods/index.tsx @@ -49,7 +49,7 @@ export const getTGridLazy = ( ) => { initializeStore({ store, storage, setStore }); return ( - }> + }> ); diff --git a/x-pack/plugins/timelines/public/plugin.ts b/x-pack/plugins/timelines/public/plugin.ts index 91439d1928acfe..27ca9de0cf0347 100644 --- a/x-pack/plugins/timelines/public/plugin.ts +++ b/x-pack/plugins/timelines/public/plugin.ts @@ -6,8 +6,6 @@ */ import { Store, Unsubscribe } from 'redux'; -import { throttle } from 'lodash'; - import { Storage } from '@kbn/kibana-utils-plugin/public'; import type { CoreSetup, Plugin, CoreStart } from '@kbn/core/public'; import type { LastUpdatedAtProps, LoadingPanelProps } from './components'; @@ -40,20 +38,6 @@ export class TimelinesPlugin implements Plugin { } }, getTGrid: (props: TGridProps) => { - if (props.type === 'standalone' && this._store) { - const { getState } = this._store; - const state = getState(); - if (state && state.app) { - this._store = undefined; - } else { - if (props.onStateChange) { - this._storeUnsubscribe = this._store.subscribe( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - throttle(() => props.onStateChange!(getState()), 500) - ); - } - } - } return getTGridLazy(props, { store: this._store, storage: this._storage, diff --git a/x-pack/plugins/timelines/public/types.ts b/x-pack/plugins/timelines/public/types.ts index 66808ee40fb867..aec647934c2ade 100644 --- a/x-pack/plugins/timelines/public/types.ts +++ b/x-pack/plugins/timelines/public/types.ts @@ -21,7 +21,6 @@ import type { } from './components'; export type { SortDirection } from '../common/types'; import type { TGridIntegratedProps } from './components/t_grid/integrated'; -import type { TGridStandaloneProps } from './components/t_grid/standalone'; import type { UseAddToTimelineProps, UseAddToTimeline } from './hooks/use_add_to_timeline'; import { HoverActionsConfig } from './components/hover_actions'; export * from './store/t_grid'; @@ -52,19 +51,14 @@ export interface TimelinesStartPlugins { } export type TimelinesStartServices = CoreStart & TimelinesStartPlugins; -interface TGridStandaloneCompProps extends TGridStandaloneProps { - type: 'standalone'; -} interface TGridIntegratedCompProps extends TGridIntegratedProps { type: 'embedded'; } -export type TGridType = 'standalone' | 'embedded'; -export type GetTGridProps = T extends 'standalone' - ? TGridStandaloneCompProps - : T extends 'embedded' +export type TGridType = 'embedded'; +export type GetTGridProps = T extends 'embedded' ? TGridIntegratedCompProps : TGridIntegratedCompProps; -export type TGridProps = TGridStandaloneCompProps | TGridIntegratedCompProps; +export type TGridProps = TGridIntegratedCompProps; export interface StatefulEventContextType { tabType: string | undefined; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 76d8a094a43a6f..0da5af908ac549 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -23587,7 +23587,6 @@ "xpack.observability.formatters.secondsTimeUnitLabel": "s", "xpack.observability.formatters.secondsTimeUnitLabelExtended": "secondes", "xpack.observability.home.addData": "Ajouter des intégrations", - "xpack.observability.hoverActions.filterForValue": "Filtrer sur la valeur", "xpack.observability.hoverActions.filterForValueButtonLabel": "Inclure", "xpack.observability.inspector.stats.dataViewDescription": "La vue de données qui se connecte aux index Elasticsearch.", "xpack.observability.inspector.stats.dataViewLabel": "Vue de données", @@ -25624,7 +25623,6 @@ "xpack.securitySolution.eventsTab.unit": "{totalCount, plural, =1 {alerte externe} other {alertes externes}}", "xpack.securitySolution.eventsViewer.unit": "{totalCount, plural, =1 {événement} other {événements}}", "xpack.securitySolution.exceptions.dissasociateListSuccessText": "La liste d'exceptions ({id}) a été retirée avec succès", - "xpack.securitySolution.exceptions.exceptionItem.showCommentsLabel": "Afficher {comments, plural, =1 {commentaire} other {commentaires}} ({comments})", "xpack.securitySolution.exceptions.failedLoadPolicies": "Une erreur s'est produite lors du chargement des politiques : \"{error}\"", "xpack.securitySolution.exceptions.fetch404Error": "La liste d'exceptions associée ({listId}) n'existe plus. Veuillez retirer la liste d'exceptions manquante pour ajouter des exceptions supplémentaires à la règle de détection.", "xpack.securitySolution.exceptions.hideCommentsLabel": "Masquer ({comments}) {comments, plural, =1 {commentaire} other {commentaires}}", @@ -28164,58 +28162,14 @@ "xpack.securitySolution.eventsViewer.alerts.overviewTable.signalStatusTitle": "Statut", "xpack.securitySolution.eventsViewer.eventsLabel": "Événements", "xpack.securitySolution.eventsViewer.showingLabel": "Affichage", - "xpack.securitySolution.exceptions.addException.addEndpointException": "Ajouter une exception de point de terminaison", - "xpack.securitySolution.exceptions.addException.addException": "Ajouter une exception à une règle", - "xpack.securitySolution.exceptions.addException.bulkCloseLabel": "Fermer toutes les alertes qui correspondent à cette exception et ont été générées par cette règle", - "xpack.securitySolution.exceptions.addException.bulkCloseLabel.disabled": "Fermer toutes les alertes qui correspondent à cette exception et ont été générées par cette règle (les listes et les champs non ECS ne sont pas pris en charge)", - "xpack.securitySolution.exceptions.addException.cancel": "Annuler", - "xpack.securitySolution.exceptions.addException.endpointQuarantineText": "Sur tous les hôtes Endpoint, les fichiers en quarantaine qui correspondent à l'exception sont automatiquement restaurés à leur emplacement d'origine. Cette exception s'applique à toutes les règles utilisant les exceptions Endpoint.", - "xpack.securitySolution.exceptions.addException.error": "Impossible d'ajouter l'exception", - "xpack.securitySolution.exceptions.addException.infoLabel": "Les alertes sont générées lorsque les conditions de la règle sont remplies, sauf quand :", - "xpack.securitySolution.exceptions.addException.operatingSystemPlaceHolder": "Sélectionner un système d'exploitation", - "xpack.securitySolution.exceptions.addException.sequenceWarning": "La requête de cette règle contient une instruction de séquence EQL. L'exception créée s'appliquera à tous les événements de la séquence.", - "xpack.securitySolution.exceptions.addException.success": "Exception ajoutée avec succès", "xpack.securitySolution.exceptions.badge.readOnly.tooltip": "Impossible de créer, de modifier ou de supprimer des exceptions", "xpack.securitySolution.exceptions.cancelLabel": "Annuler", "xpack.securitySolution.exceptions.clearExceptionsLabel": "Retirer la liste d'exceptions", "xpack.securitySolution.exceptions.commentEventLabel": "a ajouté un commentaire", "xpack.securitySolution.exceptions.dissasociateExceptionListError": "Impossible de retirer la liste d'exceptions", - "xpack.securitySolution.exceptions.editException.bulkCloseLabel": "Fermer toutes les alertes qui correspondent à cette exception et ont été générées par cette règle", - "xpack.securitySolution.exceptions.editException.bulkCloseLabel.disabled": "Fermer toutes les alertes qui correspondent à cette exception et ont été générées par cette règle (les listes et les champs non ECS ne sont pas pris en charge)", - "xpack.securitySolution.exceptions.editException.cancel": "Annuler", - "xpack.securitySolution.exceptions.editException.editEndpointExceptionTitle": "Modifier une exception de point de terminaison", - "xpack.securitySolution.exceptions.editException.editExceptionSaveButton": "Enregistrer", - "xpack.securitySolution.exceptions.editException.editExceptionTitle": "Modifier une exception à une règle", - "xpack.securitySolution.exceptions.editException.endpointQuarantineText": "Sur tous les hôtes Endpoint, les fichiers en quarantaine qui correspondent à l'exception sont automatiquement restaurés à leur emplacement d'origine. Cette exception s'applique à toutes les règles utilisant les exceptions Endpoint.", - "xpack.securitySolution.exceptions.editException.infoLabel": "Les alertes sont générées lorsque les conditions de la règle sont remplies, sauf quand :", - "xpack.securitySolution.exceptions.editException.sequenceWarning": "La requête de cette règle contient une instruction de séquence EQL. L'exception modifiée s'appliquera à tous les événements de la séquence.", - "xpack.securitySolution.exceptions.editException.success": "L'exception a été mise à jour avec succès", - "xpack.securitySolution.exceptions.editException.versionConflictDescription": "Cette exception semble avoir été mise à jour depuis que vous l'avez sélectionnée pour la modifier. Essayez de cliquer sur \"Annuler\" et de modifier à nouveau l'exception.", - "xpack.securitySolution.exceptions.editException.versionConflictTitle": "Désolé, une erreur est survenue", "xpack.securitySolution.exceptions.errorLabel": "Erreur", "xpack.securitySolution.exceptions.fetchError": "Erreur lors de la récupération de la liste d'exceptions", "xpack.securitySolution.exceptions.modalErrorAccordionText": "Afficher les informations de référence de la règle :", - "xpack.securitySolution.exceptions.exceptionItem.conditions.and": "AND", - "xpack.securitySolution.exceptions.exceptionItem.conditions.existsOperator": "existe", - "xpack.securitySolution.exceptions.exceptionItem.conditions.existsOperator.not": "n'existe pas", - "xpack.securitySolution.exceptions.exceptionItem.conditions.linux": "Linux", - "xpack.securitySolution.exceptions.exceptionItem.conditions.listOperator": "inclus dans", - "xpack.securitySolution.exceptions.exceptionItem.conditions.listOperator.not": "n'est pas inclus dans", - "xpack.securitySolution.exceptions.exceptionItem.conditions.macos": "Mac", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchAnyOperator": "est l'une des options suivantes", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchAnyOperator.not": "n'est pas l'une des options suivantes", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchOperator": "IS", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchOperator.not": "N'EST PAS", - "xpack.securitySolution.exceptions.exceptionItem.conditions.nestedOperator": "a", - "xpack.securitySolution.exceptions.exceptionItem.conditions.os": "Système d'exploitation", - "xpack.securitySolution.exceptions.exceptionItem.conditions.wildcardDoesNotMatchOperator": "NE CORRESPOND PAS À", - "xpack.securitySolution.exceptions.exceptionItem.conditions.wildcardMatchesOperator": "CORRESPONDANCES", - "xpack.securitySolution.exceptions.exceptionItem.conditions.windows": "Windows", - "xpack.securitySolution.exceptions.exceptionItem.createdLabel": "Créé", - "xpack.securitySolution.exceptions.exceptionItem.deleteItemButton": "Supprimer un élément", - "xpack.securitySolution.exceptions.exceptionItem.editItemButton": "Modifier l’élément", - "xpack.securitySolution.exceptions.exceptionItem.metaDetailsBy": "par", - "xpack.securitySolution.exceptions.exceptionItem.updatedLabel": "Mis à jour", "xpack.securitySolution.exceptions.operatingSystemFullLabel": "Système d'exploitation", "xpack.securitySolution.exceptions.operatingSystemLinux": "Linux", "xpack.securitySolution.exceptions.operatingSystemMac": "macOS", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index a03f2a024c2131..742cceebbd8cd8 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -23566,7 +23566,6 @@ "xpack.observability.formatters.secondsTimeUnitLabel": "s", "xpack.observability.formatters.secondsTimeUnitLabelExtended": "秒", "xpack.observability.home.addData": "統合の追加", - "xpack.observability.hoverActions.filterForValue": "値でフィルター", "xpack.observability.hoverActions.filterForValueButtonLabel": "フィルタリング", "xpack.observability.inspector.stats.dataViewDescription": "Elasticsearchインデックスに接続したデータビューです。", "xpack.observability.inspector.stats.dataViewLabel": "データビュー", @@ -25599,7 +25598,6 @@ "xpack.securitySolution.eventsTab.unit": "外部{totalCount, plural, other {アラート}}", "xpack.securitySolution.eventsViewer.unit": "{totalCount, plural, other {イベント}}", "xpack.securitySolution.exceptions.dissasociateListSuccessText": "例外リスト({id})が正常に削除されました", - "xpack.securitySolution.exceptions.exceptionItem.showCommentsLabel": "{comments, plural, other {件のコメント}}を表示({comments})", "xpack.securitySolution.exceptions.failedLoadPolicies": "ポリシーの読み込みエラーが発生しました:\"{error}\"", "xpack.securitySolution.exceptions.fetch404Error": "関連付けられた例外リスト({listId})は存在しません。その他の例外を検出ルールに追加するには、見つからない例外リストを削除してください。", "xpack.securitySolution.exceptions.hideCommentsLabel": "({comments}){comments, plural, other {件のコメント}}を非表示", @@ -28139,57 +28137,13 @@ "xpack.securitySolution.eventsViewer.alerts.overviewTable.signalStatusTitle": "ステータス", "xpack.securitySolution.eventsViewer.eventsLabel": "イベント", "xpack.securitySolution.eventsViewer.showingLabel": "表示中", - "xpack.securitySolution.exceptions.addException.addEndpointException": "エンドポイント例外の追加", - "xpack.securitySolution.exceptions.addException.addException": "ルール例外の追加", - "xpack.securitySolution.exceptions.addException.bulkCloseLabel": "この例外一致し、このルールによって生成された、すべてのアラートを閉じる", - "xpack.securitySolution.exceptions.addException.bulkCloseLabel.disabled": "この例外と一致し、このルールによって生成された、すべてのアラートを閉じる(リストと非ECSフィールドはサポートされません)", - "xpack.securitySolution.exceptions.addException.cancel": "キャンセル", - "xpack.securitySolution.exceptions.addException.endpointQuarantineText": "すべてのエンドポイントホストで、例外と一致する隔離されたファイルは、自動的に元の場所に復元されます。この例外はエンドポイント例外を使用するすべてのルールに適用されます。", - "xpack.securitySolution.exceptions.addException.error": "例外を追加できませんでした", - "xpack.securitySolution.exceptions.addException.infoLabel": "ルールの条件が満たされるときにアラートが生成されます。例外:", - "xpack.securitySolution.exceptions.addException.operatingSystemPlaceHolder": "オペレーティングシステムを選択", - "xpack.securitySolution.exceptions.addException.sequenceWarning": "このルールのクエリにはEQLシーケンス文があります。作成された例外は、シーケンスのすべてのイベントに適用されます。", - "xpack.securitySolution.exceptions.addException.success": "正常に例外を追加しました", "xpack.securitySolution.exceptions.badge.readOnly.tooltip": "例外を作成、編集、削除できません", "xpack.securitySolution.exceptions.cancelLabel": "キャンセル", "xpack.securitySolution.exceptions.clearExceptionsLabel": "例外リストを削除", "xpack.securitySolution.exceptions.commentEventLabel": "コメントを追加しました", "xpack.securitySolution.exceptions.dissasociateExceptionListError": "例外リストを削除できませんでした", - "xpack.securitySolution.exceptions.editException.bulkCloseLabel": "この例外一致し、このルールによって生成された、すべてのアラートを閉じる", - "xpack.securitySolution.exceptions.editException.bulkCloseLabel.disabled": "この例外と一致し、このルールによって生成された、すべてのアラートを閉じる(リストと非ECSフィールドはサポートされません)", - "xpack.securitySolution.exceptions.editException.cancel": "キャンセル", - "xpack.securitySolution.exceptions.editException.editEndpointExceptionTitle": "エンドポイント例外の編集", - "xpack.securitySolution.exceptions.editException.editExceptionSaveButton": "保存", - "xpack.securitySolution.exceptions.editException.editExceptionTitle": "ルール例外を編集", - "xpack.securitySolution.exceptions.editException.endpointQuarantineText": "すべてのエンドポイントホストで、例外と一致する隔離されたファイルは、自動的に元の場所に復元されます。この例外はエンドポイント例外を使用するすべてのルールに適用されます。", - "xpack.securitySolution.exceptions.editException.infoLabel": "ルールの条件が満たされるときにアラートが生成されます。例外:", - "xpack.securitySolution.exceptions.editException.sequenceWarning": "このルールのクエリにはEQLシーケンス文があります。修正された例外は、シーケンスのすべてのイベントに適用されます。", - "xpack.securitySolution.exceptions.editException.success": "正常に例外を更新しました", - "xpack.securitySolution.exceptions.editException.versionConflictDescription": "最初に編集することを選択したときからこの例外が更新されている可能性があります。[キャンセル]をクリックし、もう一度例外を編集してください。", - "xpack.securitySolution.exceptions.editException.versionConflictTitle": "申し訳ございません、エラーが発生しました", "xpack.securitySolution.exceptions.errorLabel": "エラー", "xpack.securitySolution.exceptions.fetchError": "例外リストの取得エラー", - "xpack.securitySolution.exceptions.exceptionItem.conditions.and": "AND", - "xpack.securitySolution.exceptions.exceptionItem.conditions.existsOperator": "存在する", - "xpack.securitySolution.exceptions.exceptionItem.conditions.existsOperator.not": "存在しない", - "xpack.securitySolution.exceptions.exceptionItem.conditions.linux": "Linux", - "xpack.securitySolution.exceptions.exceptionItem.conditions.listOperator": "に含まれる", - "xpack.securitySolution.exceptions.exceptionItem.conditions.listOperator.not": "に含まれない", - "xpack.securitySolution.exceptions.exceptionItem.conditions.macos": "Mac", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchAnyOperator": "is one of", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchAnyOperator.not": "is not one of", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchOperator": "IS", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchOperator.not": "IS NOT", - "xpack.securitySolution.exceptions.exceptionItem.conditions.nestedOperator": "がある", - "xpack.securitySolution.exceptions.exceptionItem.conditions.os": "OS", - "xpack.securitySolution.exceptions.exceptionItem.conditions.wildcardDoesNotMatchOperator": "一致しない", - "xpack.securitySolution.exceptions.exceptionItem.conditions.wildcardMatchesOperator": "一致", - "xpack.securitySolution.exceptions.exceptionItem.conditions.windows": "Windows", - "xpack.securitySolution.exceptions.exceptionItem.createdLabel": "作成済み", - "xpack.securitySolution.exceptions.exceptionItem.deleteItemButton": "アイテムを削除", - "xpack.securitySolution.exceptions.exceptionItem.editItemButton": "項目を編集", - "xpack.securitySolution.exceptions.exceptionItem.metaDetailsBy": "グループ基準", - "xpack.securitySolution.exceptions.exceptionItem.updatedLabel": "更新しました", "xpack.securitySolution.exceptions.modalErrorAccordionText": "ルール参照情報を表示:", "xpack.securitySolution.exceptions.operatingSystemFullLabel": "オペレーティングシステム", "xpack.securitySolution.exceptions.operatingSystemLinux": "Linux", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 193d57ba8f62be..d80ac7cc7ffeb6 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -23597,7 +23597,6 @@ "xpack.observability.formatters.secondsTimeUnitLabel": "s", "xpack.observability.formatters.secondsTimeUnitLabelExtended": "秒", "xpack.observability.home.addData": "添加集成", - "xpack.observability.hoverActions.filterForValue": "筛留值", "xpack.observability.hoverActions.filterForValueButtonLabel": "筛选范围", "xpack.observability.inspector.stats.dataViewDescription": "连接到 Elasticsearch 索引的数据视图。", "xpack.observability.inspector.stats.dataViewLabel": "数据视图", @@ -25633,7 +25632,6 @@ "xpack.securitySolution.eventsTab.unit": "个外部{totalCount, plural, other {告警}}", "xpack.securitySolution.eventsViewer.unit": "{totalCount, plural, other {个事件}}", "xpack.securitySolution.exceptions.dissasociateListSuccessText": "例外列表 ({id}) 已成功移除", - "xpack.securitySolution.exceptions.exceptionItem.showCommentsLabel": "显示{comments, plural, other {注释}} ({comments})", "xpack.securitySolution.exceptions.failedLoadPolicies": "加载策略时出错:“{error}”", "xpack.securitySolution.exceptions.fetch404Error": "关联的例外列表 ({listId}) 已不存在。请移除缺少的例外列表,以将其他例外添加到检测规则。", "xpack.securitySolution.exceptions.hideCommentsLabel": "隐藏 ({comments}) 个{comments, plural, other {注释}}", @@ -28173,57 +28171,13 @@ "xpack.securitySolution.eventsViewer.alerts.overviewTable.signalStatusTitle": "状态", "xpack.securitySolution.eventsViewer.eventsLabel": "事件", "xpack.securitySolution.eventsViewer.showingLabel": "正在显示", - "xpack.securitySolution.exceptions.addException.addEndpointException": "添加终端例外", - "xpack.securitySolution.exceptions.addException.addException": "添加规则例外", - "xpack.securitySolution.exceptions.addException.bulkCloseLabel": "关闭所有与此例外匹配且根据此规则生成的告警", - "xpack.securitySolution.exceptions.addException.bulkCloseLabel.disabled": "关闭所有与此例外匹配且根据此规则生成的告警(不支持列表和非 ECS 字段)", - "xpack.securitySolution.exceptions.addException.cancel": "取消", - "xpack.securitySolution.exceptions.addException.endpointQuarantineText": "在所有终端主机上,与该例外匹配的已隔离文件会自动还原到其原始位置。此例外适用于使用终端例外的所有规则。", - "xpack.securitySolution.exceptions.addException.error": "添加例外失败", - "xpack.securitySolution.exceptions.addException.infoLabel": "满足规则的条件时生成告警,但以下情况除外:", - "xpack.securitySolution.exceptions.addException.operatingSystemPlaceHolder": "选择操作系统", - "xpack.securitySolution.exceptions.addException.sequenceWarning": "此规则的查询包含 EQL 序列语句。创建的例外将应用于序列中的所有事件。", - "xpack.securitySolution.exceptions.addException.success": "已成功添加例外", "xpack.securitySolution.exceptions.badge.readOnly.tooltip": "无法创建、编辑或删除例外", "xpack.securitySolution.exceptions.cancelLabel": "取消", "xpack.securitySolution.exceptions.clearExceptionsLabel": "移除例外列表", "xpack.securitySolution.exceptions.commentEventLabel": "已添加注释", "xpack.securitySolution.exceptions.dissasociateExceptionListError": "无法移除例外列表", - "xpack.securitySolution.exceptions.editException.bulkCloseLabel": "关闭所有与此例外匹配且根据此规则生成的告警", - "xpack.securitySolution.exceptions.editException.bulkCloseLabel.disabled": "关闭所有与此例外匹配且根据此规则生成的告警(不支持列表和非 ECS 字段)", - "xpack.securitySolution.exceptions.editException.cancel": "取消", - "xpack.securitySolution.exceptions.editException.editEndpointExceptionTitle": "编辑终端例外", - "xpack.securitySolution.exceptions.editException.editExceptionSaveButton": "保存", - "xpack.securitySolution.exceptions.editException.editExceptionTitle": "编辑规则例外", - "xpack.securitySolution.exceptions.editException.endpointQuarantineText": "在所有终端主机上,与该例外匹配的已隔离文件会自动还原到其原始位置。此例外适用于使用终端例外的所有规则。", - "xpack.securitySolution.exceptions.editException.infoLabel": "满足规则的条件时生成告警,但以下情况除外:", - "xpack.securitySolution.exceptions.editException.sequenceWarning": "此规则的查询包含 EQL 序列语句。修改的例外将应用于序列中的所有事件。", - "xpack.securitySolution.exceptions.editException.success": "已成功更新例外", - "xpack.securitySolution.exceptions.editException.versionConflictDescription": "此例外可能自您首次选择编辑后已更新。尝试单击“取消”,重新编辑该例外。", - "xpack.securitySolution.exceptions.editException.versionConflictTitle": "抱歉,有错误", "xpack.securitySolution.exceptions.errorLabel": "错误", "xpack.securitySolution.exceptions.fetchError": "提取例外列表时出错", - "xpack.securitySolution.exceptions.exceptionItem.conditions.and": "且", - "xpack.securitySolution.exceptions.exceptionItem.conditions.existsOperator": "存在", - "xpack.securitySolution.exceptions.exceptionItem.conditions.existsOperator.not": "不存在", - "xpack.securitySolution.exceptions.exceptionItem.conditions.linux": "Linux", - "xpack.securitySolution.exceptions.exceptionItem.conditions.listOperator": "包含在", - "xpack.securitySolution.exceptions.exceptionItem.conditions.listOperator.not": "未包括在", - "xpack.securitySolution.exceptions.exceptionItem.conditions.macos": "Mac", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchAnyOperator": "属于", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchAnyOperator.not": "不属于", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchOperator": "是", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchOperator.not": "不是", - "xpack.securitySolution.exceptions.exceptionItem.conditions.nestedOperator": "具有", - "xpack.securitySolution.exceptions.exceptionItem.conditions.os": "OS", - "xpack.securitySolution.exceptions.exceptionItem.conditions.wildcardDoesNotMatchOperator": "不匹配", - "xpack.securitySolution.exceptions.exceptionItem.conditions.wildcardMatchesOperator": "匹配", - "xpack.securitySolution.exceptions.exceptionItem.conditions.windows": "Windows", - "xpack.securitySolution.exceptions.exceptionItem.createdLabel": "创建时间", - "xpack.securitySolution.exceptions.exceptionItem.deleteItemButton": "删除项", - "xpack.securitySolution.exceptions.exceptionItem.editItemButton": "编辑项目", - "xpack.securitySolution.exceptions.exceptionItem.metaDetailsBy": "依据", - "xpack.securitySolution.exceptions.exceptionItem.updatedLabel": "已更新", "xpack.securitySolution.exceptions.modalErrorAccordionText": "显示规则引用信息:", "xpack.securitySolution.exceptions.operatingSystemFullLabel": "操作系统", "xpack.securitySolution.exceptions.operatingSystemLinux": "Linux", diff --git a/x-pack/test/api_integration/apis/uptime/rest/delete_monitor_project.ts b/x-pack/test/api_integration/apis/uptime/rest/delete_monitor_project.ts new file mode 100644 index 00000000000000..25afc4e6651803 --- /dev/null +++ b/x-pack/test/api_integration/apis/uptime/rest/delete_monitor_project.ts @@ -0,0 +1,524 @@ +/* + * 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 uuid from 'uuid'; +import expect from '@kbn/expect'; +import { format as formatUrl } from 'url'; +import { ConfigKey, ProjectMonitorsRequest } from '@kbn/synthetics-plugin/common/runtime_types'; +import { INSUFFICIENT_FLEET_PERMISSIONS } from '@kbn/synthetics-plugin/server/synthetics_service/project_monitor/project_monitor_formatter'; +import { REQUEST_TOO_LARGE } from '@kbn/synthetics-plugin/server/routes/monitor_cruds/delete_monitor_project'; +import { API_URLS } from '@kbn/synthetics-plugin/common/constants'; +import { syntheticsMonitorType } from '@kbn/synthetics-plugin/server/legacy_uptime/lib/saved_objects/synthetics_monitor'; +import { PackagePolicy } from '@kbn/fleet-plugin/common'; +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { getFixtureJson } from './helper/get_fixture_json'; +import { PrivateLocationTestService } from './services/private_location_test_service'; +import { parseStreamApiResponse } from './add_monitor_project'; + +export default function ({ getService }: FtrProviderContext) { + describe('DeleteProjectMonitors', function () { + this.tags('skipCloud'); + + const supertest = getService('supertest'); + const config = getService('config'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const security = getService('security'); + const kibanaServerUrl = formatUrl(config.get('servers.kibana')); + const kibanaServer = getService('kibanaServer'); + const projectMonitorEndpoint = kibanaServerUrl + API_URLS.SYNTHETICS_MONITORS_PROJECT_LEGACY; + + let projectMonitors: ProjectMonitorsRequest; + + let testPolicyId = ''; + const testPrivateLocations = new PrivateLocationTestService(getService); + + const setUniqueIds = (request: ProjectMonitorsRequest) => { + return { + ...request, + monitors: request.monitors.map((monitor) => ({ ...monitor, id: uuid.v4() })), + }; + }; + + before(async () => { + await supertest.post('/api/fleet/setup').set('kbn-xsrf', 'true').send().expect(200); + await supertest + .post('/api/fleet/epm/packages/synthetics/0.10.3') + .set('kbn-xsrf', 'true') + .send({ force: true }) + .expect(200); + + const testPolicyName = 'Fleet test server policy' + Date.now(); + const apiResponse = await testPrivateLocations.addFleetPolicy(testPolicyName); + testPolicyId = apiResponse.body.item.id; + await testPrivateLocations.setTestLocations([testPolicyId]); + }); + + beforeEach(() => { + projectMonitors = setUniqueIds(getFixtureJson('project_browser_monitor')); + }); + + it('only allows 250 requests at a time', async () => { + const project = 'test-brower-suite'; + const monitors = []; + for (let i = 0; i < 251; i++) { + monitors.push({ + ...projectMonitors.monitors[0], + id: `test-id-${i}`, + name: `test-name-${i}`, + }); + } + + try { + await parseStreamApiResponse( + projectMonitorEndpoint, + JSON.stringify({ + ...projectMonitors, + project, + monitors, + }) + ); + + const savedObjectsResponse = await supertest + .get(API_URLS.SYNTHETICS_MONITORS) + .query({ + filter: `${syntheticsMonitorType}.attributes.project_id: "${project}"`, + }) + .set('kbn-xsrf', 'true'); + const { total } = savedObjectsResponse.body; + expect(total).to.eql(251); + const monitorsToDelete = monitors.map((monitor) => monitor.id); + + const response = await supertest + .delete(API_URLS.SYNTHETICS_MONITORS_PROJECT.replace('{projectName}', project)) + .set('kbn-xsrf', 'true') + .send({ monitors: monitorsToDelete }) + .expect(400); + const { message } = response.body; + expect(message).to.eql(REQUEST_TOO_LARGE); + } finally { + await parseStreamApiResponse( + projectMonitorEndpoint, + JSON.stringify({ + ...projectMonitors, + project, + keep_stale: false, + monitors: [], + }) + ); + } + }); + + it('project monitors - handles browser monitors', async () => { + const monitorToDelete = 'second-monitor-id'; + const monitors = [ + projectMonitors.monitors[0], + { + ...projectMonitors.monitors[0], + id: monitorToDelete, + }, + ]; + const project = 'test-brower-suite'; + + try { + await parseStreamApiResponse( + projectMonitorEndpoint, + JSON.stringify({ + ...projectMonitors, + project, + monitors, + }) + ); + + const savedObjectsResponse = await supertest + .get(API_URLS.SYNTHETICS_MONITORS) + .query({ + filter: `${syntheticsMonitorType}.attributes.project_id: "${project}"`, + }) + .set('kbn-xsrf', 'true') + .expect(200); + const { total } = savedObjectsResponse.body; + expect(total).to.eql(2); + const monitorsToDelete = [monitorToDelete]; + + const response = await supertest + .delete(API_URLS.SYNTHETICS_MONITORS_PROJECT.replace('{projectName}', project)) + .set('kbn-xsrf', 'true') + .send({ monitors: monitorsToDelete }) + .expect(200); + + expect(response.body.deleted_monitors).to.eql(monitorsToDelete); + + const responseAfterDeletion = await supertest + .get(API_URLS.SYNTHETICS_MONITORS) + .query({ + filter: `${syntheticsMonitorType}.attributes.project_id: "${project}"`, + }) + .set('kbn-xsrf', 'true') + .expect(200); + const { total: totalAfterDeletion } = responseAfterDeletion.body; + expect(totalAfterDeletion).to.eql(1); + } finally { + await parseStreamApiResponse( + projectMonitorEndpoint, + JSON.stringify({ + ...projectMonitors, + project, + keep_stale: false, + monitors: [], + }) + ); + } + }); + + it('does not delete monitors from a different project', async () => { + const monitors = [...projectMonitors.monitors]; + const project = 'test-brower-suite'; + const secondProject = 'second-project'; + + try { + await parseStreamApiResponse( + projectMonitorEndpoint, + JSON.stringify({ + ...projectMonitors, + project, + monitors, + }) + ); + + await parseStreamApiResponse( + projectMonitorEndpoint, + JSON.stringify({ + ...projectMonitors, + project: secondProject, + monitors, + }) + ); + + const savedObjectsResponse = await supertest + .get(API_URLS.SYNTHETICS_MONITORS) + .query({ + filter: `${syntheticsMonitorType}.attributes.project_id: "${project}"`, + }) + .set('kbn-xsrf', 'true') + .expect(200); + const secondProjectSavedObjectResponse = await supertest + .get(API_URLS.SYNTHETICS_MONITORS) + .query({ + filter: `${syntheticsMonitorType}.attributes.project_id: "${secondProject}"`, + }) + .set('kbn-xsrf', 'true') + .expect(200); + const { total } = savedObjectsResponse.body; + const { total: secondProjectTotal } = secondProjectSavedObjectResponse.body; + expect(total).to.eql(monitors.length); + expect(secondProjectTotal).to.eql(monitors.length); + const monitorsToDelete = monitors.map((monitor) => monitor.id); + + const response = await supertest + .delete(API_URLS.SYNTHETICS_MONITORS_PROJECT.replace('{projectName}', project)) + .set('kbn-xsrf', 'true') + .send({ monitors: monitorsToDelete }) + .expect(200); + + expect(response.body.deleted_monitors).to.eql(monitorsToDelete); + + const responseAfterDeletion = await supertest + .get(API_URLS.SYNTHETICS_MONITORS) + .query({ + filter: `${syntheticsMonitorType}.attributes.project_id: "${project}"`, + }) + .set('kbn-xsrf', 'true') + .expect(200); + const secondResponseAfterDeletion = await supertest + .get(API_URLS.SYNTHETICS_MONITORS) + .query({ + filter: `${syntheticsMonitorType}.attributes.project_id: "${secondProject}"`, + }) + .set('kbn-xsrf', 'true') + .expect(200); + const { total: totalAfterDeletion } = responseAfterDeletion.body; + const { total: secondProjectTotalAfterDeletion } = secondResponseAfterDeletion.body; + expect(totalAfterDeletion).to.eql(0); + expect(secondProjectTotalAfterDeletion).to.eql(monitors.length); + } finally { + await parseStreamApiResponse( + projectMonitorEndpoint, + JSON.stringify({ + ...projectMonitors, + project, + keep_stale: false, + monitors: [], + }) + ); + await parseStreamApiResponse( + projectMonitorEndpoint, + JSON.stringify({ + ...projectMonitors, + project: secondProject, + keep_stale: false, + monitors: [], + }) + ); + } + }); + + it('does not delete monitors from the same project in a different space project', async () => { + const monitors = [...projectMonitors.monitors]; + const project = 'test-brower-suite'; + const SPACE_ID = `test-space-${uuid.v4()}`; + const SPACE_NAME = `test-space-name ${uuid.v4()}`; + const secondSpaceProjectMonitorApiRoute = `${kibanaServerUrl}/s/${SPACE_ID}${API_URLS.SYNTHETICS_MONITORS_PROJECT_LEGACY}`; + await kibanaServer.spaces.create({ id: SPACE_ID, name: SPACE_NAME }); + + try { + await parseStreamApiResponse( + projectMonitorEndpoint, + JSON.stringify({ + ...projectMonitors, + project, + monitors, + }) + ); + + await parseStreamApiResponse( + secondSpaceProjectMonitorApiRoute, + JSON.stringify({ + ...projectMonitors, + project, + monitors, + }) + ); + + const savedObjectsResponse = await supertest + .get(API_URLS.SYNTHETICS_MONITORS) + .query({ + filter: `${syntheticsMonitorType}.attributes.project_id: "${project}"`, + }) + .set('kbn-xsrf', 'true') + .expect(200); + const secondSpaceProjectSavedObjectResponse = await supertest + .get(`/s/${SPACE_ID}${API_URLS.SYNTHETICS_MONITORS}`) + .query({ + filter: `${syntheticsMonitorType}.attributes.project_id: "${project}"`, + }) + .set('kbn-xsrf', 'true') + .expect(200); + const { total } = savedObjectsResponse.body; + const { total: secondSpaceTotal } = secondSpaceProjectSavedObjectResponse.body; + + expect(total).to.eql(monitors.length); + expect(secondSpaceTotal).to.eql(monitors.length); + const monitorsToDelete = monitors.map((monitor) => monitor.id); + + const response = await supertest + .delete( + `/s/${SPACE_ID}${API_URLS.SYNTHETICS_MONITORS_PROJECT.replace( + '{projectName}', + project + )}` + ) + .set('kbn-xsrf', 'true') + .send({ monitors: monitorsToDelete }) + .expect(200); + + expect(response.body.deleted_monitors).to.eql(monitorsToDelete); + + const responseAfterDeletion = await supertest + .get(API_URLS.SYNTHETICS_MONITORS) + .query({ + filter: `${syntheticsMonitorType}.attributes.project_id: "${project}"`, + }) + .set('kbn-xsrf', 'true') + .expect(200); + const secondSpaceResponseAfterDeletion = await supertest + .get(`/s/${SPACE_ID}${API_URLS.SYNTHETICS_MONITORS}`) + .query({ + filter: `${syntheticsMonitorType}.attributes.project_id: "${project}"`, + }) + .set('kbn-xsrf', 'true') + .expect(200); + const { total: totalAfterDeletion } = responseAfterDeletion.body; + const { total: secondProjectTotalAfterDeletion } = secondSpaceResponseAfterDeletion.body; + expect(totalAfterDeletion).to.eql(monitors.length); + expect(secondProjectTotalAfterDeletion).to.eql(0); + } finally { + await parseStreamApiResponse( + projectMonitorEndpoint, + JSON.stringify({ + ...projectMonitors, + project, + keep_stale: false, + monitors: [], + }) + ); + await parseStreamApiResponse( + secondSpaceProjectMonitorApiRoute, + JSON.stringify({ + ...projectMonitors, + project, + keep_stale: false, + monitors: [], + }) + ); + } + }); + + it('deletes integration policies when project monitors are deleted', async () => { + const monitors = [ + { ...projectMonitors.monitors[0], privateLocations: ['Test private location 0'] }, + ]; + const project = 'test-brower-suite'; + + try { + await parseStreamApiResponse( + projectMonitorEndpoint, + JSON.stringify({ + ...projectMonitors, + project, + monitors, + }) + ); + + const savedObjectsResponse = await supertest + .get(API_URLS.SYNTHETICS_MONITORS) + .query({ + filter: `${syntheticsMonitorType}.attributes.project_id: "${project}"`, + }) + .set('kbn-xsrf', 'true') + .expect(200); + const { total } = savedObjectsResponse.body; + expect(total).to.eql(monitors.length); + const apiResponsePolicy = await supertest.get( + '/api/fleet/package_policies?page=1&perPage=2000&kuery=ingest-package-policies.package.name%3A%20synthetics' + ); + + const packagePolicy = apiResponsePolicy.body.items.find( + (pkgPolicy: PackagePolicy) => + pkgPolicy.id === + savedObjectsResponse.body.monitors[0].attributes[ConfigKey.CUSTOM_HEARTBEAT_ID] + + '-' + + testPolicyId + ); + expect(packagePolicy.policy_id).to.be(testPolicyId); + + const monitorsToDelete = monitors.map((monitor) => monitor.id); + + const response = await supertest + .delete(API_URLS.SYNTHETICS_MONITORS_PROJECT.replace('{projectName}', project)) + .set('kbn-xsrf', 'true') + .send({ monitors: monitorsToDelete }) + .expect(200); + + expect(response.body.deleted_monitors).to.eql(monitorsToDelete); + + const responseAfterDeletion = await supertest + .get(API_URLS.SYNTHETICS_MONITORS) + .query({ + filter: `${syntheticsMonitorType}.attributes.project_id: "${project}"`, + }) + .set('kbn-xsrf', 'true') + .expect(200); + const { total: totalAfterDeletion } = responseAfterDeletion.body; + expect(totalAfterDeletion).to.eql(0); + const apiResponsePolicy2 = await supertest.get( + '/api/fleet/package_policies?page=1&perPage=2000&kuery=ingest-package-policies.package.name%3A%20synthetics' + ); + + const packagePolicy2 = apiResponsePolicy2.body.items.find( + (pkgPolicy: PackagePolicy) => + pkgPolicy.id === + savedObjectsResponse.body.monitors[0].attributes[ConfigKey.CUSTOM_HEARTBEAT_ID] + + '-' + + testPolicyId + ); + expect(packagePolicy2).to.be(undefined); + } finally { + await parseStreamApiResponse( + projectMonitorEndpoint, + JSON.stringify({ + ...projectMonitors, + project, + keep_stale: false, + monitors: [], + }) + ); + } + }); + + it('returns 403 when a user without fleet permissions attempts to delete a project monitor with a private location', async () => { + const project = 'test-brower-suite'; + const secondMonitor = { + ...projectMonitors.monitors[0], + id: 'test-id-2', + privateLocations: ['Test private location 0'], + }; + const testMonitors = [projectMonitors.monitors[0], secondMonitor]; + const monitorsToDelete = testMonitors.map((monitor) => monitor.id); + const username = 'admin'; + const roleName = 'uptime read only'; + const password = `${username} - password`; + try { + await security.role.create(roleName, { + kibana: [ + { + feature: { + uptime: ['all'], + }, + spaces: ['*'], + }, + ], + }); + await security.user.create(username, { + password, + roles: [roleName], + full_name: 'a kibana user', + }); + + await parseStreamApiResponse( + projectMonitorEndpoint, + JSON.stringify({ + ...projectMonitors, + project, + keep_stale: false, + monitors: testMonitors, + }) + ); + + const savedObjectsResponse = await supertest + .get(API_URLS.SYNTHETICS_MONITORS) + .query({ + filter: `${syntheticsMonitorType}.attributes.project_id: "${project}"`, + }) + .set('kbn-xsrf', 'true') + .expect(200); + const { total } = savedObjectsResponse.body; + expect(total).to.eql(2); + + const { + body: { message }, + } = await supertestWithoutAuth + .delete(API_URLS.SYNTHETICS_MONITORS_PROJECT.replace('{projectName}', project)) + .set('kbn-xsrf', 'true') + .auth(username, password) + .send({ monitors: monitorsToDelete }) + .expect(403); + expect(message).to.eql(INSUFFICIENT_FLEET_PERMISSIONS); + } finally { + await parseStreamApiResponse( + projectMonitorEndpoint, + JSON.stringify({ + ...projectMonitors, + project, + keep_stale: false, + monitors: [], + }) + ); + await security.user.delete(username); + await security.role.delete(roleName); + } + }); + }); +} diff --git a/x-pack/test/api_integration/apis/uptime/rest/index.ts b/x-pack/test/api_integration/apis/uptime/rest/index.ts index e1fd22c2baf8a9..2e3e6f21f34c2a 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/index.ts +++ b/x-pack/test/api_integration/apis/uptime/rest/index.ts @@ -82,6 +82,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./add_monitor_private_location')); loadTestFile(require.resolve('./edit_monitor')); loadTestFile(require.resolve('./delete_monitor')); + loadTestFile(require.resolve('./delete_monitor_project')); loadTestFile(require.resolve('./synthetics_enablement')); }); }); diff --git a/x-pack/test/apm_api_integration/tests/suggestions/generate_data.ts b/x-pack/test/apm_api_integration/tests/suggestions/generate_data.ts new file mode 100644 index 00000000000000..13d6359e0a7338 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/suggestions/generate_data.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { apm, timerange } from '@kbn/apm-synthtrace'; +import type { ApmSynthtraceEsClient } from '@kbn/apm-synthtrace'; +import { times } from 'lodash'; + +export async function generateData({ + synthtraceEsClient, + start, + end, +}: { + synthtraceEsClient: ApmSynthtraceEsClient; + start: number; + end: number; +}) { + const services = times(5).flatMap((serviceId) => { + return ['go', 'java'].flatMap((agentName) => { + return ['production', 'development', 'staging'].flatMap((environment) => { + return times(5).flatMap((envId) => { + const service = apm + .service({ + name: `${agentName}-${serviceId}`, + environment: `${environment}-${envId}`, + agentName, + }) + .instance('instance-a'); + + return service; + }); + }); + }); + }); + + const transactionNames = [ + 'GET /api/product/:id', + 'PUT /api/product/:id', + 'GET /api/user/:id', + 'PUT /api/user/:id', + ]; + + const phpService = apm + .service({ + name: `custom-php-service`, + environment: `custom-php-environment`, + agentName: 'php', + }) + .instance('instance-a'); + + const docs = timerange(start, end) + .ratePerMinute(1) + .generator((timestamp) => { + const autoGeneratedDocs = services.flatMap((service) => { + return transactionNames.flatMap((transactionName) => { + return service + .transaction({ transactionName, transactionType: 'my-custom-type' }) + .timestamp(timestamp) + .duration(1000); + }); + }); + + const customDoc = phpService + .transaction({ + transactionName: 'GET /api/php/memory', + transactionType: 'custom-php-type', + }) + .timestamp(timestamp) + .duration(1000); + + return [...autoGeneratedDocs, customDoc]; + }); + + return await synthtraceEsClient.index(docs); +} diff --git a/x-pack/test/apm_api_integration/tests/suggestions/suggestions.spec.ts b/x-pack/test/apm_api_integration/tests/suggestions/suggestions.spec.ts index 692cd1c0cf7f1d..db15db23776c78 100644 --- a/x-pack/test/apm_api_integration/tests/suggestions/suggestions.spec.ts +++ b/x-pack/test/apm_api_integration/tests/suggestions/suggestions.spec.ts @@ -7,139 +7,286 @@ import { SERVICE_ENVIRONMENT, SERVICE_NAME, + TRANSACTION_NAME, TRANSACTION_TYPE, } from '@kbn/apm-plugin/common/elasticsearch_fieldnames'; -import archives_metadata from '../../common/fixtures/es_archiver/archives_metadata'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { generateData } from './generate_data'; + +const startNumber = new Date('2021-01-01T00:00:00.000Z').getTime(); +const endNumber = new Date('2021-01-01T00:05:00.000Z').getTime() - 1; + +const start = new Date(startNumber).toISOString(); +const end = new Date(endNumber).toISOString(); export default function suggestionsTests({ getService }: FtrProviderContext) { const registry = getService('registry'); const apmApiClient = getService('apmApiClient'); - const archiveName = 'apm_8.0.0'; - const { start, end } = archives_metadata[archiveName]; - - registry.when( - 'suggestions when data is loaded', - { config: 'basic', archives: [archiveName] }, - () => { - describe('with environment', () => { - describe('with an empty string parameter', () => { - it('returns all environments', async () => { - const { body } = await apmApiClient.readUser({ - endpoint: 'GET /internal/apm/suggestions', - params: { query: { fieldName: SERVICE_ENVIRONMENT, fieldValue: '', start, end } }, - }); - - expectSnapshot(body).toMatchInline(` - Object { - "terms": Array [ - "production", - "testing", - ], - } - `); + const synthtraceEsClient = getService('synthtraceEsClient'); + + registry.when('suggestions when data is loaded', { config: 'basic', archives: [] }, async () => { + before(async () => { + await generateData({ + synthtraceEsClient, + start: startNumber, + end: endNumber, + }); + }); + + after(() => synthtraceEsClient.clean()); + + describe(`field: ${SERVICE_ENVIRONMENT}`, () => { + describe('when fieldValue is empty', () => { + it('returns all environments', async () => { + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/suggestions', + params: { + query: { fieldName: SERVICE_ENVIRONMENT, fieldValue: '', start, end }, + }, + }); + + expectSnapshot(body.terms).toMatchInline(` + Array [ + "custom-php-environment", + "development-0", + "development-1", + "development-2", + "development-3", + "development-4", + "production-0", + "production-1", + "production-2", + "production-3", + "production-4", + "staging-0", + "staging-1", + "staging-2", + "staging-3", + "staging-4", + ] + `); + }); + }); + + describe('when fieldValue is not empty', () => { + it('returns environments that start with the fieldValue', async () => { + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/suggestions', + params: { query: { fieldName: SERVICE_ENVIRONMENT, fieldValue: 'prod', start, end } }, + }); + + expectSnapshot(body.terms).toMatchInline(` + Array [ + "production-0", + "production-1", + "production-2", + "production-3", + "production-4", + ] + `); + }); + + it('returns environments that contain the fieldValue', async () => { + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/suggestions', + params: { + query: { fieldName: SERVICE_ENVIRONMENT, fieldValue: 'evelopment', start, end }, + }, + }); + + expectSnapshot(body.terms).toMatchInline(` + Array [ + "development-0", + "development-1", + "development-2", + "development-3", + "development-4", + ] + `); + }); + + it('returns no results if nothing matches', async () => { + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/suggestions', + params: { + query: { fieldName: SERVICE_ENVIRONMENT, fieldValue: 'foobar', start, end }, + }, + }); + + expect(body.terms).to.eql([]); + }); + }); + }); + + describe(`field: ${SERVICE_NAME}`, () => { + describe('when fieldValue is empty', () => { + it('returns all service names', async () => { + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/suggestions', + params: { query: { fieldName: SERVICE_NAME, fieldValue: '', start, end } }, + }); + + expectSnapshot(body.terms).toMatchInline(` + Array [ + "custom-php-service", + "go-0", + "go-1", + "go-2", + "go-3", + "go-4", + "java-0", + "java-1", + "java-2", + "java-3", + "java-4", + ] + `); + }); + }); + + describe('when fieldValue is not empty', () => { + it('returns services that start with the fieldValue', async () => { + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/suggestions', + params: { query: { fieldName: SERVICE_NAME, fieldValue: 'java', start, end } }, + }); + + expectSnapshot(body.terms).toMatchInline(` + Array [ + "java-0", + "java-1", + "java-2", + "java-3", + "java-4", + ] + `); + }); + + it('returns services that contains the fieldValue', async () => { + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/suggestions', + params: { query: { fieldName: SERVICE_NAME, fieldValue: '1', start, end } }, }); + + expectSnapshot(body.terms).toMatchInline(` + Array [ + "go-1", + "java-1", + ] + `); }); + }); + }); + + describe(`field: ${TRANSACTION_TYPE}`, () => { + describe('when fieldValue is empty', () => { + it('returns all transaction types', async () => { + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/suggestions', + params: { query: { fieldName: TRANSACTION_TYPE, fieldValue: '', start, end } }, + }); - describe('with a string parameter', () => { - it('returns items matching the string parameter', async () => { - const { body } = await apmApiClient.readUser({ - endpoint: 'GET /internal/apm/suggestions', - params: { query: { fieldName: SERVICE_ENVIRONMENT, fieldValue: 'pr', start, end } }, - }); - - expectSnapshot(body).toMatchInline(` - Object { - "terms": Array [ - "production", - ], - } + expectSnapshot(body.terms).toMatchInline(` + Array [ + "custom-php-type", + "my-custom-type", + ] `); + }); + }); + + describe('with a string parameter', () => { + it('returns items matching the string parameter', async () => { + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/suggestions', + params: { query: { fieldName: TRANSACTION_TYPE, fieldValue: 'custom', start, end } }, }); + + expectSnapshot(body.terms).toMatchInline(` + Array [ + "custom-php-type", + ] + `); }); }); + }); - describe('with service name', () => { - describe('with an empty string parameter', () => { - it('returns all services', async () => { - const { body } = await apmApiClient.readUser({ - endpoint: 'GET /internal/apm/suggestions', - params: { query: { fieldName: SERVICE_NAME, fieldValue: '', start, end } }, - }); - - expectSnapshot(body).toMatchInline(` - Object { - "terms": Array [ - "auditbeat", - "opbeans-dotnet", - "opbeans-go", - "opbeans-java", - "opbeans-node", - "opbeans-python", - "opbeans-ruby", - "opbeans-rum", - ], - } - `); + describe(`field: ${TRANSACTION_NAME}`, () => { + describe('when fieldValue is empty', () => { + it('returns all transaction names', async () => { + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/suggestions', + params: { query: { fieldName: TRANSACTION_NAME, fieldValue: '', start, end } }, }); + + expectSnapshot(body.terms).toMatchInline(` + Array [ + "GET /api/php/memory", + "GET /api/product/:id", + "GET /api/user/:id", + "PUT /api/product/:id", + "PUT /api/user/:id", + ] + `); }); + }); - describe('with a string parameter', () => { - it('returns items matching the string parameter', async () => { - const { body } = await apmApiClient.readUser({ - endpoint: 'GET /internal/apm/suggestions', - params: { query: { fieldName: SERVICE_NAME, fieldValue: 'aud', start, end } }, - }); - - expectSnapshot(body).toMatchInline(` - Object { - "terms": Array [ - "auditbeat", - ], - } - `); + describe('with a string parameter', () => { + it('returns items matching the string parameter', async () => { + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/suggestions', + params: { query: { fieldName: TRANSACTION_NAME, fieldValue: 'product', start, end } }, }); + + expectSnapshot(body.terms).toMatchInline(` + Array [ + "GET /api/product/:id", + "PUT /api/product/:id", + ] + `); }); }); - describe('with transaction type', () => { - describe('with an empty string parameter', () => { - it('returns all transaction types', async () => { - const { body } = await apmApiClient.readUser({ - endpoint: 'GET /internal/apm/suggestions', - params: { query: { fieldName: TRANSACTION_TYPE, fieldValue: '', start, end } }, - }); - - expectSnapshot(body).toMatchInline(` - Object { - "terms": Array [ - "Worker", - "celery", - "page-load", - "request", - ], - } - `); + describe('when limiting the suggestions to a specific service', () => { + it('returns items matching the string parameter', async () => { + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/suggestions', + params: { + query: { + serviceName: 'custom-php-service', + fieldName: TRANSACTION_NAME, + fieldValue: '', + start, + end, + }, + }, }); + + expectSnapshot(body.terms).toMatchInline(` + Array [ + "GET /api/php/memory", + ] + `); }); - describe('with a string parameter', () => { - it('returns items matching the string parameter', async () => { - const { body } = await apmApiClient.readUser({ - endpoint: 'GET /internal/apm/suggestions', - params: { query: { fieldName: TRANSACTION_TYPE, fieldValue: 'w', start, end } }, - }); - - expectSnapshot(body).toMatchInline(` - Object { - "terms": Array [ - "Worker", - ], - } - `); + it('does not return transactions from other services', async () => { + const { body } = await apmApiClient.readUser({ + endpoint: 'GET /internal/apm/suggestions', + params: { + query: { + serviceName: 'custom-php-service', + fieldName: TRANSACTION_NAME, + fieldValue: 'product', + start, + end, + }, + }, }); + + expect(body.terms).to.eql([]); }); }); - } - ); + }); + }); } diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/cases/assignees.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/cases/assignees.ts index c2b53a008f43a6..574a843a858d70 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/cases/assignees.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/cases/assignees.ts @@ -162,21 +162,19 @@ export default ({ getService }: FtrProviderContext): void => { auth: { user: superUser, space: 'space1' }, }); - const [_, caseWithDeleteAssignee1, caseWithDeleteAssignee2] = await Promise.all([ - createCase(supertest, postCaseReq), - createCase( - supertest, - getPostCaseRequest({ - assignees: [{ uid: profile[0].uid }], - }) - ), - createCase( - supertest, - getPostCaseRequest({ - assignees: [{ uid: profile[0].uid }], - }) - ), - ]); + await createCase(supertest, postCaseReq); + const caseWithDeleteAssignee1 = await createCase( + supertest, + getPostCaseRequest({ + assignees: [{ uid: profile[0].uid }], + }) + ); + const caseWithDeleteAssignee2 = await createCase( + supertest, + getPostCaseRequest({ + assignees: [{ uid: profile[0].uid }], + }) + ); const cases = await findCases({ supertest, @@ -202,21 +200,19 @@ export default ({ getService }: FtrProviderContext): void => { auth: { user: superUser, space: 'space1' }, }); - const [_, caseWithDeleteAssignee1, caseWithDeleteAssignee2] = await Promise.all([ - createCase(supertest, postCaseReq), - createCase( - supertest, - getPostCaseRequest({ - assignees: [{ uid: profileUidsToFilter[0].uid }], - }) - ), - createCase( - supertest, - getPostCaseRequest({ - assignees: [{ uid: profileUidsToFilter[1].uid }], - }) - ), - ]); + await createCase(supertest, postCaseReq); + const caseWithDeleteAssignee1 = await createCase( + supertest, + getPostCaseRequest({ + assignees: [{ uid: profileUidsToFilter[0].uid }], + }) + ); + const caseWithDeleteAssignee2 = await createCase( + supertest, + getPostCaseRequest({ + assignees: [{ uid: profileUidsToFilter[1].uid }], + }) + ); const cases = await findCases({ supertest, @@ -242,21 +238,20 @@ export default ({ getService }: FtrProviderContext): void => { auth: { user: superUser, space: 'space1' }, }); - const [caseWithNoAssignees] = await Promise.all([ - createCase(supertest, postCaseReq), - createCase( - supertest, - getPostCaseRequest({ - assignees: [{ uid: profile[0].uid }], - }) - ), - createCase( - supertest, - getPostCaseRequest({ - assignees: [{ uid: profile[0].uid }], - }) - ), - ]); + const caseWithNoAssignees = await createCase(supertest, postCaseReq); + await createCase( + supertest, + getPostCaseRequest({ + assignees: [{ uid: profile[0].uid }], + }) + ); + + await createCase( + supertest, + getPostCaseRequest({ + assignees: [{ uid: profile[0].uid }], + }) + ); const cases = await findCases({ supertest, @@ -282,21 +277,20 @@ export default ({ getService }: FtrProviderContext): void => { auth: { user: superUser, space: 'space1' }, }); - const [caseWithNoAssignees, caseWithDeleteAssignee1] = await Promise.all([ - createCase(supertest, postCaseReq), - createCase( - supertest, - getPostCaseRequest({ - assignees: [{ uid: profileUidsToFilter[0].uid }], - }) - ), - createCase( - supertest, - getPostCaseRequest({ - assignees: [{ uid: profileUidsToFilter[1].uid }], - }) - ), - ]); + const caseWithNoAssignees = await createCase(supertest, postCaseReq); + const caseWithDeleteAssignee1 = await createCase( + supertest, + getPostCaseRequest({ + assignees: [{ uid: profileUidsToFilter[0].uid }], + }) + ); + + await createCase( + supertest, + getPostCaseRequest({ + assignees: [{ uid: profileUidsToFilter[1].uid }], + }) + ); const cases = await findCases({ supertest, diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/find_rule_exception_references.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/find_rule_exception_references.ts index 7619c6f3e359ac..173aaa86c43282 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/find_rule_exception_references.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/find_rule_exception_references.ts @@ -210,7 +210,7 @@ export default ({ getService }: FtrProviderContext) => { .get(DETECTION_ENGINE_RULES_EXCEPTIONS_REFERENCE_URL) .set('kbn-xsrf', 'true') .query({ - namespace_types: `${exceptionList.namespace_type},${exceptionList2.namespace_type}`, + namespace_types: 'single,agnostic', }) .expect(200); diff --git a/x-pack/test/functional/apps/discover/async_scripted_fields.js b/x-pack/test/functional/apps/discover/async_scripted_fields.js index ba670ad78aa32f..9a9d5e0d450f21 100644 --- a/x-pack/test/functional/apps/discover/async_scripted_fields.js +++ b/x-pack/test/functional/apps/discover/async_scripted_fields.js @@ -77,7 +77,7 @@ export default function ({ getService, getPageObjects }) { it('query return results with valid scripted field', async function () { if (false) { - /* the commented-out steps below were used to create the scripted fields in the logstash-* index pattern + /* the skipped steps below were used to create the scripted fields in the logstash-* index pattern which are now saved in the esArchive. */ @@ -118,6 +118,7 @@ export default function ({ getService, getPageObjects }) { }); } + await PageObjects.common.navigateToApp('discover'); await PageObjects.discover.selectIndexPattern('logstash-*'); await queryBar.setQuery('php* OR *jpg OR *css*'); await testSubjects.click('querySubmitButton'); diff --git a/x-pack/test/functional/apps/lens/group3/index.ts b/x-pack/test/functional/apps/lens/group3/index.ts index 627e9d560ca210..30c8624d876b50 100644 --- a/x-pack/test/functional/apps/lens/group3/index.ts +++ b/x-pack/test/functional/apps/lens/group3/index.ts @@ -86,7 +86,6 @@ export default ({ getService, loadTestFile, getPageObjects }: FtrProviderContext loadTestFile(require.resolve('./error_handling')); loadTestFile(require.resolve('./lens_tagging')); loadTestFile(require.resolve('./lens_reporting')); - loadTestFile(require.resolve('./open_in_lens')); // keep these two last in the group in this order because they are messing with the default saved objects loadTestFile(require.resolve('./rollup')); loadTestFile(require.resolve('./no_data')); diff --git a/x-pack/test/functional/apps/lens/group3/open_in_lens/index.ts b/x-pack/test/functional/apps/lens/group3/open_in_lens/index.ts deleted file mode 100644 index b1d5a1cbb3c528..00000000000000 --- a/x-pack/test/functional/apps/lens/group3/open_in_lens/index.ts +++ /dev/null @@ -1,15 +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 { FtrProviderContext } from '../../../../ftr_provider_context'; - -export default function ({ loadTestFile }: FtrProviderContext) { - describe('Open in Lens', function () { - loadTestFile(require.resolve('./tsvb')); - loadTestFile(require.resolve('./agg_based')); - }); -} diff --git a/x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/gauge.ts b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/gauge.ts similarity index 98% rename from x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/gauge.ts rename to x-pack/test/functional/apps/lens/open_in_lens/agg_based/gauge.ts index 0d85d363d8b859..35838915ede310 100644 --- a/x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/gauge.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/gauge.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const { visualize, lens, timePicker, visEditor, visChart } = getPageObjects([ diff --git a/x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/goal.ts b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/goal.ts similarity index 98% rename from x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/goal.ts rename to x-pack/test/functional/apps/lens/open_in_lens/agg_based/goal.ts index 547d15856b7f14..d5b793b267131c 100644 --- a/x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/goal.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/goal.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const { visualize, lens, visChart, timePicker, visEditor } = getPageObjects([ diff --git a/x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/index.ts b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/index.ts similarity index 89% rename from x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/index.ts rename to x-pack/test/functional/apps/lens/open_in_lens/agg_based/index.ts index 0737c7ffeeb501..87c9d025893a1a 100644 --- a/x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/index.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('Agg based Vis to Lens', function () { diff --git a/x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/metric.ts b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/metric.ts similarity index 99% rename from x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/metric.ts rename to x-pack/test/functional/apps/lens/open_in_lens/agg_based/metric.ts index eef46d2c0cdb73..4958704801c8c9 100644 --- a/x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/metric.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/metric.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const { visEditor, visualize, lens, timePicker, visChart } = getPageObjects([ diff --git a/x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/pie.ts b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/pie.ts similarity index 98% rename from x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/pie.ts rename to x-pack/test/functional/apps/lens/open_in_lens/agg_based/pie.ts index ed08f1ea5ae09e..346aada45cea87 100644 --- a/x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/pie.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/pie.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const { visualize, visEditor, lens, timePicker, header } = getPageObjects([ diff --git a/x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/table.ts b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/table.ts similarity index 99% rename from x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/table.ts rename to x-pack/test/functional/apps/lens/open_in_lens/agg_based/table.ts index c03773e3276b16..1497eea84c851e 100644 --- a/x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/table.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/table.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const { visualize, visEditor, lens, timePicker, header } = getPageObjects([ diff --git a/x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/xy.ts b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/xy.ts similarity index 99% rename from x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/xy.ts rename to x-pack/test/functional/apps/lens/open_in_lens/agg_based/xy.ts index 9fd425984e3c5a..4c966536001a37 100644 --- a/x-pack/test/functional/apps/lens/group3/open_in_lens/agg_based/xy.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/xy.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const { visualize, visEditor, lens, timePicker, header, visChart } = getPageObjects([ diff --git a/x-pack/test/functional/apps/lens/open_in_lens/config.ts b/x-pack/test/functional/apps/lens/open_in_lens/config.ts new file mode 100644 index 00000000000000..d927f93adeffd0 --- /dev/null +++ b/x-pack/test/functional/apps/lens/open_in_lens/config.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrConfigProviderContext } from '@kbn/test'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const functionalConfig = await readConfigFile(require.resolve('../../../config.base.js')); + + return { + ...functionalConfig.getAll(), + testFiles: [require.resolve('.')], + }; +} diff --git a/x-pack/test/functional/apps/lens/open_in_lens/index.ts b/x-pack/test/functional/apps/lens/open_in_lens/index.ts new file mode 100644 index 00000000000000..5d81bfcb9a9272 --- /dev/null +++ b/x-pack/test/functional/apps/lens/open_in_lens/index.ts @@ -0,0 +1,75 @@ +/* + * 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 { EsArchiver } from '@kbn/es-archiver'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default ({ getService, loadTestFile, getPageObjects }: FtrProviderContext) => { + const browser = getService('browser'); + const log = getService('log'); + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const PageObjects = getPageObjects(['timePicker']); + const config = getService('config'); + let remoteEsArchiver; + + describe('lens app - Open in Lens', () => { + const esArchive = 'x-pack/test/functional/es_archives/logstash_functional'; + const localIndexPatternString = 'logstash-*'; + const remoteIndexPatternString = 'ftr-remote:logstash-*'; + const localFixtures = { + lensBasic: 'x-pack/test/functional/fixtures/kbn_archiver/lens/lens_basic.json', + lensDefault: 'x-pack/test/functional/fixtures/kbn_archiver/lens/default', + }; + + const remoteFixtures = { + lensBasic: 'x-pack/test/functional/fixtures/kbn_archiver/lens/ccs/lens_basic.json', + lensDefault: 'x-pack/test/functional/fixtures/kbn_archiver/lens/ccs/default', + }; + let esNode: EsArchiver; + let fixtureDirs: { + lensBasic: string; + lensDefault: string; + }; + let indexPatternString: string; + before(async () => { + log.debug('Starting lens before method'); + await browser.setWindowSize(1280, 1200); + try { + config.get('esTestCluster.ccs'); + remoteEsArchiver = getService('remoteEsArchiver' as 'esArchiver'); + esNode = remoteEsArchiver; + fixtureDirs = remoteFixtures; + indexPatternString = remoteIndexPatternString; + } catch (error) { + esNode = esArchiver; + fixtureDirs = localFixtures; + indexPatternString = localIndexPatternString; + } + + await esNode.load(esArchive); + // changing the timepicker default here saves us from having to set it in Discover (~8s) + await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); + await kibanaServer.uiSettings.update({ + defaultIndex: indexPatternString, + 'dateFormat:tz': 'UTC', + }); + await kibanaServer.importExport.load(fixtureDirs.lensBasic); + await kibanaServer.importExport.load(fixtureDirs.lensDefault); + }); + + after(async () => { + await esArchiver.unload(esArchive); + await PageObjects.timePicker.resetDefaultAbsoluteRangeViaUiSettings(); + await kibanaServer.importExport.unload(fixtureDirs.lensBasic); + await kibanaServer.importExport.unload(fixtureDirs.lensDefault); + }); + + loadTestFile(require.resolve('./tsvb')); + loadTestFile(require.resolve('./agg_based')); + }); +}; diff --git a/x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/dashboard.ts b/x-pack/test/functional/apps/lens/open_in_lens/tsvb/dashboard.ts similarity index 98% rename from x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/dashboard.ts rename to x-pack/test/functional/apps/lens/open_in_lens/tsvb/dashboard.ts index 292aaa3a36f057..72daa5ff5486b2 100644 --- a/x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/dashboard.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/tsvb/dashboard.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const { visualize, visualBuilder, lens, timeToVisualize, dashboard, canvas } = getPageObjects([ diff --git a/x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/gauge.ts b/x-pack/test/functional/apps/lens/open_in_lens/tsvb/gauge.ts similarity index 98% rename from x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/gauge.ts rename to x-pack/test/functional/apps/lens/open_in_lens/tsvb/gauge.ts index 872ce7a58a22c3..4655fd34accfaf 100644 --- a/x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/gauge.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/tsvb/gauge.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const { visualize, visualBuilder, lens, header } = getPageObjects([ diff --git a/x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/index.ts b/x-pack/test/functional/apps/lens/open_in_lens/tsvb/index.ts similarity index 89% rename from x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/index.ts rename to x-pack/test/functional/apps/lens/open_in_lens/tsvb/index.ts index ea859195e63460..c0b5197983aa49 100644 --- a/x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/index.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/tsvb/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('TSVB to Lens', function () { diff --git a/x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/metric.ts b/x-pack/test/functional/apps/lens/open_in_lens/tsvb/metric.ts similarity index 98% rename from x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/metric.ts rename to x-pack/test/functional/apps/lens/open_in_lens/tsvb/metric.ts index 794a2be110a32a..081b3787e39a79 100644 --- a/x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/metric.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/tsvb/metric.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const { visualize, visualBuilder, lens, header } = getPageObjects([ diff --git a/x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/timeseries.ts b/x-pack/test/functional/apps/lens/open_in_lens/tsvb/timeseries.ts similarity index 99% rename from x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/timeseries.ts rename to x-pack/test/functional/apps/lens/open_in_lens/tsvb/timeseries.ts index 4c0c7e66b1ba36..dc77e9fcedb9a0 100644 --- a/x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/timeseries.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/tsvb/timeseries.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const { visualize, visualBuilder, lens, header } = getPageObjects([ diff --git a/x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/top_n.ts b/x-pack/test/functional/apps/lens/open_in_lens/tsvb/top_n.ts similarity index 99% rename from x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/top_n.ts rename to x-pack/test/functional/apps/lens/open_in_lens/tsvb/top_n.ts index 0631872fc9bd4c..1192b38b03c69d 100644 --- a/x-pack/test/functional/apps/lens/group3/open_in_lens/tsvb/top_n.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/tsvb/top_n.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const { visualize, visualBuilder, lens, header } = getPageObjects([ diff --git a/x-pack/test/observability_functional/apps/observability/exploratory_view.ts b/x-pack/test/observability_functional/apps/observability/exploratory_view.ts index b3adaa556dac3d..9aa33a8e9f652b 100644 --- a/x-pack/test/observability_functional/apps/observability/exploratory_view.ts +++ b/x-pack/test/observability_functional/apps/observability/exploratory_view.ts @@ -85,8 +85,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.header.waitUntilLoadingHasFinished(); - expect(await find.existsByCssSelector('[title="Chrome Mobile iOS"]')).to.eql(true); - expect(await find.existsByCssSelector('[title="Mobile Safari"]')).to.eql(true); + expect( + await find.existsByCssSelector( + '[aria-label="Chrome Mobile iOS; Activate to hide series in graph"]' + ) + ).to.eql(true); + expect( + await find.existsByCssSelector( + '[aria-label="Mobile Safari; Activate to hide series in graph"]' + ) + ).to.eql(true); }); }); } diff --git a/x-pack/test/observability_functional/apps/observability/pages/alerts/index.ts b/x-pack/test/observability_functional/apps/observability/pages/alerts/index.ts index cdb0ea37a6417c..7052dcba7ff230 100644 --- a/x-pack/test/observability_functional/apps/observability/pages/alerts/index.ts +++ b/x-pack/test/observability_functional/apps/observability/pages/alerts/index.ts @@ -231,15 +231,6 @@ export default ({ getService }: FtrProviderContext) => { }); }); - /* - * ATTENTION FUTURE DEVELOPER - * - * These tests should only be valid for 7.17.x - * You can run this test if you go to this file: - * x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx - * and at line 397 and change showCheckboxes to true - * - */ describe.skip('Bulk Actions', () => { before(async () => { await security.testUser.setRoles(['global_alerts_logs_all_else_read']); diff --git a/x-pack/test/plugin_functional/config.ts b/x-pack/test/plugin_functional/config.ts index 361318c0992a37..19846669f48ba0 100644 --- a/x-pack/test/plugin_functional/config.ts +++ b/x-pack/test/plugin_functional/config.ts @@ -31,7 +31,6 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { testFiles: [ resolve(__dirname, './test_suites/resolver'), resolve(__dirname, './test_suites/global_search'), - resolve(__dirname, './test_suites/timelines'), ], services, @@ -62,9 +61,6 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { resolverTest: { pathname: '/app/resolverTest', }, - timelineTest: { - pathname: '/app/timelinesTest', - }, }, // choose where screenshots should be saved diff --git a/x-pack/test/plugin_functional/plugins/timelines_test/kibana.json b/x-pack/test/plugin_functional/plugins/timelines_test/kibana.json deleted file mode 100644 index 1960c498395661..00000000000000 --- a/x-pack/test/plugin_functional/plugins/timelines_test/kibana.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "id": "timelinesTest", - "owner": { "name": "Security solution", "githubTeam": "security-solution" }, - "version": "1.0.0", - "kibanaVersion": "kibana", - "configPath": ["xpack", "timelinesTest"], - "requiredPlugins": ["timelines", "data"], - "requiredBundles": ["kibanaReact"], - "server": false, - "ui": true -} diff --git a/x-pack/test/plugin_functional/plugins/timelines_test/public/applications/timelines_test/index.tsx b/x-pack/test/plugin_functional/plugins/timelines_test/public/applications/timelines_test/index.tsx deleted file mode 100644 index 6b576012afb81b..00000000000000 --- a/x-pack/test/plugin_functional/plugins/timelines_test/public/applications/timelines_test/index.tsx +++ /dev/null @@ -1,100 +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 { Router } from 'react-router-dom'; -import React, { useCallback, useRef } from 'react'; -import ReactDOM from 'react-dom'; -import { AppMountParameters, CoreStart } from '@kbn/core/public'; -import { I18nProvider } from '@kbn/i18n-react'; -import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; -import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; -import { TimelinesUIStart } from '@kbn/timelines-plugin/public'; -import { DataPublicPluginStart } from '@kbn/data-plugin/public'; - -type CoreStartTimelines = CoreStart & { data: DataPublicPluginStart }; - -/** - * Render the Timeline Test app. Returns a cleanup function. - */ -export function renderApp( - coreStart: CoreStartTimelines, - parameters: AppMountParameters, - timelinesPluginSetup: TimelinesUIStart | null -) { - ReactDOM.render( - , - parameters.element - ); - - return () => { - ReactDOM.unmountComponentAtNode(parameters.element); - }; -} - -const AppRoot = React.memo( - ({ - coreStart, - parameters, - timelinesPluginSetup, - }: { - coreStart: CoreStartTimelines; - parameters: AppMountParameters; - timelinesPluginSetup: TimelinesUIStart | null; - }) => { - const refetch = useRef(); - - const setRefetch = useCallback((_refetch) => { - refetch.current = _refetch; - }, []); - - const hasAlertsCrudPermissions = useCallback(() => true, []); - - return ( - - - - - {(timelinesPluginSetup && - timelinesPluginSetup.getTGrid && - timelinesPluginSetup.getTGrid<'standalone'>({ - type: 'standalone', - columns: [], - indexNames: [], - deletedEventIds: [], - disabledCellActions: [], - end: '', - filters: [], - hasAlertsCrudPermissions, - itemsPerPageOptions: [1, 2, 3], - loadingText: 'Loading events', - renderCellValue: () =>
test
, - sort: [], - leadingControlColumns: [], - trailingControlColumns: [], - query: { - query: '', - language: 'kuery', - }, - setRefetch, - start: '', - rowRenderers: [], - runtimeMappings: {}, - filterStatus: 'open', - unit: (n: number) => `${n}`, - })) ?? - null} -
-
-
-
- ); - } -); diff --git a/x-pack/test/plugin_functional/plugins/timelines_test/public/index.ts b/x-pack/test/plugin_functional/plugins/timelines_test/public/index.ts deleted file mode 100644 index 540e23622b2b38..00000000000000 --- a/x-pack/test/plugin_functional/plugins/timelines_test/public/index.ts +++ /dev/null @@ -1,20 +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 { PluginInitializer } from '@kbn/core/public'; -import { - TimelinesTestPlugin, - TimelinesTestPluginSetupDependencies, - TimelinesTestPluginStartDependencies, -} from './plugin'; - -export const plugin: PluginInitializer< - void, - void, - TimelinesTestPluginSetupDependencies, - TimelinesTestPluginStartDependencies -> = () => new TimelinesTestPlugin(); diff --git a/x-pack/test/plugin_functional/plugins/timelines_test/public/plugin.ts b/x-pack/test/plugin_functional/plugins/timelines_test/public/plugin.ts deleted file mode 100644 index 13758e02603a34..00000000000000 --- a/x-pack/test/plugin_functional/plugins/timelines_test/public/plugin.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { Plugin, CoreStart, CoreSetup, AppMountParameters } from '@kbn/core/public'; -import { i18n } from '@kbn/i18n'; -import { TimelinesUIStart } from '@kbn/timelines-plugin/public'; -import { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import { renderApp } from './applications/timelines_test'; - -export type TimelinesTestPluginSetup = void; -export type TimelinesTestPluginStart = void; -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface TimelinesTestPluginSetupDependencies {} - -export interface TimelinesTestPluginStartDependencies { - timelines: TimelinesUIStart; - data: DataPublicPluginStart; -} - -export class TimelinesTestPlugin - implements - Plugin< - TimelinesTestPluginSetup, - void, - TimelinesTestPluginSetupDependencies, - TimelinesTestPluginStartDependencies - > -{ - private timelinesPlugin: TimelinesUIStart | null = null; - public setup( - core: CoreSetup, - setupDependencies: TimelinesTestPluginSetupDependencies - ) { - core.application.register({ - id: 'timelinesTest', - title: i18n.translate('xpack.timelinesTest.pluginTitle', { - defaultMessage: 'Timelines Test', - }), - mount: async (params: AppMountParameters) => { - const startServices = await core.getStartServices(); - const [coreStart, { data }] = startServices; - return renderApp({ ...coreStart, data }, params, this.timelinesPlugin); - }, - }); - } - - public start(core: CoreStart, { timelines }: TimelinesTestPluginStartDependencies) { - this.timelinesPlugin = timelines; - } -} diff --git a/x-pack/test/plugin_functional/test_suites/timelines/index.ts b/x-pack/test/plugin_functional/test_suites/timelines/index.ts deleted file mode 100644 index 955966eab12c06..00000000000000 --- a/x-pack/test/plugin_functional/test_suites/timelines/index.ts +++ /dev/null @@ -1,24 +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 { FtrProviderContext } from '../../ftr_provider_context'; - -export default function ({ getPageObjects, getService }: FtrProviderContext) { - describe('Timelines plugin API', function () { - const pageObjects = getPageObjects(['common']); - const testSubjects = getService('testSubjects'); - - describe('timelines plugin rendering', function () { - before(async () => { - await pageObjects.common.navigateToApp('timelineTest'); - }); - it('shows the timeline component on navigation', async () => { - await testSubjects.existOrFail('events-viewer-panel'); - }); - }); - }); -} diff --git a/x-pack/test/security_api_integration/fixtures/user_profiles/user_profiles_consumer/server/init_routes.ts b/x-pack/test/security_api_integration/fixtures/user_profiles/user_profiles_consumer/server/init_routes.ts index aa45cb7e3bb4ff..091e50ff173507 100644 --- a/x-pack/test/security_api_integration/fixtures/user_profiles/user_profiles_consumer/server/init_routes.ts +++ b/x-pack/test/security_api_integration/fixtures/user_profiles/user_profiles_consumer/server/init_routes.ts @@ -16,8 +16,13 @@ export function initRoutes(core: CoreSetup) { path: '/internal/user_profiles_consumer/_suggest', validate: { body: schema.object({ - name: schema.string(), + name: schema.maybe(schema.string()), dataPath: schema.maybe(schema.string()), + hint: schema.maybe( + schema.object({ + uids: schema.arrayOf(schema.string()), + }) + ), size: schema.maybe(schema.number()), requiredAppPrivileges: schema.maybe(schema.arrayOf(schema.string())), }), @@ -28,6 +33,7 @@ export function initRoutes(core: CoreSetup) { const profiles = await pluginDeps.security.userProfiles.suggest({ name: request.body.name, dataPath: request.body.dataPath, + hint: request.body.hint, size: request.body.size, requiredPrivileges: request.body.requiredAppPrivileges ? { diff --git a/x-pack/test/security_api_integration/tests/user_profiles/suggest.ts b/x-pack/test/security_api_integration/tests/user_profiles/suggest.ts index 1e45f0edacf37b..cf58f1b35d3b5a 100644 --- a/x-pack/test/security_api_integration/tests/user_profiles/suggest.ts +++ b/x-pack/test/security_api_integration/tests/user_profiles/suggest.ts @@ -305,7 +305,7 @@ export default function ({ getService }: FtrProviderContext) { .post('/internal/security/user_profile/_data') .set('kbn-xsrf', 'xxx') .set('Cookie', usersSessions.get('user_one')!.cookie.cookieString()) - .send({ some: 'data', some_nested: { data: 'nested_data' } }) + .send({ some: 'data', some_more: 'data', some_nested: { data: 'nested_data' } }) .expect(200); // 2. Data is not returned by default @@ -334,7 +334,7 @@ export default function ({ getService }: FtrProviderContext) { suggestions = await supertest .post('/internal/user_profiles_consumer/_suggest') .set('kbn-xsrf', 'xxx') - .send({ name: 'one', requiredAppPrivileges: ['discover'], dataPath: 'some' }) + .send({ name: 'one', requiredAppPrivileges: ['discover'], dataPath: 'some,some_more' }) .expect(200); expect(suggestions.body).to.have.length(1); expectSnapshot( @@ -344,6 +344,7 @@ export default function ({ getService }: FtrProviderContext) { Object { "data": Object { "some": "data", + "some_more": "data", }, "user": Object { "email": "one@elastic.co", @@ -368,6 +369,7 @@ export default function ({ getService }: FtrProviderContext) { Object { "data": Object { "some": "data", + "some_more": "data", "some_nested": Object { "data": "nested_data", }, @@ -381,5 +383,31 @@ export default function ({ getService }: FtrProviderContext) { ] `); }); + + it('can get suggestions with hints', async () => { + const profile = await supertestWithoutAuth + .get('/internal/security/user_profile') + .set('kbn-xsrf', 'xxx') + .set('Cookie', usersSessions.get('user_three')!.cookie.cookieString()) + .expect(200); + + expect(profile.body.uid).not.to.be.empty(); + + const suggestions = await supertest + .post('/internal/user_profiles_consumer/_suggest') + .set('kbn-xsrf', 'xxx') + .send({ hint: { uids: [profile.body.uid] } }) + .expect(200); + + // `user_three` should be first in list + expect(suggestions.body.length).to.be.above(0); + expectSnapshot(suggestions.body[0].user).toMatchInline(` + Object { + "email": "three@elastic.co", + "full_name": "THREE", + "username": "user_three", + } + `); + }); }); } diff --git a/yarn.lock b/yarn.lock index a7a1440720c616..1bf736f1aa393c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12381,6 +12381,13 @@ cross-env@^6.0.3: dependencies: cross-spawn "^7.0.0" +cross-fetch@3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f" + integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw== + dependencies: + node-fetch "2.6.7" + cross-spawn@^6.0.0: version "6.0.5" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" @@ -13242,13 +13249,6 @@ debug@4.1.1: dependencies: ms "^2.1.1" -debug@4.3.1: - version "4.3.1" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" - integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== - dependencies: - ms "2.1.2" - debuglog@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492" @@ -13594,10 +13594,10 @@ detective@^5.0.2: defined "^1.0.0" minimist "^1.1.1" -devtools-protocol@0.0.901419: - version "0.0.901419" - resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.901419.tgz#79b5459c48fe7e1c5563c02bd72f8fec3e0cebcd" - integrity sha512-4INMPwNm9XRpBukhNbF7OB6fNTTCaI8pzy/fXg0xQzAy5h3zL1P8xT3QazgKqBrb/hAYwIBizqDBZ7GtJE74QQ== +devtools-protocol@0.0.1045489: + version "0.0.1045489" + resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1045489.tgz#f959ad560b05acd72d55644bc3fb8168a83abf28" + integrity sha512-D+PTmWulkuQW4D1NTiCRCFxF7pQPn0hgp4YyX4wAQ6xYXKOadSWPR3ENGDQ47MW/Ewc9v2rpC/UEEGahgBYpSQ== dezalgo@^1.0.0: version "1.0.3" @@ -17060,7 +17060,7 @@ https-proxy-agent@5.0.0: agent-base "6" debug "4" -https-proxy-agent@^5.0.0, https-proxy-agent@^5.0.1: +https-proxy-agent@5.0.1, https-proxy-agent@^5.0.0, https-proxy-agent@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== @@ -21027,7 +21027,7 @@ node-emoji@^1.10.0: dependencies: lodash.toarray "^4.4.0" -node-fetch@2.6.1, node-fetch@^1.0.1, node-fetch@^2.3.0, node-fetch@^2.6.1, node-fetch@^2.6.7: +node-fetch@2.6.7, node-fetch@^1.0.1, node-fetch@^2.3.0, node-fetch@^2.6.1, node-fetch@^2.6.7: version "2.6.7" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== @@ -22281,13 +22281,6 @@ pixelmatch@^5.3.0: dependencies: pngjs "^6.0.0" -pkg-dir@4.2.0, pkg-dir@^4.1.0, pkg-dir@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" - integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== - dependencies: - find-up "^4.0.0" - pkg-dir@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-2.0.0.tgz#f6d5d1109e19d63edf428e0bd57e12777615334b" @@ -22302,6 +22295,13 @@ pkg-dir@^3.0.0: dependencies: find-up "^3.0.0" +pkg-dir@^4.1.0, pkg-dir@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" + integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== + dependencies: + find-up "^4.0.0" + pkg-dir@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-5.0.0.tgz#a02d6aebe6ba133a928f74aec20bafdfe6b8e760" @@ -22926,21 +22926,16 @@ process@^0.11.10: resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI= -progress@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.1.tgz#c9242169342b1c29d275889c95734621b1952e31" - integrity sha512-OE+a6vzqazc+K6LxJrX5UPyKFvGnL5CYmq2jFGNIBWHpc4QyE49/YOumcrpQFJpfejmvRtbJzgO1zPmMCqlbBg== +progress@2.0.3, progress@^2.0.0, progress@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" + integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== progress@^1.1.8: version "1.1.8" resolved "https://registry.yarnpkg.com/progress/-/progress-1.1.8.tgz#e260c78f6161cdd9b0e56cc3e0a85de17c7a57be" integrity sha1-4mDHj2Fhzdmw5WzD4Khd4Xx6V74= -progress@^2.0.0, progress@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" - integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== - proj4@2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/proj4/-/proj4-2.6.2.tgz#4665d7cbc30fd356375007c2fed53b07dbda1d67" @@ -23190,23 +23185,22 @@ pupa@^2.1.1: dependencies: escape-goat "^2.0.0" -puppeteer@^10.2.0: - version "10.4.0" - resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-10.4.0.tgz#a6465ff97fda0576c4ac29601406f67e6fea3dc7" - integrity sha512-2cP8mBoqnu5gzAVpbZ0fRaobBWZM8GEUF4I1F6WbgHrKV/rz7SX8PG2wMymZgD0wo0UBlg2FBPNxlF/xlqW6+w== +puppeteer@18.1.0: + version "18.1.0" + resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-18.1.0.tgz#7fa53b29f87dfb3192d415f38a46e35b107ec907" + integrity sha512-2RCVWIF+pZOSfksWlQU0Hh6CeUT5NYt66CDDgRyuReu6EvBAk1y+/Q7DuzYNvGChSecGMb7QPN0hkxAa3guAog== dependencies: - debug "4.3.1" - devtools-protocol "0.0.901419" + cross-fetch "3.1.5" + debug "4.3.4" + devtools-protocol "0.0.1045489" extract-zip "2.0.1" - https-proxy-agent "5.0.0" - node-fetch "2.6.1" - pkg-dir "4.2.0" - progress "2.0.1" + https-proxy-agent "5.0.1" + progress "2.0.3" proxy-from-env "1.1.0" rimraf "3.0.2" - tar-fs "2.0.0" - unbzip2-stream "1.3.3" - ws "7.4.6" + tar-fs "2.1.1" + unbzip2-stream "1.4.3" + ws "8.9.0" q@^1.5.1: version "1.5.1" @@ -26638,17 +26632,7 @@ tape@^5.0.1: string.prototype.trim "^1.2.1" through "^2.3.8" -tar-fs@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.0.0.tgz#677700fc0c8b337a78bee3623fdc235f21d7afad" - integrity sha512-vaY0obB6Om/fso8a8vakQBzwholQ7v5+uy+tF3Ozvxv1KNezmVQAiWtcNmMHFSFPqL3dJA8ha6gdtFbfX9mcxA== - dependencies: - chownr "^1.1.1" - mkdirp "^0.5.1" - pump "^3.0.0" - tar-stream "^2.0.0" - -tar-fs@^2.0.0, tar-fs@^2.1.1: +tar-fs@2.1.1, tar-fs@^2.0.0, tar-fs@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784" integrity sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng== @@ -26658,7 +26642,7 @@ tar-fs@^2.0.0, tar-fs@^2.1.1: pump "^3.0.0" tar-stream "^2.1.4" -tar-stream@^2.0.0, tar-stream@^2.1.4, tar-stream@^2.2.0: +tar-stream@^2.1.4, tar-stream@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287" integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== @@ -27448,10 +27432,10 @@ unbox-primitive@^1.0.1: has-symbols "^1.0.2" which-boxed-primitive "^1.0.2" -unbzip2-stream@1.3.3: - version "1.3.3" - resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.3.3.tgz#d156d205e670d8d8c393e1c02ebd506422873f6a" - integrity sha512-fUlAF7U9Ah1Q6EieQ4x4zLNejrRvDWUYmxXUpN3uziFYCHapjWFaCAnreY9bGgxzaMCFAPPpYNng57CypwJVhg== +unbzip2-stream@1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz#b0da04c4371311df771cdc215e87f2130991ace7" + integrity sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg== dependencies: buffer "^5.2.1" through "^2.3.8" @@ -29112,10 +29096,10 @@ write-file-atomic@^4.0.1: imurmurhash "^0.1.4" signal-exit "^3.0.7" -ws@7.4.6: - version "7.4.6" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c" - integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A== +ws@8.9.0: + version "8.9.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.9.0.tgz#2a994bb67144be1b53fe2d23c53c028adeb7f45e" + integrity sha512-Ja7nszREasGaYUYCI2k4lCKIRTt+y7XuqVoHR44YpI49TtryyqbqvDMn5eqfW7e6HzTukDRIsXqzVHScqRcafg== ws@>=8.7.0, ws@^8.2.3, ws@^8.4.2: version "8.8.0"