diff --git a/oas_docs/output/kibana.serverless.staging.yaml b/oas_docs/output/kibana.serverless.staging.yaml index 6df65e8ae2e3e..d7b1b6d02323a 100644 --- a/oas_docs/output/kibana.serverless.staging.yaml +++ b/oas_docs/output/kibana.serverless.staging.yaml @@ -35261,6 +35261,11 @@ paths: schema: nullable: true type: string + - in: query + name: userFilter + schema: + nullable: true + type: string responses: '200': content: diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index 6df65e8ae2e3e..d7b1b6d02323a 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -35261,6 +35261,11 @@ paths: schema: nullable: true type: string + - in: query + name: userFilter + schema: + nullable: true + type: string responses: '200': content: diff --git a/oas_docs/output/kibana.staging.yaml b/oas_docs/output/kibana.staging.yaml index 76e217fcba16d..24b0462ae93ef 100644 --- a/oas_docs/output/kibana.staging.yaml +++ b/oas_docs/output/kibana.staging.yaml @@ -38692,6 +38692,11 @@ paths: schema: nullable: true type: string + - in: query + name: userFilter + schema: + nullable: true + type: string responses: '200': content: diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index 76e217fcba16d..24b0462ae93ef 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -38692,6 +38692,11 @@ paths: schema: nullable: true type: string + - in: query + name: userFilter + schema: + nullable: true + type: string responses: '200': content: diff --git a/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.gen.ts b/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.gen.ts index c4c48022f6512..a4659d8d98d5a 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.gen.ts +++ b/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.gen.ts @@ -40,6 +40,7 @@ export const GetNotesRequestQuery = z.object({ sortField: z.string().nullable().optional(), sortOrder: z.string().nullable().optional(), filter: z.string().nullable().optional(), + userFilter: z.string().nullable().optional(), }); export type GetNotesRequestQueryInput = z.input; diff --git a/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.schema.yaml b/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.schema.yaml index 985e7728b7cc8..cc8681c6f8f64 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.schema.yaml +++ b/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.schema.yaml @@ -51,6 +51,11 @@ paths: schema: type: string nullable: true + - in: query + name: userFilter + schema: + nullable: true + type: string responses: '200': description: Indicates the requested notes were returned. diff --git a/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_timeline_api_2023_10_31.bundled.schema.yaml b/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_timeline_api_2023_10_31.bundled.schema.yaml index 48eb959168856..8de192ce26826 100644 --- a/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_timeline_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_timeline_api_2023_10_31.bundled.schema.yaml @@ -97,6 +97,11 @@ paths: schema: nullable: true type: string + - in: query + name: userFilter + schema: + nullable: true + type: string responses: '200': content: diff --git a/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_timeline_api_2023_10_31.bundled.schema.yaml b/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_timeline_api_2023_10_31.bundled.schema.yaml index 343ec3dc30a73..66127d5b8cd52 100644 --- a/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_timeline_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_timeline_api_2023_10_31.bundled.schema.yaml @@ -97,6 +97,11 @@ paths: schema: nullable: true type: string + - in: query + name: userFilter + schema: + nullable: true + type: string responses: '200': content: diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts index 16e1e7edf0eaa..01eec48ed7718 100644 --- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts @@ -549,6 +549,7 @@ export const mockGlobalState: State = { direction: 'desc' as const, }, filter: '', + userFilter: '', search: '', selectedIds: [], pendingDeleteIds: [], diff --git a/x-pack/plugins/security_solution/public/notes/api/api.ts b/x-pack/plugins/security_solution/public/notes/api/api.ts index 4bda803950b84..3bac1a0a2d7df 100644 --- a/x-pack/plugins/security_solution/public/notes/api/api.ts +++ b/x-pack/plugins/security_solution/public/notes/api/api.ts @@ -42,6 +42,7 @@ export const fetchNotes = async ({ sortField, sortOrder, filter, + userFilter, search, }: { page: number; @@ -49,6 +50,7 @@ export const fetchNotes = async ({ sortField: string; sortOrder: string; filter: string; + userFilter: string; search: string; }) => { const response = await KibanaServices.get().http.get(NOTE_URL, { @@ -58,6 +60,7 @@ export const fetchNotes = async ({ sortField, sortOrder, filter, + userFilter, search, }, version: '2023-10-31', diff --git a/x-pack/plugins/security_solution/public/notes/components/add_note.tsx b/x-pack/plugins/security_solution/public/notes/components/add_note.tsx index b3b226550b66f..78a84064467f6 100644 --- a/x-pack/plugins/security_solution/public/notes/components/add_note.tsx +++ b/x-pack/plugins/security_solution/public/notes/components/add_note.tsx @@ -88,7 +88,7 @@ export const AddNote = memo( createNote({ note: { timelineId: timelineId || '', - eventId, + eventId: eventId || '', note: editorValue, }, }) diff --git a/x-pack/plugins/security_solution/public/notes/components/search_row.test.tsx b/x-pack/plugins/security_solution/public/notes/components/search_row.test.tsx new file mode 100644 index 0000000000000..71693edb81724 --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/search_row.test.tsx @@ -0,0 +1,65 @@ +/* + * 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 userEvent from '@testing-library/user-event'; +import React from 'react'; +import { SearchRow } from './search_row'; +import { SEARCH_BAR_TEST_ID, USER_SELECT_TEST_ID } from './test_ids'; +import { useSuggestUsers } from '../../common/components/user_profiles/use_suggest_users'; + +jest.mock('../../common/components/user_profiles/use_suggest_users'); + +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); + +describe('SearchRow', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useSuggestUsers as jest.Mock).mockReturnValue({ + isLoading: false, + data: [{ user: { username: 'test' } }, { user: { username: 'elastic' } }], + }); + }); + + it('should render the component', () => { + const { getByTestId } = render(); + + expect(getByTestId(SEARCH_BAR_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(USER_SELECT_TEST_ID)).toBeInTheDocument(); + }); + + it('should call the correct action when entering a value in the search bar', async () => { + const { getByTestId } = render(); + + const searchBox = getByTestId(SEARCH_BAR_TEST_ID); + + await userEvent.type(searchBox, 'test'); + await userEvent.keyboard('{enter}'); + + expect(mockDispatch).toHaveBeenCalled(); + }); + + it('should call the correct action when select a user', async () => { + const { getByTestId } = render(); + + const userSelect = getByTestId('comboBoxSearchInput'); + userSelect.focus(); + + const option = await screen.findByText('test'); + fireEvent.click(option); + + expect(mockDispatch).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/notes/components/search_row.tsx b/x-pack/plugins/security_solution/public/notes/components/search_row.tsx index 6e08251a61135..9a33c84cbec58 100644 --- a/x-pack/plugins/security_solution/public/notes/components/search_row.tsx +++ b/x-pack/plugins/security_solution/public/notes/components/search_row.tsx @@ -5,25 +5,19 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiSearchBar } from '@elastic/eui'; -import React, { useMemo, useCallback } from 'react'; +import { EuiComboBox, EuiFlexGroup, EuiFlexItem, EuiSearchBar } from '@elastic/eui'; +import React, { useMemo, useCallback, useState } from 'react'; import { useDispatch } from 'react-redux'; -import styled from 'styled-components'; -import { userSearchedNotes } from '..'; +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 { SEARCH_BAR_TEST_ID, USER_SELECT_TEST_ID } from './test_ids'; +import { useSuggestUsers } from '../../common/components/user_profiles/use_suggest_users'; +import { userFilterUsers, userSearchedNotes } from '..'; -const SearchRowContainer = styled.div` - &:not(:last-child) { - margin-bottom: ${(props) => props.theme.eui.euiSizeL}; - } -`; - -SearchRowContainer.displayName = 'SearchRowContainer'; - -const SearchRowFlexGroup = styled(EuiFlexGroup)` - margin-bottom: ${(props) => props.theme.eui.euiSizeXS}; -`; - -SearchRowFlexGroup.displayName = 'SearchRowFlexGroup'; +export const USERS_DROPDOWN = i18n.translate('xpack.securitySolution.notes.usersDropdownLabel', { + defaultMessage: 'Users', +}); export const SearchRow = React.memo(() => { const dispatch = useDispatch(); @@ -31,7 +25,7 @@ export const SearchRow = React.memo(() => { () => ({ placeholder: 'Search note contents', incremental: false, - 'data-test-subj': 'notes-search-bar', + 'data-test-subj': SEARCH_BAR_TEST_ID, }), [] ); @@ -43,14 +37,43 @@ 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>>(); + const onChange = useCallback( + (user: Array>) => { + setSelectedUser(user); + dispatch(userFilterUsers(user.length > 0 ? user[0].label : '')); + }, + [dispatch] + ); + return ( - - - - - - - + + + + + + + + ); }); diff --git a/x-pack/plugins/security_solution/public/notes/components/test_ids.ts b/x-pack/plugins/security_solution/public/notes/components/test_ids.ts index ac4eeb1948748..1464ed17d8764 100644 --- a/x-pack/plugins/security_solution/public/notes/components/test_ids.ts +++ b/x-pack/plugins/security_solution/public/notes/components/test_ids.ts @@ -19,3 +19,5 @@ export const OPEN_FLYOUT_BUTTON_TEST_ID = `${PREFIX}OpenFlyoutButton` as const; export const TIMELINE_DESCRIPTION_COMMENT_TEST_ID = `${PREFIX}TimelineDescriptionComment` as const; export const NOTE_CONTENT_BUTTON_TEST_ID = `${PREFIX}NoteContentButton` as const; export const NOTE_CONTENT_POPOVER_TEST_ID = `${PREFIX}NoteContentPopover` as const; +export const SEARCH_BAR_TEST_ID = `${PREFIX}SearchBar` as const; +export const USER_SELECT_TEST_ID = `${PREFIX}UserSelect` as const; diff --git a/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx b/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx index f0a337cb6c217..e34824d1ad814 100644 --- a/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx +++ b/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx @@ -23,6 +23,7 @@ import { selectNotesTableSelectedIds, selectNotesTableSearch, userSelectedBulkDelete, + selectNotesTableUserFilters, } from '..'; export const BATCH_ACTIONS = i18n.translate( @@ -51,6 +52,7 @@ export const NotesUtilityBar = React.memo(() => { const pagination = useSelector(selectNotesPagination); const sort = useSelector(selectNotesTableSort); const selectedItems = useSelector(selectNotesTableSelectedIds); + const notesUserFilters = useSelector(selectNotesTableUserFilters); const resultsCount = useMemo(() => { const { perPage, page, total } = pagination; const startOfCurrentPage = perPage * (page - 1) + 1; @@ -83,10 +85,19 @@ export const NotesUtilityBar = React.memo(() => { sortField: sort.field, sortOrder: sort.direction, filter: '', + userFilter: notesUserFilters, search: notesSearch, }) ); - }, [dispatch, pagination.page, pagination.perPage, sort.field, sort.direction, notesSearch]); + }, [ + dispatch, + pagination.page, + pagination.perPage, + sort.field, + sort.direction, + notesUserFilters, + notesSearch, + ]); return ( diff --git a/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx b/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx index 2b7f0f690532c..e329f0d75b911 100644 --- a/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx +++ b/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx @@ -36,6 +36,7 @@ import { selectNotesTablePendingDeleteIds, selectFetchNotesError, ReqStatus, + selectNotesTableUserFilters, } from '..'; import type { NotesState } from '..'; import { SearchRow } from '../components/search_row'; @@ -119,6 +120,7 @@ export const NoteManagementPage = () => { const pagination = useSelector(selectNotesPagination); const sort = useSelector(selectNotesTableSort); const notesSearch = useSelector(selectNotesTableSearch); + const notesUserFilters = useSelector(selectNotesTableUserFilters); const pendingDeleteIds = useSelector(selectNotesTablePendingDeleteIds); const isDeleteModalVisible = pendingDeleteIds.length > 0; const fetchNotesStatus = useSelector(selectFetchNotesStatus); @@ -134,10 +136,19 @@ export const NoteManagementPage = () => { sortField: sort.field, sortOrder: sort.direction, filter: '', + userFilter: notesUserFilters, search: notesSearch, }) ); - }, [dispatch, pagination.page, pagination.perPage, sort.field, sort.direction, notesSearch]); + }, [ + dispatch, + pagination.page, + pagination.perPage, + sort.field, + sort.direction, + notesUserFilters, + notesSearch, + ]); useEffect(() => { fetchData(); @@ -212,6 +223,7 @@ export const NoteManagementPage = () => { <EuiSpacer size="m" /> <SearchRow /> + <EuiSpacer size="m" /> <NotesUtilityBar /> <EuiBasicTable items={notes} diff --git a/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts b/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts index 3ab0333dc1abb..7cbaecf7d7135 100644 --- a/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts +++ b/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts @@ -46,6 +46,8 @@ import { fetchNotesBySavedObjectIds, selectNotesBySavedObjectId, selectSortedNotesBySavedObjectId, + userFilterUsers, + selectNotesTableUserFilters, userClosedCreateErrorToast, } from './notes.slice'; import type { NotesState } from './notes.slice'; @@ -69,7 +71,7 @@ const generateNoteMock = (documentId: string): Note => ({ const mockNote1 = generateNoteMock('1'); const mockNote2 = generateNoteMock('2'); -const initialNonEmptyState = { +const initialNonEmptyState: NotesState = { entities: { [mockNote1.noteId]: mockNote1, [mockNote2.noteId]: mockNote2, @@ -99,6 +101,7 @@ const initialNonEmptyState = { direction: 'desc' as const, }, filter: '', + userFilter: '', search: '', selectedIds: [], pendingDeleteIds: [], @@ -501,6 +504,17 @@ describe('notesSlice', () => { }); }); + describe('userFilterUsers', () => { + it('should set correct value to filter users', () => { + const action = { type: userFilterUsers.type, payload: 'abc' }; + + expect(notesReducer(initalEmptyState, action)).toEqual({ + ...initalEmptyState, + userFilter: 'abc', + }); + }); + }); + describe('userSearchedNotes', () => { it('should set correct value to search notes', () => { const action = { type: userSearchedNotes.type, payload: 'abc' }; @@ -837,6 +851,14 @@ describe('notesSlice', () => { expect(selectNotesTableSearch(state)).toBe('test search'); }); + it('should select associated filter', () => { + const state = { + ...mockGlobalState, + notes: { ...initialNotesState, userFilter: 'abc' }, + }; + expect(selectNotesTableUserFilters(state)).toBe('abc'); + }); + it('should select notes table pending delete ids', () => { const state = { ...mockGlobalState, diff --git a/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts b/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts index 979e984b5719b..d5a4e7d4ab14e 100644 --- a/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts +++ b/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts @@ -57,6 +57,7 @@ export interface NotesState extends EntityState<Note> { direction: 'asc' | 'desc'; }; filter: string; + userFilter: string; search: string; selectedIds: string[]; pendingDeleteIds: string[]; @@ -91,6 +92,7 @@ export const initialNotesState: NotesState = notesAdapter.getInitialState({ direction: 'desc', }, filter: '', + userFilter: '', search: '', selectedIds: [], pendingDeleteIds: [], @@ -124,12 +126,21 @@ export const fetchNotes = createAsyncThunk< sortField: string; sortOrder: string; filter: string; + userFilter: string; search: string; }, {} >('notes/fetchNotes', async (args) => { - const { page, perPage, sortField, sortOrder, filter, search } = args; - const res = await fetchNotesApi({ page, perPage, sortField, sortOrder, filter, search }); + const { page, perPage, sortField, sortOrder, filter, userFilter, search } = args; + const res = await fetchNotesApi({ + page, + perPage, + sortField, + sortOrder, + filter, + userFilter, + search, + }); return { ...normalizeEntities('notes' in res ? res.notes : []), totalCount: 'totalCount' in res ? res.totalCount : 0, @@ -152,7 +163,7 @@ export const deleteNotes = createAsyncThunk<string[], { ids: string[]; refetch?: await deleteNotesApi(ids); if (refetch) { const state = getState() as State; - const { search, pagination, sort } = state.notes; + const { search, pagination, userFilter, sort } = state.notes; dispatch( fetchNotes({ page: pagination.page, @@ -160,6 +171,7 @@ export const deleteNotes = createAsyncThunk<string[], { ids: string[]; refetch?: sortField: sort.field, sortOrder: sort.direction, filter: '', + userFilter, search, }) ); @@ -172,99 +184,102 @@ const notesSlice = createSlice({ name: 'notes', initialState: initialNotesState, reducers: { - userSelectedPage: (state, action: { payload: number }) => { + userSelectedPage: (state: NotesState, action: { payload: number }) => { state.pagination.page = action.payload; }, - userSelectedPerPage: (state, action: { payload: number }) => { + userSelectedPerPage: (state: NotesState, action: { payload: number }) => { state.pagination.perPage = action.payload; }, userSortedNotes: ( - state, + state: NotesState, action: { payload: { field: keyof Note; direction: 'asc' | 'desc' } } ) => { state.sort = action.payload; }, - userFilteredNotes: (state, action: { payload: string }) => { + userFilteredNotes: (state: NotesState, action: { payload: string }) => { state.filter = action.payload; }, - userSearchedNotes: (state, action: { payload: string }) => { + userFilterUsers: (state: NotesState, action: { payload: string }) => { + state.userFilter = action.payload; + }, + userSearchedNotes: (state: NotesState, action: { payload: string }) => { state.search = action.payload; }, - userSelectedRow: (state, action: { payload: string[] }) => { + userSelectedRow: (state: NotesState, action: { payload: string[] }) => { state.selectedIds = action.payload; }, - userClosedDeleteModal: (state) => { + userClosedDeleteModal: (state: NotesState) => { state.pendingDeleteIds = []; }, - userSelectedNotesForDeletion: (state, action: { payload: string }) => { + userSelectedNotesForDeletion: (state: NotesState, action: { payload: string }) => { state.pendingDeleteIds = [action.payload]; }, - userSelectedBulkDelete: (state) => { + userSelectedBulkDelete: (state: NotesState) => { state.pendingDeleteIds = state.selectedIds; }, - userClosedCreateErrorToast: (state) => { + userClosedCreateErrorToast: (state: NotesState) => { state.error.createNote = null; }, }, extraReducers(builder) { builder - .addCase(fetchNotesByDocumentIds.pending, (state) => { + .addCase(fetchNotesByDocumentIds.pending, (state: NotesState) => { state.status.fetchNotesByDocumentIds = ReqStatus.Loading; }) - .addCase(fetchNotesByDocumentIds.fulfilled, (state, action) => { + .addCase(fetchNotesByDocumentIds.fulfilled, (state: NotesState, action) => { notesAdapter.upsertMany(state, action.payload.entities.notes); state.status.fetchNotesByDocumentIds = ReqStatus.Succeeded; }) - .addCase(fetchNotesByDocumentIds.rejected, (state, action) => { + .addCase(fetchNotesByDocumentIds.rejected, (state: NotesState, action) => { state.status.fetchNotesByDocumentIds = ReqStatus.Failed; state.error.fetchNotesByDocumentIds = action.payload ?? action.error; }) - .addCase(fetchNotesBySavedObjectIds.pending, (state) => { + .addCase(fetchNotesBySavedObjectIds.pending, (state: NotesState) => { state.status.fetchNotesBySavedObjectIds = ReqStatus.Loading; }) - .addCase(fetchNotesBySavedObjectIds.fulfilled, (state, action) => { + .addCase(fetchNotesBySavedObjectIds.fulfilled, (state: NotesState, action) => { notesAdapter.upsertMany(state, action.payload.entities.notes); state.status.fetchNotesBySavedObjectIds = ReqStatus.Succeeded; }) - .addCase(fetchNotesBySavedObjectIds.rejected, (state, action) => { + .addCase(fetchNotesBySavedObjectIds.rejected, (state: NotesState, action) => { state.status.fetchNotesBySavedObjectIds = ReqStatus.Failed; state.error.fetchNotesBySavedObjectIds = action.payload ?? action.error; }) - .addCase(createNote.pending, (state) => { + .addCase(createNote.pending, (state: NotesState) => { state.status.createNote = ReqStatus.Loading; }) - .addCase(createNote.fulfilled, (state, action) => { + .addCase(createNote.fulfilled, (state: NotesState, action) => { notesAdapter.addMany(state, action.payload.entities.notes); state.status.createNote = ReqStatus.Succeeded; }) - .addCase(createNote.rejected, (state, action) => { + .addCase(createNote.rejected, (state: NotesState, action) => { state.status.createNote = ReqStatus.Failed; state.error.createNote = action.payload ?? action.error; }) - .addCase(deleteNotes.pending, (state) => { + .addCase(deleteNotes.pending, (state: NotesState) => { state.status.deleteNotes = ReqStatus.Loading; }) - .addCase(deleteNotes.fulfilled, (state, action) => { + .addCase(deleteNotes.fulfilled, (state: NotesState, action) => { notesAdapter.removeMany(state, action.payload); state.status.deleteNotes = ReqStatus.Succeeded; state.pendingDeleteIds = state.pendingDeleteIds.filter( (value) => !action.payload.includes(value) ); }) - .addCase(deleteNotes.rejected, (state, action) => { + .addCase(deleteNotes.rejected, (state: NotesState, action) => { state.status.deleteNotes = ReqStatus.Failed; state.error.deleteNotes = action.payload ?? action.error; }) - .addCase(fetchNotes.pending, (state) => { + .addCase(fetchNotes.pending, (state: NotesState) => { state.status.fetchNotes = ReqStatus.Loading; }) - .addCase(fetchNotes.fulfilled, (state, action) => { + .addCase(fetchNotes.fulfilled, (state: NotesState, action) => { notesAdapter.setAll(state, action.payload.entities.notes); state.pagination.total = action.payload.totalCount; state.status.fetchNotes = ReqStatus.Succeeded; state.selectedIds = []; }) - .addCase(fetchNotes.rejected, (state, action) => { + .addCase(fetchNotes.rejected, (state: NotesState, action) => { state.status.fetchNotes = ReqStatus.Failed; state.error.fetchNotes = action.payload ?? action.error; }); @@ -307,6 +322,8 @@ export const selectNotesTableSelectedIds = (state: State) => state.notes.selecte export const selectNotesTableSearch = (state: State) => state.notes.search; +export const selectNotesTableUserFilters = (state: State) => state.notes.userFilter; + export const selectNotesTablePendingDeleteIds = (state: State) => state.notes.pendingDeleteIds; export const selectFetchNotesError = (state: State) => state.notes.error.fetchNotes; @@ -394,6 +411,7 @@ export const { userSelectedPerPage, userSortedNotes, userFilteredNotes, + userFilterUsers, userSearchedNotes, userSelectedRow, userClosedDeleteModal, diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts index 925379baedad5..bc6c83e2b159c 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts @@ -11,6 +11,7 @@ import type { SortOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey' import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; import type { SavedObjectsFindOptions } from '@kbn/core-saved-objects-api-server'; import { nodeBuilder } from '@kbn/es-query'; +import type { KueryNode } from '@kbn/es-query'; import { timelineSavedObjectType } from '../../saved_object_mappings'; import type { SecuritySolutionPluginRouter } from '../../../../types'; import { MAX_UNASSOCIATED_NOTES, NOTE_URL } from '../../../../../common/constants'; @@ -126,6 +127,22 @@ export const getNotesRoute = (router: SecuritySolutionPluginRouter) => { sortOrder, filter, }; + + // retrieve all the notes created by a specific user + const userFilter = queryParams?.userFilter; + if (userFilter) { + // we need to combine the associatedFilter with the filter query + // we have to type case here because the filter is a string (from the schema) and that cannot be changed as it would be a breaking change + const filterAsKueryNode: KueryNode = (filter || '') as unknown as KueryNode; + + options.filter = nodeBuilder.and([ + nodeBuilder.is(`${noteSavedObjectType}.attributes.createdBy`, userFilter), + filterAsKueryNode, + ]); + } else { + options.filter = filter; + } + const res = await getAllSavedNote(frameworkRequest, options); const body: GetNotesResponse = res ?? {}; return response.ok({ body }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/helpers.ts b/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/helpers.ts index a5944dc8c6149..5bf4d61c8b595 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/helpers.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/helpers.ts @@ -7,7 +7,10 @@ import type SuperTest from 'supertest'; import { v4 as uuidv4 } from 'uuid'; -import { BareNote, TimelineTypeEnum } from '@kbn/security-solution-plugin/common/api/timeline'; +import { + PersistNoteRouteRequestBody, + TimelineTypeEnum, +} from '@kbn/security-solution-plugin/common/api/timeline'; import { NOTE_URL } from '@kbn/security-solution-plugin/common/constants'; import type { Client } from '@elastic/elasticsearch'; import { SECURITY_SOLUTION_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; @@ -58,7 +61,6 @@ export const createNote = async ( note: { documentId?: string; savedObjectId?: string; - user?: string; text: string; } ) => @@ -70,9 +72,9 @@ export const createNote = async ( eventId: note.documentId || '', timelineId: note.savedObjectId || '', created: Date.now(), - createdBy: note.user || 'elastic', + createdBy: 'elastic', updated: Date.now(), - updatedBy: note.user || 'elastic', + updatedBy: 'elastic', note: note.text, - } as BareNote, - }); + }, + } as PersistNoteRouteRequestBody); diff --git a/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/notes.ts b/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/notes.ts index dabb453f80158..8a636358c2649 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/notes.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/notes.ts @@ -408,8 +408,41 @@ export default function ({ getService }: FtrProviderContext) { }); // TODO should add more tests for the filter query parameter (I don't know how it's supposed to work) - // TODO should add more tests for the MAX_UNASSOCIATED_NOTES advanced settings values + + // TODO figure out why this test is failing on CI but not locally + // we can't really test for other users because the persistNote endpoint forces overrideOwner to be true then all the notes created here are owned by the elastic user + it.skip('should retrieve all notes that have been created by a specific user', async () => { + await Promise.all([ + createNote(supertest, { text: 'first note' }), + createNote(supertest, { text: 'second note' }), + ]); + + const response = await supertest + .get('/api/note?userFilter=elastic') + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31'); + + const { totalCount } = response.body; + + expect(totalCount).to.be(2); + }); + + it('should return nothing if no notes have been created by that user', async () => { + await Promise.all([ + createNote(supertest, { text: 'first note' }), + createNote(supertest, { text: 'second note' }), + ]); + + const response = await supertest + .get('/api/note?userFilter=user1') + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31'); + + const { totalCount } = response.body; + + expect(totalCount).to.be(0); + }); }); }); }