Skip to content

Commit

Permalink
[Security Solution][Notes] - fix user filter not checking correct lic…
Browse files Browse the repository at this point in the history
…ense in notes management page (elastic#197149)
  • Loading branch information
PhilippeOberti authored Oct 22, 2024
1 parent 6d7fecd commit dcd8e0c
Show file tree
Hide file tree
Showing 8 changed files with 202 additions and 61 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
});
3 changes: 2 additions & 1 deletion x-pack/packages/security-solution/upselling/service/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}: {
Expand All @@ -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[]>(
Expand All @@ -36,6 +46,7 @@ export const useSuggestUsers = ({ searchTerm }: { searchTerm: string }) => {
{
retry: false,
staleTime: Infinity,
enabled,
onError: (e) => {
addError(e, { title: USER_PROFILES_FAILURE });
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -35,15 +36,23 @@ 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();
expect(getByTestId(ASSOCIATED_NOT_SELECT_TEST_ID)).toBeInTheDocument();
});

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);

Expand All @@ -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]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,22 @@
* 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,
EuiSelect,
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',
});
Expand Down Expand Up @@ -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));
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'),
},
];

0 comments on commit dcd8e0c

Please sign in to comment.