[ResponseOps][Cases] Preferring profile uid for recent cases (#140861)

* Preferring profile uid for recent cases

* removing uncommented code

* Fixing ui crash

* Adding integration test
This commit is contained in:
Jonathan Buttner 2022-09-20 09:35:18 -04:00 committed by GitHub
parent c92f2b95c9
commit a71f9652d8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 336 additions and 176 deletions

View file

@ -6,14 +6,17 @@
*/
import React from 'react';
import { configure } from '@testing-library/react';
import { configure, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import RecentCases, { RecentCasesProps } from '.';
import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../../common/mock';
import { useGetCasesMockState } from '../../containers/mock';
import { useCurrentUser } from '../../common/lib/kibana/hooks';
import { useGetCases } from '../../containers/use_get_cases';
import { useGetCurrentUserProfile } from '../../containers/user_profiles/use_get_current_user_profile';
import { userProfiles } from '../../containers/user_profiles/api.mock';
jest.mock('../../containers/user_profiles/use_get_current_user_profile');
jest.mock('../../containers/use_get_cases');
jest.mock('../../common/lib/kibana/hooks');
jest.mock('../../common/navigation/hooks');
@ -27,6 +30,7 @@ const mockData = {
...useGetCasesMockState,
};
const useGetCurrentUserProfileMock = useGetCurrentUserProfile as jest.Mock;
const useGetCasesMock = useGetCases as jest.Mock;
const useCurrentUserMock = useCurrentUser as jest.Mock;
@ -34,12 +38,17 @@ describe('RecentCases', () => {
let appMockRender: AppMockRenderer;
beforeEach(() => {
jest.clearAllMocks();
useGetCurrentUserProfileMock.mockReturnValue({
data: userProfiles[0],
isLoading: false,
});
useGetCasesMock.mockImplementation(() => mockData);
useCurrentUserMock.mockResolvedValue({
useCurrentUserMock.mockReturnValue({
email: 'elastic@elastic.co',
fullName: 'Elastic',
username: 'elastic',
});
appMockRender = createAppMockRenderer();
});
@ -78,7 +87,7 @@ describe('RecentCases', () => {
});
});
it('sets the reporter filters correctly', () => {
it('sets the reporter filters correctly', async () => {
const { getByTestId } = appMockRender.render(
<TestProviders>
<RecentCases {...defaultProps} />
@ -91,12 +100,21 @@ describe('RecentCases', () => {
});
// apply the filter
const myRecentCasesElement = getByTestId('myRecentlyReported');
userEvent.click(myRecentCasesElement);
await waitFor(() => {
const myRecentCasesElement = getByTestId('myRecentlyReported');
userEvent.click(myRecentCasesElement);
});
expect(useGetCasesMock).toHaveBeenLastCalledWith({
filterOptions: {
reporters: [{ email: undefined, full_name: undefined, username: undefined }],
reporters: [
{
email: 'damaged_raccoon@elastic.co',
full_name: 'Damaged Raccoon',
profile_uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0',
username: 'damaged_raccoon',
},
],
},
queryParams: { perPage: 10 },
});
@ -112,4 +130,38 @@ describe('RecentCases', () => {
queryParams: { perPage: 10 },
});
});
it('sets the reporter filters to the user info without the profile uid when it cannot find the current user profile', async () => {
useGetCurrentUserProfileMock.mockReturnValue({ data: undefined, isLoading: false });
const { getByTestId } = appMockRender.render(
<TestProviders>
<RecentCases {...defaultProps} />
</TestProviders>
);
expect(useGetCasesMock).toHaveBeenCalledWith({
filterOptions: { reporters: [] },
queryParams: { perPage: 10 },
});
// apply the filter
await waitFor(() => {
const myRecentCasesElement = getByTestId('myRecentlyReported');
userEvent.click(myRecentCasesElement);
});
expect(useGetCasesMock).toHaveBeenLastCalledWith({
filterOptions: {
reporters: [
{
email: 'elastic@elastic.co',
full_name: 'Elastic',
username: 'elastic',
},
],
},
queryParams: { perPage: 10 },
});
});
});

View file

@ -9,21 +9,39 @@ import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiText, EuiTitle } from
import React, { useCallback, useMemo, useState } from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import { UserProfile } from '@kbn/user-profile-components';
import * as i18n from './translations';
import { LinkAnchor } from '../links';
import { RecentCasesFilters } from './filters';
import { RecentCasesComp } from './recent_cases';
import { FilterMode as RecentCasesFilterMode } from './types';
import { useCurrentUser } from '../../common/lib/kibana';
import { AuthenticatedElasticUser, useCurrentUser } from '../../common/lib/kibana';
import { useAllCasesNavigation } from '../../common/navigation';
import { casesQueryClient } from '../cases_context/query_client';
import { useGetCurrentUserProfile } from '../../containers/user_profiles/use_get_current_user_profile';
import { User } from '../../../common/api';
export interface RecentCasesProps {
maxCasesToShow: number;
}
const RecentCases = React.memo(({ maxCasesToShow }: RecentCasesProps) => {
const RecentCases = React.memo((props: RecentCasesProps) => {
return (
<QueryClientProvider client={casesQueryClient}>
<RecentCasesWithoutQueryProvider {...props} />
</QueryClientProvider>
);
});
RecentCases.displayName = 'RecentCases';
// eslint-disable-next-line import/no-default-export
export { RecentCases as default };
const RecentCasesWithoutQueryProvider = React.memo(({ maxCasesToShow }: RecentCasesProps) => {
const currentUser = useCurrentUser();
const { data: currentUserProfile, isLoading: isLoadingCurrentUserProfile } =
useGetCurrentUserProfile();
const { getAllCasesUrl, navigateToAllCases } = useAllCasesNavigation();
const [recentCasesFilterBy, setRecentCasesFilterBy] =
@ -37,42 +55,36 @@ const RecentCases = React.memo(({ maxCasesToShow }: RecentCasesProps) => {
[navigateToAllCases]
);
const recentCasesFilterOptions = useMemo(
() =>
recentCasesFilterBy === 'myRecentlyReported' && currentUser != null
? {
reporters: [
{
email: currentUser.email,
full_name: currentUser.fullName,
username: currentUser.username,
},
],
}
: { reporters: [] },
[currentUser, recentCasesFilterBy]
);
const recentCasesFilterOptions = useMemo(() => {
return getReporterFilter({
currentUser,
isLoadingCurrentUserProfile,
recentCasesFilterBy,
currentUserProfile,
});
}, [currentUser, currentUserProfile, isLoadingCurrentUserProfile, recentCasesFilterBy]);
// show the recently reported if we have the current user profile, or if we have the fallback user information
const showMyRecentlyReported = currentUserProfile != null || currentUser != null;
return (
<QueryClientProvider client={casesQueryClient}>
<>
<EuiFlexGroup alignItems="center" gutterSize="none" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h2>{i18n.RECENT_CASES}</h2>
</EuiTitle>
</EuiFlexItem>
<>
<EuiFlexGroup alignItems="center" gutterSize="none" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h2>{i18n.RECENT_CASES}</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<RecentCasesFilters
filterBy={recentCasesFilterBy}
setFilterBy={setRecentCasesFilterBy}
showMyRecentlyReported={currentUser != null}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiHorizontalRule margin="s" />
</>
<EuiFlexItem grow={false}>
<RecentCasesFilters
filterBy={recentCasesFilterBy}
setFilterBy={setRecentCasesFilterBy}
showMyRecentlyReported={showMyRecentlyReported}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiHorizontalRule margin="s" />
<EuiText color="subdued" size="s">
<RecentCasesComp filterOptions={recentCasesFilterOptions} maxCasesToShow={maxCasesToShow} />
<EuiHorizontalRule margin="s" />
@ -83,11 +95,50 @@ const RecentCases = React.memo(({ maxCasesToShow }: RecentCasesProps) => {
</LinkAnchor>
</EuiText>
</EuiText>
</QueryClientProvider>
</>
);
});
RecentCases.displayName = 'RecentCases';
RecentCasesWithoutQueryProvider.displayName = 'RecentCases';
// eslint-disable-next-line import/no-default-export
export { RecentCases as default };
const getReporterFilter = ({
recentCasesFilterBy,
currentUserProfile,
currentUser,
isLoadingCurrentUserProfile,
}: {
recentCasesFilterBy: RecentCasesFilterMode;
currentUserProfile?: UserProfile;
currentUser: AuthenticatedElasticUser | null;
isLoadingCurrentUserProfile: boolean;
}): { reporters: User[] } => {
const emptyFilter = { reporters: [] };
if (recentCasesFilterBy !== 'myRecentlyReported') {
return emptyFilter;
}
if (currentUserProfile != null && !isLoadingCurrentUserProfile) {
return {
reporters: [
{
email: currentUserProfile.user.email,
full_name: currentUserProfile.user.full_name,
username: currentUserProfile.user.username,
profile_uid: currentUserProfile.uid,
},
],
};
} else if (currentUser != null) {
return {
reporters: [
{
email: currentUser.email,
full_name: currentUser.fullName,
username: currentUser.username,
},
],
};
}
return emptyFilter;
};

View file

@ -7,6 +7,7 @@
import type { ValidFeatureId } from '@kbn/rule-data-utils';
import { BASE_RAC_ALERTS_API_PATH } from '@kbn/rule-registry-plugin/common/constants';
import { isEmpty } from 'lodash';
import {
Cases,
FetchCasesProps,
@ -182,7 +183,7 @@ export const getCases = async ({
...(filterOptions.status !== StatusAll ? { status: filterOptions.status } : {}),
...(filterOptions.severity !== SeverityAll ? { severity: filterOptions.severity } : {}),
assignees: filterOptions.assignees,
reporters: filterOptions.reporters.map((r) => r.username ?? '').filter((r) => r !== ''),
reporters: constructReportersFilter(filterOptions.reporters),
tags: filterOptions.tags,
...(filterOptions.search.length > 0 ? { search: filterOptions.search } : {}),
...(filterOptions.searchFields.length > 0 ? { searchFields: filterOptions.searchFields } : {}),
@ -199,6 +200,18 @@ export const getCases = async ({
return convertAllCasesToCamel(decodeCasesFindResponse(response));
};
export const constructReportersFilter = (reporters: User[]) => {
return reporters
.map((reporter) => {
if (reporter.profile_uid != null) {
return reporter.profile_uid;
}
return reporter.username ?? '';
})
.filter((reporterID) => !isEmpty(reporterID));
};
export const postCase = async (newCase: CasePostRequest, signal: AbortSignal): Promise<Case> => {
const response = await KibanaServices.get().http.fetch<CaseResponse>(CASES_URL, {
method: 'POST',

View file

@ -238,25 +238,12 @@ export const observabilityOnlyRead: Role = {
},
};
export const roles = [
noKibanaPrivileges,
noCasesPrivilegesSpace1,
globalRead,
securitySolutionOnlyAll,
securitySolutionOnlyRead,
securitySolutionOnlyDelete,
securitySolutionOnlyNoDelete,
observabilityOnlyAll,
observabilityOnlyRead,
testDisabledPluginAll,
];
/**
* These roles have access to all spaces.
*/
export const securitySolutionOnlyAllSpacesAll: Role = {
name: 'sec_only_all',
export const securitySolutionOnlyAllSpacesRole: Role = {
name: 'sec_only_all_spaces',
privileges: {
elasticsearch: {
indices: [
@ -279,74 +266,15 @@ export const securitySolutionOnlyAllSpacesAll: Role = {
},
};
export const securitySolutionOnlyReadSpacesAll: Role = {
name: 'sec_only_read',
privileges: {
elasticsearch: {
indices: [
{
names: ['*'],
privileges: ['all'],
},
],
},
kibana: [
{
feature: {
securitySolutionFixture: ['read'],
actions: ['read'],
actionsSimulators: ['read'],
},
spaces: ['*'],
},
],
},
};
export const observabilityOnlyAllSpacesAll: Role = {
name: 'obs_only_all',
privileges: {
elasticsearch: {
indices: [
{
names: ['*'],
privileges: ['all'],
},
],
},
kibana: [
{
feature: {
observabilityFixture: ['all'],
actions: ['all'],
actionsSimulators: ['all'],
},
spaces: ['*'],
},
],
},
};
export const observabilityOnlyReadSpacesAll: Role = {
name: 'obs_only_read',
privileges: {
elasticsearch: {
indices: [
{
names: ['*'],
privileges: ['all'],
},
],
},
kibana: [
{
feature: {
observabilityFixture: ['read'],
actions: ['read'],
actionsSimulators: ['read'],
},
spaces: ['*'],
},
],
},
};
export const roles = [
noKibanaPrivileges,
noCasesPrivilegesSpace1,
globalRead,
securitySolutionOnlyAll,
securitySolutionOnlyRead,
securitySolutionOnlyDelete,
securitySolutionOnlyNoDelete,
observabilityOnlyAll,
observabilityOnlyRead,
testDisabledPluginAll,
];

View file

@ -13,10 +13,7 @@ import {
globalRead as globalReadRole,
noKibanaPrivileges as noKibanaPrivilegesRole,
noCasesPrivilegesSpace1 as noCasesPrivilegesSpace1Role,
securitySolutionOnlyAllSpacesAll,
securitySolutionOnlyReadSpacesAll,
observabilityOnlyAllSpacesAll,
observabilityOnlyReadSpacesAll,
securitySolutionOnlyAllSpacesRole,
testDisabledPluginAll,
securitySolutionOnlyDelete,
securitySolutionOnlyNoDelete,
@ -101,6 +98,16 @@ export const noCasesPrivilegesSpace1: User = {
roles: [noCasesPrivilegesSpace1Role.name],
};
/**
* These users will have access to all spaces.
*/
export const secOnlySpacesAll: User = {
username: 'sec_only_all_spaces',
password: 'sec_only_all_spaces',
roles: [securitySolutionOnlyAllSpacesRole.name],
};
export const users = [
superUser,
secOnly,
@ -116,43 +123,3 @@ export const users = [
noCasesPrivilegesSpace1,
testDisabled,
];
/**
* These users will have access to all spaces.
*/
export const secOnlySpacesAll: User = {
username: 'sec_only',
password: 'sec_only',
roles: [securitySolutionOnlyAllSpacesAll.name],
};
export const secOnlyReadSpacesAll: User = {
username: 'sec_only_read',
password: 'sec_only_read',
roles: [securitySolutionOnlyReadSpacesAll.name],
};
export const obsOnlySpacesAll: User = {
username: 'obs_only',
password: 'obs_only',
roles: [observabilityOnlyAllSpacesAll.name],
};
export const obsOnlyReadSpacesAll: User = {
username: 'obs_only_read',
password: 'obs_only_read',
roles: [observabilityOnlyReadSpacesAll.name],
};
export const obsSecSpacesAll: User = {
username: 'obs_sec',
password: 'obs_sec',
roles: [securitySolutionOnlyAllSpacesAll.name, observabilityOnlyAllSpacesAll.name],
};
export const obsSecReadSpacesAll: User = {
username: 'obs_sec_read',
password: 'obs_sec_read',
roles: [securitySolutionOnlyReadSpacesAll.name, observabilityOnlyReadSpacesAll.name],
};

View file

@ -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 expect from '@kbn/expect';
import { Cookie } from 'tough-cookie';
import { User } from '@kbn/cases-plugin/common/api';
import { UserProfile } from '@kbn/security-plugin/common';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import { findCasesResp, getPostCaseRequest } from '../../../../common/lib/mock';
import { findCases, createCase, deleteAllCaseItems } from '../../../../common/lib/utils';
import { secOnlySpacesAll, superUser } from '../../../../common/lib/authentication/users';
import { suggestUserProfiles, loginUsers } from '../../../../common/lib/user_profiles';
import { getUserInfo } from '../../../../common/lib/authentication';
import { createUsersAndRoles, deleteUsersAndRoles } from '../../../../common/lib/authentication';
import { securitySolutionOnlyAllSpacesRole } from '../../../../common/lib/authentication/roles';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext): void => {
const es = getService('es');
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
describe('find_cases', () => {
const secOnlyInfo: User = getUserInfo(secOnlySpacesAll);
let cookies: Cookie[];
let suggestedSecUsers: UserProfile[];
let superUserHeaders: { Cookie: string };
let secOnlyHeaders: { Cookie: string };
before(async () => {
await createUsersAndRoles(
getService,
[secOnlySpacesAll],
[securitySolutionOnlyAllSpacesRole]
);
});
beforeEach(async () => {
cookies = await loginUsers({
supertest: supertestWithoutAuth,
users: [superUser, secOnlySpacesAll],
});
superUserHeaders = {
Cookie: cookies[0].cookieString(),
};
secOnlyHeaders = {
Cookie: cookies[1].cookieString(),
};
suggestedSecUsers = await suggestUserProfiles({
supertest: supertestWithoutAuth,
req: {
name: secOnlyInfo.username!,
owners: ['securitySolutionFixture'],
size: 1,
},
auth: { user: superUser, space: 'space1' },
});
});
afterEach(async () => {
await deleteAllCaseItems(es);
});
after(async () => {
await deleteUsersAndRoles(
getService,
[secOnlySpacesAll],
[securitySolutionOnlyAllSpacesRole]
);
});
it('filters by reporters using the profile uid and username', async () => {
// create a case with super user
const superUserCase = await createCase(
supertestWithoutAuth,
getPostCaseRequest({ owner: 'securitySolutionFixture' }),
200,
{ user: superUser, space: null }
);
// create a case with a security user
const secOnlyCase = await createCase(
supertestWithoutAuth,
getPostCaseRequest({ owner: 'securitySolutionFixture' }),
200,
null,
secOnlyHeaders
);
// find cases for both users
const cases = await findCases({
supertest,
query: { reporters: [suggestedSecUsers[0].uid, superUser.username!] },
});
expect(cases).to.eql({
...findCasesResp,
total: 2,
// should only find the case created by the security user
cases: [superUserCase, secOnlyCase],
count_open_cases: 2,
});
});
it('filters by reporters using the profile uid', async () => {
const [, secCase] = await Promise.all([
// create a case with super user
createCase(
supertestWithoutAuth,
getPostCaseRequest({ owner: 'securitySolutionFixture' }),
200,
null,
superUserHeaders
),
// create a case with a security user
createCase(
supertestWithoutAuth,
getPostCaseRequest({ owner: 'securitySolutionFixture' }),
200,
null,
secOnlyHeaders
),
]);
// find all cases for only the security user
const cases = await findCases({
supertest,
query: { reporters: suggestedSecUsers[0].uid },
});
expect(cases).to.eql({
...findCasesResp,
total: 1,
// should only find the case created by the security user
cases: [secCase],
count_open_cases: 1,
});
});
});
};

View file

@ -30,6 +30,7 @@ export default ({ loadTestFile, getService }: FtrProviderContext): void => {
loadTestFile(require.resolve('./cases/push_case'));
loadTestFile(require.resolve('./cases/user_actions/get_all_user_actions'));
loadTestFile(require.resolve('./cases/assignees'));
loadTestFile(require.resolve('./cases/find_cases'));
loadTestFile(require.resolve('./configure'));
// sub privileges are only available with a license above basic
loadTestFile(require.resolve('./delete_sub_privilege'));