[8.x] [Security Solution][Notes] - allow filtering by user (#195519) (#196475)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Security Solution][Notes] - allow filtering by user
(#195519)](https://github.com/elastic/kibana/pull/195519)

<!--- 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-16T02:42:23Z","message":"[Security
Solution][Notes] - allow filtering by user
(#195519)","sha":"d85b51db222f29efbd2d8f32067a13b4932feba8","branchLabelMapping":{"^v9.0.0$":"main","^v8.16.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] -
allow filtering by
user","number":195519,"url":"https://github.com/elastic/kibana/pull/195519","mergeCommit":{"message":"[Security
Solution][Notes] - allow filtering by user
(#195519)","sha":"d85b51db222f29efbd2d8f32067a13b4932feba8"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/195519","number":195519,"mergeCommit":{"message":"[Security
Solution][Notes] - allow filtering by user
(#195519)","sha":"d85b51db222f29efbd2d8f32067a13b4932feba8"}},{"branch":"8.x","label":"v8.16.0","branchLabelMappingKey":"^v8.16.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

Co-authored-by: Philippe Oberti <philippe.oberti@elastic.co>
This commit is contained in:
Kibana Machine 2024-10-16 15:30:29 +11:00 committed by GitHub
parent b4fc47aba9
commit 85145569bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 309 additions and 64 deletions

View file

@ -15011,6 +15011,11 @@ paths:
schema:
nullable: true
type: string
- in: query
name: userFilter
schema:
nullable: true
type: string
responses:
'200':
content:

View file

@ -15011,6 +15011,11 @@ paths:
schema:
nullable: true
type: string
- in: query
name: userFilter
schema:
nullable: true
type: string
responses:
'200':
content:

View file

@ -18441,6 +18441,11 @@ paths:
schema:
nullable: true
type: string
- in: query
name: userFilter
schema:
nullable: true
type: string
responses:
'200':
content:

View file

@ -18441,6 +18441,11 @@ paths:
schema:
nullable: true
type: string
- in: query
name: userFilter
schema:
nullable: true
type: string
responses:
'200':
content:

View file

@ -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<typeof GetNotesRequestQuery>;

View file

@ -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.

View file

@ -96,6 +96,11 @@ paths:
schema:
nullable: true
type: string
- in: query
name: userFilter
schema:
nullable: true
type: string
responses:
'200':
content:

View file

@ -96,6 +96,11 @@ paths:
schema:
nullable: true
type: string
- in: query
name: userFilter
schema:
nullable: true
type: string
responses:
'200':
content:

View file

@ -549,6 +549,7 @@ export const mockGlobalState: State = {
direction: 'desc' as const,
},
filter: '',
userFilter: '',
search: '',
selectedIds: [],
pendingDeleteIds: [],

View file

@ -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<GetNotesResponse>(NOTE_URL, {
@ -58,6 +60,7 @@ export const fetchNotes = async ({
sortField,
sortOrder,
filter,
userFilter,
search,
},
version: '2023-10-31',

View file

@ -88,7 +88,7 @@ export const AddNote = memo(
createNote({
note: {
timelineId: timelineId || '',
eventId,
eventId: eventId || '',
note: editorValue,
},
})

View file

@ -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(<SearchRow />);
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(<SearchRow />);
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(<SearchRow />);
const userSelect = getByTestId('comboBoxSearchInput');
userSelect.focus();
const option = await screen.findByText('test');
fireEvent.click(option);
expect(mockDispatch).toHaveBeenCalled();
});
});

View file

@ -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<Array<EuiComboBoxOptionOption<string>>>();
const onChange = useCallback(
(user: Array<EuiComboBoxOptionOption<string>>) => {
setSelectedUser(user);
dispatch(userFilterUsers(user.length > 0 ? user[0].label : ''));
},
[dispatch]
);
return (
<SearchRowContainer>
<SearchRowFlexGroup gutterSize="s">
<EuiFlexItem>
<EuiSearchBar box={searchBox} onChange={onQueryChange} defaultQuery="" />
</EuiFlexItem>
</SearchRowFlexGroup>
</SearchRowContainer>
<EuiFlexGroup gutterSize="m">
<EuiFlexItem>
<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}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
});

View file

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

View file

@ -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 (
<UtilityBar border>
<UtilityBarSection>

View file

@ -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 = () => {
<Title title={i18n.NOTES} />
<EuiSpacer size="m" />
<SearchRow />
<EuiSpacer size="m" />
<NotesUtilityBar />
<EuiBasicTable
items={notes}

View file

@ -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,

View file

@ -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,

View file

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

View file

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

View file

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