[8.16] [Security Solution][Notes] - fix user filter not checking correct license in notes management page (#197149) (#197244)

# Backport

This will backport the following commits from `main` to `8.16`:
- [[Security Solution][Notes] - fix user filter not checking correct
license in notes management page
(#197149)](https://github.com/elastic/kibana/pull/197149)

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Philippe
Oberti","email":"philippe.oberti@elastic.co"},"sourceCommit":{"committedDate":"2024-10-22T13:32:48Z","message":"[Security
Solution][Notes] - fix user filter not checking correct license in notes
management page
(#197149)","sha":"dcd8e0c614183ae648e00979eb82123656076d16","branchLabelMapping":{"^v9.0.0$":"main","^v8.17.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["backport","release_note:skip","v9.0.0","Team:Threat
Hunting:Investigations","v8.16.0"],"title":"[Security Solution][Notes] -
fix user filter not checking correct license in notes management
page","number":197149,"url":"https://github.com/elastic/kibana/pull/197149","mergeCommit":{"message":"[Security
Solution][Notes] - fix user filter not checking correct license in notes
management page
(#197149)","sha":"dcd8e0c614183ae648e00979eb82123656076d16"}},"sourceBranch":"main","suggestedTargetBranches":["8.16"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/197149","number":197149,"mergeCommit":{"message":"[Security
Solution][Notes] - fix user filter not checking correct license in notes
management page
(#197149)","sha":"dcd8e0c614183ae648e00979eb82123656076d16"}},{"branch":"8.16","label":"v8.16.0","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

Co-authored-by: Philippe Oberti <philippe.oberti@elastic.co>
This commit is contained in:
Kibana Machine 2024-10-23 02:22:56 +11:00 committed by GitHub
parent bbe0c0602a
commit 0b47f783e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 202 additions and 61 deletions

View file

@ -46,3 +46,11 @@ export const ALERT_SUPPRESSION_RULE_DETAILS = i18n.translate(
'Alert suppression is configured but will not be applied due to insufficient licensing',
}
);
export const UPGRADE_NOTES_MANAGEMENT_USER_FILTER = (requiredLicense: string) =>
i18n.translate('securitySolutionPackages.noteManagement.userFilter.upsell', {
defaultMessage: 'Upgrade to {requiredLicense} to make use of user filters',
values: {
requiredLicense,
},
});

View file

@ -27,4 +27,5 @@ export type UpsellingMessageId =
| 'investigation_guide_interactions'
| 'alert_assignments'
| 'alert_suppression_rule_form'
| 'alert_suppression_rule_details';
| 'alert_suppression_rule_details'
| 'note_management_user_filter';

View file

@ -13,10 +13,6 @@ import { suggestUsers } from './api';
import { USER_PROFILES_FAILURE } from './translations';
import { useAppToasts } from '../../hooks/use_app_toasts';
export interface SuggestUserProfilesArgs {
searchTerm: string;
}
export const bulkGetUserProfiles = async ({
searchTerm,
}: {
@ -25,7 +21,21 @@ export const bulkGetUserProfiles = async ({
return suggestUsers({ searchTerm });
};
export const useSuggestUsers = ({ searchTerm }: { searchTerm: string }) => {
export interface UseSuggestUsersParams {
/**
* Search term to filter user profiles
*/
searchTerm: string;
/**
* Whether the query should be enabled
*/
enabled?: boolean;
}
/**
* Fetches user profiles based on a search term
*/
export const useSuggestUsers = ({ enabled = true, searchTerm }: UseSuggestUsersParams) => {
const { addError } = useAppToasts();
return useQuery<UserProfileWithAvatar[]>(
@ -36,6 +46,7 @@ export const useSuggestUsers = ({ searchTerm }: { searchTerm: string }) => {
{
retry: false,
staleTime: Infinity,
enabled,
onError: (e) => {
addError(e, { title: USER_PROFILES_FAILURE });
},

View file

@ -5,13 +5,14 @@
* 2.0.
*/
import { fireEvent, render, screen } from '@testing-library/react';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { SearchRow } from './search_row';
import { ASSOCIATED_NOT_SELECT_TEST_ID, SEARCH_BAR_TEST_ID, USER_SELECT_TEST_ID } from './test_ids';
import { AssociatedFilter } from '../../../common/notes/constants';
import { useSuggestUsers } from '../../common/components/user_profiles/use_suggest_users';
import { TestProviders } from '../../common/mock';
jest.mock('../../common/components/user_profiles/use_suggest_users');
@ -35,7 +36,11 @@ describe('SearchRow', () => {
});
it('should render the component', () => {
const { getByTestId } = render(<SearchRow />);
const { getByTestId } = render(
<TestProviders>
<SearchRow />
</TestProviders>
);
expect(getByTestId(SEARCH_BAR_TEST_ID)).toBeInTheDocument();
expect(getByTestId(USER_SELECT_TEST_ID)).toBeInTheDocument();
@ -43,7 +48,11 @@ describe('SearchRow', () => {
});
it('should call the correct action when entering a value in the search bar', async () => {
const { getByTestId } = render(<SearchRow />);
const { getByTestId } = render(
<TestProviders>
<SearchRow />
</TestProviders>
);
const searchBox = getByTestId(SEARCH_BAR_TEST_ID);
@ -53,20 +62,12 @@ describe('SearchRow', () => {
expect(mockDispatch).toHaveBeenCalled();
});
it('should call the correct action when select a user', async () => {
const { getByTestId } = render(<SearchRow />);
const userSelect = getByTestId('comboBoxSearchInput');
userSelect.focus();
const option = await screen.findByText('test');
fireEvent.click(option);
expect(mockDispatch).toHaveBeenCalled();
});
it('should call the correct action when select a value in the associated note dropdown', async () => {
const { getByTestId } = render(<SearchRow />);
const { getByTestId } = render(
<TestProviders>
<SearchRow />
</TestProviders>
);
const associatedNoteSelect = getByTestId(ASSOCIATED_NOT_SELECT_TEST_ID);
await userEvent.selectOptions(associatedNoteSelect, [AssociatedFilter.documentOnly]);

View file

@ -5,10 +5,9 @@
* 2.0.
*/
import React, { useMemo, useCallback, useState } from 'react';
import React, { useCallback } from 'react';
import type { EuiSelectOption } from '@elastic/eui';
import {
EuiComboBox,
EuiFlexGroup,
EuiFlexItem,
EuiSearchBar,
@ -16,17 +15,12 @@ import {
useGeneratedHtmlId,
} from '@elastic/eui';
import { useDispatch } from 'react-redux';
import type { UserProfileWithAvatar } from '@kbn/user-profile-components';
import { i18n } from '@kbn/i18n';
import type { EuiComboBoxOptionOption } from '@elastic/eui/src/components/combo_box/types';
import { ASSOCIATED_NOT_SELECT_TEST_ID, SEARCH_BAR_TEST_ID, USER_SELECT_TEST_ID } from './test_ids';
import { useSuggestUsers } from '../../common/components/user_profiles/use_suggest_users';
import { userFilterAssociatedNotes, userFilterUsers, userSearchedNotes } from '..';
import { UserFilterDropdown } from './user_filter_dropdown';
import { ASSOCIATED_NOT_SELECT_TEST_ID, SEARCH_BAR_TEST_ID } from './test_ids';
import { userFilterAssociatedNotes, userSearchedNotes } from '..';
import { AssociatedFilter } from '../../../common/notes/constants';
export const USERS_DROPDOWN = i18n.translate('xpack.securitySolution.notes.usersDropdownLabel', {
defaultMessage: 'Users',
});
const FILTER_SELECT = i18n.translate('xpack.securitySolution.notes.management.filterSelect', {
defaultMessage: 'Select filter',
});
@ -55,26 +49,6 @@ export const SearchRow = React.memo(() => {
[dispatch]
);
const { isLoading: isLoadingSuggestedUsers, data: userProfiles } = useSuggestUsers({
searchTerm: '',
});
const users = useMemo(
() =>
(userProfiles || []).map((userProfile: UserProfileWithAvatar) => ({
label: userProfile.user.full_name || userProfile.user.username,
})),
[userProfiles]
);
const [selectedUser, setSelectedUser] = useState<Array<EuiComboBoxOptionOption<string>>>();
const onChange = useCallback(
(user: Array<EuiComboBoxOptionOption<string>>) => {
setSelectedUser(user);
dispatch(userFilterUsers(user.length > 0 ? user[0].label : ''));
},
[dispatch]
);
const onAssociatedNoteSelectChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
dispatch(userFilterAssociatedNotes(e.target.value as AssociatedFilter));
@ -88,15 +62,7 @@ export const SearchRow = React.memo(() => {
<EuiSearchBar box={searchBox} onChange={onQueryChange} defaultQuery="" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiComboBox
prepend={USERS_DROPDOWN}
singleSelection={{ asPlainText: true }}
options={users}
selectedOptions={selectedUser}
onChange={onChange}
isLoading={isLoadingSuggestedUsers}
data-test-subj={USER_SELECT_TEST_ID}
/>
<UserFilterDropdown />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSelect

View file

@ -0,0 +1,69 @@
/*
* 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 { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { UserFilterDropdown } from './user_filter_dropdown';
import { USER_SELECT_TEST_ID } from './test_ids';
import { useSuggestUsers } from '../../common/components/user_profiles/use_suggest_users';
import { useLicense } from '../../common/hooks/use_license';
import { useUpsellingMessage } from '../../common/hooks/use_upselling';
jest.mock('../../common/components/user_profiles/use_suggest_users');
jest.mock('../../common/hooks/use_license');
jest.mock('../../common/hooks/use_upselling');
const mockDispatch = jest.fn();
jest.mock('react-redux', () => {
const original = jest.requireActual('react-redux');
return {
...original,
useDispatch: () => mockDispatch,
};
});
describe('UserFilterDropdown', () => {
beforeEach(() => {
jest.clearAllMocks();
(useSuggestUsers as jest.Mock).mockReturnValue({
isLoading: false,
data: [{ user: { username: 'test' } }, { user: { username: 'elastic' } }],
});
(useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => true });
(useUpsellingMessage as jest.Mock).mockReturnValue('upsellingMessage');
});
it('should render the component enabled', () => {
const { getByTestId } = render(<UserFilterDropdown />);
const dropdown = getByTestId(USER_SELECT_TEST_ID);
expect(dropdown).toBeInTheDocument();
expect(dropdown).not.toHaveClass('euiComboBox-isDisabled');
});
it('should render the dropdown disabled', async () => {
(useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => false });
const { getByTestId } = render(<UserFilterDropdown />);
expect(getByTestId(USER_SELECT_TEST_ID)).toHaveClass('euiComboBox-isDisabled');
});
it('should call the correct action when select a user', async () => {
const { getByTestId } = render(<UserFilterDropdown />);
const userSelect = getByTestId('comboBoxSearchInput');
userSelect.focus();
const option = await screen.findByText('test');
fireEvent.click(option);
expect(mockDispatch).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,79 @@
/*
* 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, { useMemo, useCallback, useState } from 'react';
import { EuiComboBox, EuiToolTip } from '@elastic/eui';
import { useDispatch } from 'react-redux';
import type { UserProfileWithAvatar } from '@kbn/user-profile-components';
import { i18n } from '@kbn/i18n';
import type { EuiComboBoxOptionOption } from '@elastic/eui/src/components/combo_box/types';
import { useLicense } from '../../common/hooks/use_license';
import { useUpsellingMessage } from '../../common/hooks/use_upselling';
import { USER_SELECT_TEST_ID } from './test_ids';
import { useSuggestUsers } from '../../common/components/user_profiles/use_suggest_users';
import { userFilterUsers } from '..';
export const USERS_DROPDOWN = i18n.translate('xpack.securitySolution.notes.usersDropdownLabel', {
defaultMessage: 'Users',
});
export const UserFilterDropdown = React.memo(() => {
const dispatch = useDispatch();
const isPlatinumPlus = useLicense().isPlatinumPlus();
const upsellingMessage = useUpsellingMessage('note_management_user_filter');
const { isLoading, data } = useSuggestUsers({
searchTerm: '',
enabled: isPlatinumPlus,
});
const users = useMemo(
() =>
(data || []).map((userProfile: UserProfileWithAvatar) => ({
label: userProfile.user.full_name || userProfile.user.username,
})),
[data]
);
const [selectedUser, setSelectedUser] = useState<Array<EuiComboBoxOptionOption<string>>>();
const onChange = useCallback(
(user: Array<EuiComboBoxOptionOption<string>>) => {
setSelectedUser(user);
dispatch(userFilterUsers(user.length > 0 ? user[0].label : ''));
},
[dispatch]
);
const dropdown = useMemo(
() => (
<EuiComboBox
prepend={USERS_DROPDOWN}
singleSelection={{ asPlainText: true }}
options={users}
selectedOptions={selectedUser}
onChange={onChange}
isLoading={isPlatinumPlus && isLoading}
isDisabled={!isPlatinumPlus}
data-test-subj={USER_SELECT_TEST_ID}
/>
),
[isLoading, isPlatinumPlus, onChange, selectedUser, users]
);
return (
<>
{isPlatinumPlus ? (
<>{dropdown}</>
) : (
<EuiToolTip position="bottom" content={upsellingMessage}>
{dropdown}
</EuiToolTip>
)}
</>
);
});
UserFilterDropdown.displayName = 'UserFilterDropdown';

View file

@ -12,6 +12,7 @@ import {
ALERT_SUPPRESSION_RULE_FORM,
UPGRADE_ALERT_ASSIGNMENTS,
UPGRADE_INVESTIGATION_GUIDE,
UPGRADE_NOTES_MANAGEMENT_USER_FILTER,
} from '@kbn/security-solution-upselling/messages';
import type {
MessageUpsellings,
@ -132,4 +133,9 @@ export const upsellingMessages: UpsellingMessages = [
minimumLicenseRequired: 'platinum',
message: ALERT_SUPPRESSION_RULE_DETAILS,
},
{
id: 'note_management_user_filter',
minimumLicenseRequired: 'platinum',
message: UPGRADE_NOTES_MANAGEMENT_USER_FILTER('Platinum'),
},
];