[Cases][Sharable SOs] Resolve deep link ids (#111984)

* backend implementation

* front UI changes to manage all resolve outcomes

* add spaces dependency to o11y

* tests fixed

* hack removed

* added resolve api integration test

* unit tests for the front changes

* add base resolve cypress test

* non-null assertions removed

* add resolve operation for logging

* eslint fix new rule

* create test snapshot

* migration resolve functional test
This commit is contained in:
Sergi Massaneda 2021-09-23 17:08:13 +02:00 committed by GitHub
parent 854e0d4d85
commit 9c30f87ff4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 845 additions and 186 deletions

View file

@ -254,6 +254,16 @@ export const CaseResponseRt = rt.intersection([
}),
]);
export const CaseResolveResponseRt = rt.intersection([
rt.type({
case: CaseResponseRt,
outcome: rt.union([rt.literal('exactMatch'), rt.literal('aliasMatch'), rt.literal('conflict')]),
}),
rt.partial({
alias_target_id: rt.string,
}),
]);
export const CasesFindResponseRt = rt.intersection([
rt.type({
cases: rt.array(CaseResponseRt),
@ -319,6 +329,7 @@ export type CaseAttributes = rt.TypeOf<typeof CaseAttributesRt>;
export type CasesClientPostRequest = rt.TypeOf<typeof CasesClientPostRequestRt>;
export type CasePostRequest = rt.TypeOf<typeof CasePostRequestRt>;
export type CaseResponse = rt.TypeOf<typeof CaseResponseRt>;
export type CaseResolveResponse = rt.TypeOf<typeof CaseResolveResponseRt>;
export type CasesResponse = rt.TypeOf<typeof CasesResponseRt>;
export type CasesFindRequest = rt.TypeOf<typeof CasesFindRequestRt>;
export type CasesByAlertIDRequest = rt.TypeOf<typeof CasesByAlertIDRequestRt>;

View file

@ -114,6 +114,12 @@ export interface Case extends BasicCase {
type: CaseType;
}
export interface ResolvedCase {
case: Case;
outcome: 'exactMatch' | 'aliasMatch' | 'conflict';
aliasTargetId?: string;
}
export interface QueryParams {
page: number;
perPage: number;

View file

@ -13,6 +13,7 @@ import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_r
import { StartServices } from '../../../types';
import { EuiTheme } from '../../../../../../../src/plugins/kibana_react/common';
import { securityMock } from '../../../../../security/public/mocks';
import { spacesPluginMock } from '../../../../../spaces/public/mocks';
import { triggersActionsUiMock } from '../../../../../triggers_actions_ui/public/mocks';
export const createStartServicesMock = (): StartServices =>
@ -25,6 +26,7 @@ export const createStartServicesMock = (): StartServices =>
},
security: securityMock.createStart(),
triggersActionsUi: triggersActionsUiMock.createStart(),
spaces: spacesPluginMock.createStartContract(),
} as unknown as StartServices);
export const createWithKibanaMock = () => {

View file

@ -18,6 +18,7 @@ import {
getAlertUserAction,
} from '../../containers/mock';
import { TestProviders } from '../../common/mock';
import { SpacesApi } from '../../../../spaces/public';
import { useUpdateCase } from '../../containers/use_update_case';
import { useGetCase } from '../../containers/use_get_case';
import { useGetCaseUserActions } from '../../containers/use_get_case_user_actions';
@ -47,6 +48,13 @@ const useConnectorsMock = useConnectors as jest.Mock;
const usePostPushToServiceMock = usePostPushToService as jest.Mock;
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
const spacesUiApiMock = {
redirectLegacyUrl: jest.fn().mockResolvedValue(undefined),
components: {
getLegacyUrlConflict: jest.fn().mockReturnValue(<div data-test-subj="conflict-component" />),
},
};
const alertsHit = [
{
_id: 'alert-id-1',
@ -138,6 +146,7 @@ describe('CaseView ', () => {
isLoading: false,
isError: false,
data,
resolveOutcome: 'exactMatch',
updateCase,
fetchCase,
};
@ -174,6 +183,7 @@ describe('CaseView ', () => {
actionTypeTitle: '.servicenow',
iconClass: 'logoSecurity',
});
useKibanaMock().services.spaces = { ui: spacesUiApiMock } as unknown as SpacesApi;
});
it('should render CaseComponent', async () => {
@ -395,36 +405,7 @@ describe('CaseView ', () => {
}));
const wrapper = mount(
<TestProviders>
<CaseView
{...{
allCasesNavigation: {
href: 'all-cases-href',
onClick: jest.fn(),
},
caseDetailsNavigation: {
href: 'case-details-href',
onClick: jest.fn(),
},
caseId: '1234',
configureCasesNavigation: {
href: 'configure-cases-href',
onClick: jest.fn(),
},
getCaseDetailHrefWithCommentId: jest.fn(),
onComponentInitialized: jest.fn(),
actionsNavigation: {
href: jest.fn(),
onClick: jest.fn(),
},
ruleDetailsNavigation: {
href: jest.fn(),
onClick: jest.fn(),
},
showAlertDetails: jest.fn(),
useFetchAlertData: jest.fn().mockReturnValue([false, alertsHit[0]]),
userCanCrud: true,
}}
/>
<CaseView {...caseProps} />
</TestProviders>
);
await waitFor(() => {
@ -439,36 +420,7 @@ describe('CaseView ', () => {
}));
const wrapper = mount(
<TestProviders>
<CaseView
{...{
allCasesNavigation: {
href: 'all-cases-href',
onClick: jest.fn(),
},
caseDetailsNavigation: {
href: 'case-details-href',
onClick: jest.fn(),
},
caseId: '1234',
configureCasesNavigation: {
href: 'configure-cases-href',
onClick: jest.fn(),
},
getCaseDetailHrefWithCommentId: jest.fn(),
onComponentInitialized: jest.fn(),
actionsNavigation: {
href: jest.fn(),
onClick: jest.fn(),
},
ruleDetailsNavigation: {
href: jest.fn(),
onClick: jest.fn(),
},
showAlertDetails: jest.fn(),
useFetchAlertData: jest.fn().mockReturnValue([false, alertsHit[0]]),
userCanCrud: true,
}}
/>
<CaseView {...caseProps} />
</TestProviders>
);
await waitFor(() => {
@ -477,43 +429,66 @@ describe('CaseView ', () => {
});
it('should return case view when data is there', async () => {
(useGetCase as jest.Mock).mockImplementation(() => defaultGetCase);
(useGetCase as jest.Mock).mockImplementation(() => ({
...defaultGetCase,
resolveOutcome: 'exactMatch',
}));
const wrapper = mount(
<TestProviders>
<CaseView
{...{
allCasesNavigation: {
href: 'all-cases-href',
onClick: jest.fn(),
},
caseDetailsNavigation: {
href: 'case-details-href',
onClick: jest.fn(),
},
caseId: '1234',
configureCasesNavigation: {
href: 'configure-cases-href',
onClick: jest.fn(),
},
getCaseDetailHrefWithCommentId: jest.fn(),
onComponentInitialized: jest.fn(),
actionsNavigation: {
href: jest.fn(),
onClick: jest.fn(),
},
ruleDetailsNavigation: {
href: jest.fn(),
onClick: jest.fn(),
},
showAlertDetails: jest.fn(),
useFetchAlertData: jest.fn().mockReturnValue([false, alertsHit[0]]),
userCanCrud: true,
}}
/>
<CaseView {...caseProps} />
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find('[data-test-subj="case-view-title"]').exists()).toBeTruthy();
expect(spacesUiApiMock.components.getLegacyUrlConflict).not.toHaveBeenCalled();
expect(spacesUiApiMock.redirectLegacyUrl).not.toHaveBeenCalled();
});
});
it('should redirect case view when resolves to alias match', async () => {
const resolveAliasId = `${defaultGetCase.data.id}_2`;
(useGetCase as jest.Mock).mockImplementation(() => ({
...defaultGetCase,
resolveOutcome: 'aliasMatch',
resolveAliasId,
}));
const wrapper = mount(
<TestProviders>
<CaseView {...caseProps} />
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find('[data-test-subj="case-view-title"]').exists()).toBeTruthy();
expect(spacesUiApiMock.components.getLegacyUrlConflict).not.toHaveBeenCalled();
expect(spacesUiApiMock.redirectLegacyUrl).toHaveBeenCalledWith(
`cases/${resolveAliasId}`,
'case'
);
});
});
it('should redirect case view when resolves to conflict', async () => {
const resolveAliasId = `${defaultGetCase.data.id}_2`;
(useGetCase as jest.Mock).mockImplementation(() => ({
...defaultGetCase,
resolveOutcome: 'conflict',
resolveAliasId,
}));
const wrapper = mount(
<TestProviders>
<CaseView {...caseProps} />
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find('[data-test-subj="case-view-title"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="conflict-component"]').exists()).toBeTruthy();
expect(spacesUiApiMock.redirectLegacyUrl).not.toHaveBeenCalled();
expect(spacesUiApiMock.components.getLegacyUrlConflict).toHaveBeenCalledWith({
objectNoun: 'case',
currentObjectId: defaultGetCase.data.id,
otherObjectId: resolveAliasId,
otherObjectPath: `cases/${resolveAliasId}`,
});
});
});
@ -521,41 +496,12 @@ describe('CaseView ', () => {
(useGetCase as jest.Mock).mockImplementation(() => defaultGetCase);
const wrapper = mount(
<TestProviders>
<CaseView
{...{
allCasesNavigation: {
href: 'all-cases-href',
onClick: jest.fn(),
},
caseDetailsNavigation: {
href: 'case-details-href',
onClick: jest.fn(),
},
caseId: '1234',
configureCasesNavigation: {
href: 'configure-cases-href',
onClick: jest.fn(),
},
getCaseDetailHrefWithCommentId: jest.fn(),
onComponentInitialized: jest.fn(),
actionsNavigation: {
href: jest.fn(),
onClick: jest.fn(),
},
ruleDetailsNavigation: {
href: jest.fn(),
onClick: jest.fn(),
},
showAlertDetails: jest.fn(),
useFetchAlertData: jest.fn().mockReturnValue([false, alertsHit[0]]),
userCanCrud: true,
}}
/>
<CaseView {...caseProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="case-refresh"]').first().simulate('click');
await waitFor(() => {
expect(fetchCaseUserActions).toBeCalledWith('1234', 'resilient-2', undefined);
expect(fetchCaseUserActions).toBeCalledWith(caseProps.caseData.id, 'resilient-2', undefined);
expect(fetchCase).toBeCalled();
});
});

View file

@ -40,6 +40,7 @@ import { CasesNavigation } from '../links';
import { OwnerProvider } from '../owner_context';
import { getConnectorById } from '../utils';
import { DoesNotExist } from './does_not_exist';
import { useKibana } from '../../common/lib/kibana';
export interface CaseViewComponentProps {
allCasesNavigation: CasesNavigation;
@ -499,6 +500,14 @@ export const CaseComponent = React.memo<CaseComponentProps>(
}
);
export const CaseViewLoading = () => (
<MyEuiFlexGroup gutterSize="none" justifyContent="center" alignItems="center">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner data-test-subj="case-view-loading" size="xl" />
</EuiFlexItem>
</MyEuiFlexGroup>
);
export const CaseView = React.memo(
({
allCasesNavigation,
@ -518,27 +527,59 @@ export const CaseView = React.memo(
refreshRef,
hideSyncAlerts,
}: CaseViewProps) => {
const { data, isLoading, isError, fetchCase, updateCase } = useGetCase(caseId, subCaseId);
if (isError) {
return <DoesNotExist allCasesNavigation={allCasesNavigation} caseId={caseId} />;
}
if (isLoading) {
return (
<MyEuiFlexGroup gutterSize="none" justifyContent="center" alignItems="center">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner data-test-subj="case-view-loading" size="xl" />
</EuiFlexItem>
</MyEuiFlexGroup>
);
}
if (onCaseDataSuccess && data) {
onCaseDataSuccess(data);
}
const { data, resolveOutcome, resolveAliasId, isLoading, isError, fetchCase, updateCase } =
useGetCase(caseId, subCaseId);
const { spaces: spacesApi, http } = useKibana().services;
return (
useEffect(() => {
if (onCaseDataSuccess && data) {
onCaseDataSuccess(data);
}
}, [data, onCaseDataSuccess]);
useEffect(() => {
if (spacesApi && resolveOutcome === 'aliasMatch' && resolveAliasId != null) {
// CAUTION: the path /cases/:detailName is working in both Observability (/app/observability/cases/:detailName) and
// Security Solutions (/app/security/cases/:detailName) plugins. This will need to be changed if this component is loaded
// under any another path, passing a path builder function by props from every parent plugin.
const newPath = http.basePath.prepend(
`cases/${resolveAliasId}${window.location.search}${window.location.hash}`
);
spacesApi.ui.redirectLegacyUrl(newPath, i18n.CASE);
}
}, [resolveOutcome, resolveAliasId, spacesApi, http]);
const getLegacyUrlConflictCallout = useCallback(() => {
// This function returns a callout component *if* we have encountered a "legacy URL conflict" scenario
if (data && spacesApi && resolveOutcome === 'conflict' && resolveAliasId != null) {
// We have resolved to one object, but another object has a legacy URL alias associated with this ID/page. We should display a
// callout with a warning for the user, and provide a way for them to navigate to the other object.
const otherObjectId = resolveAliasId; // This is always defined if outcome === 'conflict'
// CAUTION: the path /cases/:detailName is working in both Observability (/app/observability/cases/:detailName) and
// Security Solutions (/app/security/cases/:detailName) plugins. This will need to be changed if this component is loaded
// under any another path, passing a path builder function by props from every parent plugin.
const otherObjectPath = http.basePath.prepend(
`cases/${otherObjectId}${window.location.search}${window.location.hash}`
);
return spacesApi.ui.components.getLegacyUrlConflict({
objectNoun: i18n.CASE,
currentObjectId: data.id,
otherObjectId,
otherObjectPath,
});
}
return null;
}, [data, resolveAliasId, resolveOutcome, spacesApi, http.basePath]);
return isError ? (
<DoesNotExist allCasesNavigation={allCasesNavigation} caseId={caseId} />
) : isLoading ? (
<CaseViewLoading />
) : (
data && (
<CasesTimelineIntegrationProvider timelineIntegration={timelineIntegration}>
<OwnerProvider owner={[data.owner]}>
{getLegacyUrlConflictCallout()}
<CaseComponent
allCasesNavigation={allCasesNavigation}
caseData={data}
@ -566,6 +607,7 @@ export const CaseView = React.memo(
);
CaseComponent.displayName = 'CaseComponent';
CaseViewLoading.displayName = 'CaseViewLoading';
CaseView.displayName = 'CaseView';
// eslint-disable-next-line import/no-default-export

View file

@ -21,6 +21,7 @@ import {
basicCase,
basicCaseCommentPatch,
basicCasePost,
basicResolvedCase,
casesStatus,
caseUserActions,
pushedCase,
@ -33,6 +34,7 @@ import {
CommentRequest,
User,
CaseStatuses,
ResolvedCase,
} from '../../../common';
export const getCase = async (
@ -41,6 +43,12 @@ export const getCase = async (
signal: AbortSignal
): Promise<Case> => Promise.resolve(basicCase);
export const resolveCase = async (
caseId: string,
includeComments: boolean = true,
signal: AbortSignal
): Promise<ResolvedCase> => Promise.resolve(basicResolvedCase);
export const getCasesStatus = async (signal: AbortSignal): Promise<CasesStatus> =>
Promise.resolve(casesStatus);

View file

@ -30,6 +30,7 @@ import {
postCase,
postComment,
pushCase,
resolveCase,
} from './api';
import {
@ -68,7 +69,7 @@ describe('Case Configuration API', () => {
});
const data = ['1', '2'];
test('check url, method, signal', async () => {
test('should be called with correct check url, method, signal', async () => {
await deleteCases(data, abortCtrl.signal);
expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}`, {
method: 'DELETE',
@ -77,7 +78,7 @@ describe('Case Configuration API', () => {
});
});
test('happy path', async () => {
test('should return correct response', async () => {
const resp = await deleteCases(data, abortCtrl.signal);
expect(resp).toEqual('');
});
@ -89,7 +90,7 @@ describe('Case Configuration API', () => {
fetchMock.mockResolvedValue(actionLicenses);
});
test('check url, method, signal', async () => {
test('should be called with correct check url, method, signal', async () => {
await getActionLicense(abortCtrl.signal);
expect(fetchMock).toHaveBeenCalledWith(`/api/actions/connector_types`, {
method: 'GET',
@ -97,7 +98,7 @@ describe('Case Configuration API', () => {
});
});
test('happy path', async () => {
test('should return correct response', async () => {
const resp = await getActionLicense(abortCtrl.signal);
expect(resp).toEqual(actionLicenses);
});
@ -110,7 +111,7 @@ describe('Case Configuration API', () => {
});
const data = basicCase.id;
test('check url, method, signal', async () => {
test('should be called with correct check url, method, signal', async () => {
await getCase(data, true, abortCtrl.signal);
expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/${basicCase.id}`, {
method: 'GET',
@ -119,18 +120,46 @@ describe('Case Configuration API', () => {
});
});
test('happy path', async () => {
test('should return correct response', async () => {
const resp = await getCase(data, true, abortCtrl.signal);
expect(resp).toEqual(basicCase);
});
});
describe('resolveCase', () => {
const targetAliasId = '12345';
const basicResolveCase = {
outcome: 'aliasMatch',
case: basicCaseSnake,
};
const caseId = basicCase.id;
beforeEach(() => {
fetchMock.mockClear();
fetchMock.mockResolvedValue({ ...basicResolveCase, target_alias_id: targetAliasId });
});
test('should be called with correct check url, method, signal', async () => {
await resolveCase(caseId, true, abortCtrl.signal);
expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/${caseId}/resolve`, {
method: 'GET',
query: { includeComments: true },
signal: abortCtrl.signal,
});
});
test('should return correct response', async () => {
const resp = await resolveCase(caseId, true, abortCtrl.signal);
expect(resp).toEqual({ ...basicResolveCase, case: basicCase, targetAliasId });
});
});
describe('getCases', () => {
beforeEach(() => {
fetchMock.mockClear();
fetchMock.mockResolvedValue(allCasesSnake);
});
test('check url, method, signal', async () => {
test('should be called with correct check url, method, signal', async () => {
await getCases({
filterOptions: { ...DEFAULT_FILTER_OPTIONS, owner: [SECURITY_SOLUTION_OWNER] },
queryParams: DEFAULT_QUERY_PARAMS,
@ -148,7 +177,7 @@ describe('Case Configuration API', () => {
});
});
test('correctly applies filters', async () => {
test('should applies correct filters', async () => {
await getCases({
filterOptions: {
...DEFAULT_FILTER_OPTIONS,
@ -175,7 +204,7 @@ describe('Case Configuration API', () => {
});
});
test('tags with weird chars get handled gracefully', async () => {
test('should handle tags with weird chars', async () => {
const weirdTags: string[] = ['(', '"double"'];
await getCases({
@ -204,7 +233,7 @@ describe('Case Configuration API', () => {
});
});
test('happy path', async () => {
test('should return correct response', async () => {
const resp = await getCases({
filterOptions: { ...DEFAULT_FILTER_OPTIONS, owner: [SECURITY_SOLUTION_OWNER] },
queryParams: DEFAULT_QUERY_PARAMS,
@ -219,7 +248,7 @@ describe('Case Configuration API', () => {
fetchMock.mockClear();
fetchMock.mockResolvedValue(casesStatusSnake);
});
test('check url, method, signal', async () => {
test('should be called with correct check url, method, signal', async () => {
await getCasesStatus(abortCtrl.signal, [SECURITY_SOLUTION_OWNER]);
expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/status`, {
method: 'GET',
@ -228,7 +257,7 @@ describe('Case Configuration API', () => {
});
});
test('happy path', async () => {
test('should return correct response', async () => {
const resp = await getCasesStatus(abortCtrl.signal, [SECURITY_SOLUTION_OWNER]);
expect(resp).toEqual(casesStatus);
});
@ -240,7 +269,7 @@ describe('Case Configuration API', () => {
fetchMock.mockResolvedValue(caseUserActionsSnake);
});
test('check url, method, signal', async () => {
test('should be called with correct check url, method, signal', async () => {
await getCaseUserActions(basicCase.id, abortCtrl.signal);
expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/${basicCase.id}/user_actions`, {
method: 'GET',
@ -248,7 +277,7 @@ describe('Case Configuration API', () => {
});
});
test('happy path', async () => {
test('should return correct response', async () => {
const resp = await getCaseUserActions(basicCase.id, abortCtrl.signal);
expect(resp).toEqual(caseUserActions);
});
@ -260,7 +289,7 @@ describe('Case Configuration API', () => {
fetchMock.mockResolvedValue(respReporters);
});
test('check url, method, signal', async () => {
test('should be called with correct check url, method, signal', async () => {
await getReporters(abortCtrl.signal, [SECURITY_SOLUTION_OWNER]);
expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/reporters`, {
method: 'GET',
@ -271,7 +300,7 @@ describe('Case Configuration API', () => {
});
});
test('happy path', async () => {
test('should return correct response', async () => {
const resp = await getReporters(abortCtrl.signal, [SECURITY_SOLUTION_OWNER]);
expect(resp).toEqual(respReporters);
});
@ -283,7 +312,7 @@ describe('Case Configuration API', () => {
fetchMock.mockResolvedValue(tags);
});
test('check url, method, signal', async () => {
test('should be called with correct check url, method, signal', async () => {
await getTags(abortCtrl.signal, [SECURITY_SOLUTION_OWNER]);
expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/tags`, {
method: 'GET',
@ -294,7 +323,7 @@ describe('Case Configuration API', () => {
});
});
test('happy path', async () => {
test('should return correct response', async () => {
const resp = await getTags(abortCtrl.signal, [SECURITY_SOLUTION_OWNER]);
expect(resp).toEqual(tags);
});
@ -306,7 +335,7 @@ describe('Case Configuration API', () => {
fetchMock.mockResolvedValue([basicCaseSnake]);
});
const data = { description: 'updated description' };
test('check url, method, signal', async () => {
test('should be called with correct check url, method, signal', async () => {
await patchCase(basicCase.id, data, basicCase.version, abortCtrl.signal);
expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}`, {
method: 'PATCH',
@ -317,7 +346,7 @@ describe('Case Configuration API', () => {
});
});
test('happy path', async () => {
test('should return correct response', async () => {
const resp = await patchCase(
basicCase.id,
{ description: 'updated description' },
@ -341,7 +370,7 @@ describe('Case Configuration API', () => {
},
];
test('check url, method, signal', async () => {
test('should be called with correct check url, method, signal', async () => {
await patchCasesStatus(data, abortCtrl.signal);
expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}`, {
method: 'PATCH',
@ -350,7 +379,7 @@ describe('Case Configuration API', () => {
});
});
test('happy path', async () => {
test('should return correct response', async () => {
const resp = await patchCasesStatus(data, abortCtrl.signal);
expect(resp).toEqual({ ...cases });
});
@ -362,7 +391,7 @@ describe('Case Configuration API', () => {
fetchMock.mockResolvedValue(basicCaseSnake);
});
test('check url, method, signal', async () => {
test('should be called with correct check url, method, signal', async () => {
await patchComment({
caseId: basicCase.id,
commentId: basicCase.comments[0].id,
@ -384,7 +413,7 @@ describe('Case Configuration API', () => {
});
});
test('happy path', async () => {
test('should return correct response', async () => {
const resp = await patchComment({
caseId: basicCase.id,
commentId: basicCase.comments[0].id,
@ -418,7 +447,7 @@ describe('Case Configuration API', () => {
owner: SECURITY_SOLUTION_OWNER,
};
test('check url, method, signal', async () => {
test('should be called with correct check url, method, signal', async () => {
await postCase(data, abortCtrl.signal);
expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}`, {
method: 'POST',
@ -427,7 +456,7 @@ describe('Case Configuration API', () => {
});
});
test('happy path', async () => {
test('should return correct response', async () => {
const resp = await postCase(data, abortCtrl.signal);
expect(resp).toEqual(basicCase);
});
@ -444,7 +473,7 @@ describe('Case Configuration API', () => {
type: CommentType.user as const,
};
test('check url, method, signal', async () => {
test('should be called with correct check url, method, signal', async () => {
await postComment(data, basicCase.id, abortCtrl.signal);
expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/${basicCase.id}/comments`, {
method: 'POST',
@ -453,7 +482,7 @@ describe('Case Configuration API', () => {
});
});
test('happy path', async () => {
test('should return correct response', async () => {
const resp = await postComment(data, basicCase.id, abortCtrl.signal);
expect(resp).toEqual(basicCase);
});
@ -467,7 +496,7 @@ describe('Case Configuration API', () => {
fetchMock.mockResolvedValue(pushedCaseSnake);
});
test('check url, method, signal', async () => {
test('should be called with correct check url, method, signal', async () => {
await pushCase(basicCase.id, connectorId, abortCtrl.signal);
expect(fetchMock).toHaveBeenCalledWith(
`${CASES_URL}/${basicCase.id}/connector/${connectorId}/_push`,
@ -479,7 +508,7 @@ describe('Case Configuration API', () => {
);
});
test('happy path', async () => {
test('should return correct response', async () => {
const resp = await pushCase(basicCase.id, connectorId, abortCtrl.signal);
expect(resp).toEqual(pushedCase);
});

View file

@ -14,6 +14,7 @@ import {
CasePatchRequest,
CasePostRequest,
CaseResponse,
CaseResolveResponse,
CASES_URL,
CasesFindResponse,
CasesResponse,
@ -35,6 +36,7 @@ import {
SubCaseResponse,
SubCasesResponse,
User,
ResolvedCase,
} from '../../common';
import { getAllConnectorTypesUrl } from '../../common/utils/connectors_api';
@ -61,6 +63,7 @@ import {
decodeCasesFindResponse,
decodeCasesStatusResponse,
decodeCaseUserActionsResponse,
decodeCaseResolveResponse,
} from './utils';
export const getCase = async (
@ -78,6 +81,24 @@ export const getCase = async (
return convertToCamelCase<CaseResponse, Case>(decodeCaseResponse(response));
};
export const resolveCase = async (
caseId: string,
includeComments: boolean = true,
signal: AbortSignal
): Promise<ResolvedCase> => {
const response = await KibanaServices.get().http.fetch<CaseResolveResponse>(
getCaseDetailsUrl(caseId) + '/resolve',
{
method: 'GET',
query: {
includeComments,
},
signal,
}
);
return convertToCamelCase<CaseResolveResponse, ResolvedCase>(decodeCaseResolveResponse(response));
};
export const getSubCase = async (
caseId: string,
subCaseId: string,

View file

@ -20,6 +20,7 @@ import {
CommentResponse,
CommentType,
ConnectorTypes,
ResolvedCase,
isCreateConnector,
isPush,
isUpdateConnector,
@ -163,6 +164,12 @@ export const basicCase: Case = {
subCaseIds: [],
};
export const basicResolvedCase: ResolvedCase = {
case: basicCase,
outcome: 'aliasMatch',
aliasTargetId: `${basicCase.id}_2`,
};
export const collectionCase: Case = {
type: CaseType.collection,
owner: SECURITY_SOLUTION_OWNER,

View file

@ -7,7 +7,7 @@
import { renderHook, act } from '@testing-library/react-hooks';
import { useGetCase, UseGetCase } from './use_get_case';
import { basicCase } from './mock';
import { basicCase, basicResolvedCase } from './mock';
import * as api from './api';
jest.mock('./api');
@ -28,6 +28,7 @@ describe('useGetCase', () => {
await waitForNextUpdate();
expect(result.current).toEqual({
data: null,
resolveOutcome: null,
isLoading: false,
isError: false,
fetchCase: result.current.fetchCase,
@ -36,13 +37,13 @@ describe('useGetCase', () => {
});
});
it('calls getCase with correct arguments', async () => {
const spyOnGetCase = jest.spyOn(api, 'getCase');
it('calls resolveCase with correct arguments', async () => {
const spyOnResolveCase = jest.spyOn(api, 'resolveCase');
await act(async () => {
const { waitForNextUpdate } = renderHook<string, UseGetCase>(() => useGetCase(basicCase.id));
await waitForNextUpdate();
await waitForNextUpdate();
expect(spyOnGetCase).toBeCalledWith(basicCase.id, true, abortCtrl.signal);
expect(spyOnResolveCase).toBeCalledWith(basicCase.id, true, abortCtrl.signal);
});
});
@ -55,6 +56,8 @@ describe('useGetCase', () => {
await waitForNextUpdate();
expect(result.current).toEqual({
data: basicCase,
resolveOutcome: basicResolvedCase.outcome,
resolveAliasId: basicResolvedCase.aliasTargetId,
isLoading: false,
isError: false,
fetchCase: result.current.fetchCase,
@ -64,7 +67,7 @@ describe('useGetCase', () => {
});
it('refetch case', async () => {
const spyOnGetCase = jest.spyOn(api, 'getCase');
const spyOnResolveCase = jest.spyOn(api, 'resolveCase');
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetCase>(() =>
useGetCase(basicCase.id)
@ -72,7 +75,7 @@ describe('useGetCase', () => {
await waitForNextUpdate();
await waitForNextUpdate();
result.current.fetchCase();
expect(spyOnGetCase).toHaveBeenCalledTimes(2);
expect(spyOnResolveCase).toHaveBeenCalledTimes(2);
});
});
@ -103,8 +106,8 @@ describe('useGetCase', () => {
});
it('unhappy path', async () => {
const spyOnGetCase = jest.spyOn(api, 'getCase');
spyOnGetCase.mockImplementation(() => {
const spyOnResolveCase = jest.spyOn(api, 'resolveCase');
spyOnResolveCase.mockImplementation(() => {
throw new Error('Something went wrong');
});
@ -117,6 +120,7 @@ describe('useGetCase', () => {
expect(result.current).toEqual({
data: null,
resolveOutcome: null,
isLoading: false,
isError: true,
fetchCase: result.current.fetchCase,

View file

@ -7,20 +7,22 @@
import { useEffect, useReducer, useCallback, useRef } from 'react';
import { Case } from './types';
import { Case, ResolvedCase } from './types';
import * as i18n from './translations';
import { useToasts } from '../common/lib/kibana';
import { getCase, getSubCase } from './api';
import { resolveCase, getSubCase } from './api';
interface CaseState {
data: Case | null;
resolveOutcome: ResolvedCase['outcome'] | null;
resolveAliasId?: string;
isLoading: boolean;
isError: boolean;
}
type Action =
| { type: 'FETCH_INIT'; payload: { silent: boolean } }
| { type: 'FETCH_SUCCESS'; payload: Case }
| { type: 'FETCH_SUCCESS'; payload: ResolvedCase }
| { type: 'FETCH_FAILURE' }
| { type: 'UPDATE_CASE'; payload: Case };
@ -40,7 +42,9 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => {
...state,
isLoading: false,
isError: false,
data: action.payload,
data: action.payload.case,
resolveOutcome: action.payload.outcome,
resolveAliasId: action.payload.aliasTargetId,
};
case 'FETCH_FAILURE':
return {
@ -72,6 +76,7 @@ export const useGetCase = (caseId: string, subCaseId?: string): UseGetCase => {
isLoading: false,
isError: false,
data: null,
resolveOutcome: null,
});
const toasts = useToasts();
const isCancelledRef = useRef(false);
@ -89,9 +94,12 @@ export const useGetCase = (caseId: string, subCaseId?: string): UseGetCase => {
abortCtrlRef.current = new AbortController();
dispatch({ type: 'FETCH_INIT', payload: { silent } });
const response = await (subCaseId
? getSubCase(caseId, subCaseId, true, abortCtrlRef.current.signal)
: getCase(caseId, true, abortCtrlRef.current.signal));
const response: ResolvedCase = subCaseId
? {
case: await getSubCase(caseId, subCaseId, true, abortCtrlRef.current.signal),
outcome: 'exactMatch', // sub-cases are not resolved, forced to exactMatch always
}
: await resolveCase(caseId, true, abortCtrlRef.current.signal);
if (!isCancelledRef.current) {
dispatch({ type: 'FETCH_SUCCESS', payload: response });

View file

@ -30,6 +30,8 @@ import {
CaseUserActionsResponseRt,
CommentType,
CasePatchRequest,
CaseResolveResponse,
CaseResolveResponseRt,
} from '../../common';
import { AllCases, Case, UpdateByKey } from './types';
import * as i18n from './translations';
@ -80,6 +82,12 @@ export const createToasterPlainError = (message: string) => new ToasterError([me
export const decodeCaseResponse = (respCase?: CaseResponse) =>
pipe(CaseResponseRt.decode(respCase), fold(throwErrors(createToasterPlainError), identity));
export const decodeCaseResolveResponse = (respCase?: CaseResolveResponse) =>
pipe(
CaseResolveResponseRt.decode(respCase),
fold(throwErrors(createToasterPlainError), identity)
);
export const decodeCasesResponse = (respCase?: CasesResponse) =>
pipe(CasesResponseRt.decode(respCase), fold(throwErrors(createToasterPlainError), identity));

View file

@ -16,6 +16,7 @@ import type {
} from '../../triggers_actions_ui/public';
import type { DataPublicPluginStart } from '../../../../src/plugins/data/public';
import type { EmbeddableStart } from '../../../../src/plugins/embeddable/public';
import type { SpacesPluginStart } from '../../spaces/public';
import type { Storage } from '../../../../src/plugins/kibana_utils/public';
import { AllCasesProps } from './components/all_cases';
@ -36,6 +37,7 @@ export interface StartPlugins {
lens: LensPublicStart;
storage: Storage;
triggersActionsUi: TriggersActionsStart;
spaces?: SpacesPluginStart;
}
/**

View file

@ -1596,6 +1596,90 @@ Object {
}
`;
exports[`audit_logger log function event structure creates the correct audit event for operation: "resolveCase" with an error and entity 1`] = `
Object {
"error": Object {
"code": "Error",
"message": "an error",
},
"event": Object {
"action": "case_resolve",
"category": Array [
"database",
],
"outcome": "failure",
"type": Array [
"access",
],
},
"kibana": Object {
"saved_object": Object {
"id": "1",
"type": "cases",
},
},
"message": "Failed attempt to access cases [id=1] as owner \\"awesome\\"",
}
`;
exports[`audit_logger log function event structure creates the correct audit event for operation: "resolveCase" with an error but no entity 1`] = `
Object {
"error": Object {
"code": "Error",
"message": "an error",
},
"event": Object {
"action": "case_resolve",
"category": Array [
"database",
],
"outcome": "failure",
"type": Array [
"access",
],
},
"message": "Failed attempt to access a case as any owners",
}
`;
exports[`audit_logger log function event structure creates the correct audit event for operation: "resolveCase" without an error but with an entity 1`] = `
Object {
"event": Object {
"action": "case_resolve",
"category": Array [
"database",
],
"outcome": "success",
"type": Array [
"access",
],
},
"kibana": Object {
"saved_object": Object {
"id": "5",
"type": "cases",
},
},
"message": "User has accessed cases [id=5] as owner \\"super\\"",
}
`;
exports[`audit_logger log function event structure creates the correct audit event for operation: "resolveCase" without an error or entity 1`] = `
Object {
"event": Object {
"action": "case_resolve",
"category": Array [
"database",
],
"outcome": "success",
"type": Array [
"access",
],
},
"message": "User has accessed a case as any owners",
}
`;
exports[`audit_logger log function event structure creates the correct audit event for operation: "updateCase" with an error and entity 1`] = `
Object {
"error": Object {

View file

@ -152,6 +152,14 @@ export const Operations: Record<ReadOperations | WriteOperations, OperationDetai
docType: 'case',
savedObjectType: CASE_SAVED_OBJECT,
},
[ReadOperations.ResolveCase]: {
ecsType: EVENT_TYPES.access,
name: ACCESS_CASE_OPERATION,
action: 'case_resolve',
verbs: accessVerbs,
docType: 'case',
savedObjectType: CASE_SAVED_OBJECT,
},
[ReadOperations.FindCases]: {
ecsType: EVENT_TYPES.access,
name: ACCESS_CASE_OPERATION,

View file

@ -28,6 +28,7 @@ export type GetSpaceFn = (request: KibanaRequest) => Promise<Space | undefined>;
*/
export enum ReadOperations {
GetCase = 'getCase',
ResolveCase = 'resolveCase',
FindCases = 'findCases',
GetCaseIDsByAlertID = 'getCaseIDsByAlertID',
GetCaseStatuses = 'getCaseStatuses',

View file

@ -18,6 +18,7 @@ import { CasesClient } from '../client';
import { CasesClientInternal } from '../client_internal';
import {
ICasePostRequest,
ICaseResolveResponse,
ICaseResponse,
ICasesFindRequest,
ICasesFindResponse,
@ -31,6 +32,7 @@ import { find } from './find';
import {
CasesByAlertIDParams,
get,
resolve,
getCasesByAlertID,
GetParams,
getReporters,
@ -57,6 +59,11 @@ export interface CasesSubClient {
* Retrieves a single case with the specified ID.
*/
get(params: GetParams): Promise<ICaseResponse>;
/**
* @experimental
* Retrieves a single case resolving the specified ID.
*/
resolve(params: GetParams): Promise<ICaseResolveResponse>;
/**
* Pushes a specific case to an external system.
*/
@ -99,6 +106,7 @@ export const createCasesSubClient = (
create: (data: CasePostRequest) => create(data, clientArgs),
find: (params: CasesFindRequest) => find(params, clientArgs),
get: (params: GetParams) => get(params, clientArgs),
resolve: (params: GetParams) => resolve(params, clientArgs),
push: (params: PushParams) => push(params, clientArgs, casesClient, casesClientInternal),
update: (cases: CasesPatchRequest) => update(cases, clientArgs, casesClientInternal),
delete: (ids: string[]) => deleteCases(ids, clientArgs),

View file

@ -9,10 +9,12 @@ import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
import { SavedObject } from 'kibana/server';
import { SavedObject, SavedObjectsResolveResponse } from 'kibana/server';
import {
CaseResponseRt,
CaseResponse,
CaseResolveResponseRt,
CaseResolveResponse,
User,
UsersRt,
AllTagsFindRequest,
@ -230,6 +232,86 @@ export const get = async (
}
};
/**
* Retrieves a case resolving its ID and optionally loading its comments and sub case comments.
*
* @experimental
*/
export const resolve = async (
{ id, includeComments, includeSubCaseComments }: GetParams,
clientArgs: CasesClientArgs
): Promise<CaseResolveResponse> => {
const { unsecuredSavedObjectsClient, caseService, logger, authorization } = clientArgs;
try {
if (!ENABLE_CASE_CONNECTOR && includeSubCaseComments) {
throw Boom.badRequest(
'The `includeSubCaseComments` is not supported when the case connector feature is disabled'
);
}
const {
saved_object: savedObject,
...resolveData
}: SavedObjectsResolveResponse<CaseAttributes> = await caseService.getResolveCase({
unsecuredSavedObjectsClient,
id,
});
await authorization.ensureAuthorized({
operation: Operations.resolveCase,
entities: [
{
id: savedObject.id,
owner: savedObject.attributes.owner,
},
],
});
let subCaseIds: string[] = [];
if (ENABLE_CASE_CONNECTOR) {
const subCasesForCaseId = await caseService.findSubCasesByCaseId({
unsecuredSavedObjectsClient,
ids: [id],
});
subCaseIds = subCasesForCaseId.saved_objects.map((so) => so.id);
}
if (!includeComments) {
return CaseResolveResponseRt.encode({
...resolveData,
case: flattenCaseSavedObject({
savedObject,
subCaseIds,
}),
});
}
const theComments = await caseService.getAllCaseComments({
unsecuredSavedObjectsClient,
id,
options: {
sortField: 'created_at',
sortOrder: 'asc',
},
includeSubCaseComments: ENABLE_CASE_CONNECTOR && includeSubCaseComments,
});
return CaseResolveResponseRt.encode({
...resolveData,
case: flattenCaseSavedObject({
savedObject,
subCaseIds,
comments: theComments.saved_objects,
totalComment: theComments.total,
totalAlerts: countAlertsForID({ comments: theComments, id }),
}),
});
} catch (error) {
throw createCaseError({ message: `Failed to resolve case id: ${id}: ${error}`, error, logger });
}
};
/**
* Retrieves the tags from all the cases.
*/

View file

@ -22,6 +22,7 @@ const createCasesSubClientMock = (): CasesSubClientMock => {
return {
create: jest.fn(),
find: jest.fn(),
resolve: jest.fn(),
get: jest.fn(),
push: jest.fn(),
update: jest.fn(),

View file

@ -16,6 +16,7 @@
import {
AllCommentsResponse,
CasePostRequest,
CaseResolveResponse,
CaseResponse,
CasesConfigurePatch,
CasesConfigureRequest,
@ -40,6 +41,7 @@ export interface ICasePostRequest extends CasePostRequest {}
export interface ICasesFindRequest extends CasesFindRequest {}
export interface ICasesPatchRequest extends CasesPatchRequest {}
export interface ICaseResponse extends CaseResponse {}
export interface ICaseResolveResponse extends CaseResolveResponse {}
export interface ICasesResponse extends CasesResponse {}
export interface ICasesFindResponse extends CasesFindResponse {}

View file

@ -45,4 +45,38 @@ export function initGetCaseApi({ router, logger }: RouteDeps) {
}
}
);
router.get(
{
path: `${CASE_DETAILS_URL}/resolve`,
validate: {
params: schema.object({
case_id: schema.string(),
}),
query: schema.object({
includeComments: schema.boolean({ defaultValue: true }),
includeSubCaseComments: schema.maybe(schema.boolean({ defaultValue: false })),
}),
},
},
async (context, request, response) => {
try {
const casesClient = await context.cases.getCasesClient();
const id = request.params.case_id;
return response.ok({
body: await casesClient.cases.resolve({
id,
includeComments: request.query.includeComments,
includeSubCaseComments: request.query.includeSubCaseComments,
}),
});
} catch (error) {
logger.error(
`Failed to retrieve case in resolve route case id: ${request.params.case_id} \ninclude comments: ${request.query.includeComments} \ninclude sub comments: ${request.query.includeSubCaseComments}: ${error}`
);
return response.customError(wrapError(error));
}
}
);
}

View file

@ -16,6 +16,7 @@ import {
SavedObjectsFindResult,
SavedObjectsBulkUpdateResponse,
SavedObjectsUpdateResponse,
SavedObjectsResolveResponse,
} from 'kibana/server';
import type { estypes } from '@elastic/elasticsearch';
@ -738,6 +739,27 @@ export class CasesService {
throw error;
}
}
public async getResolveCase({
unsecuredSavedObjectsClient,
id: caseId,
}: GetCaseArgs): Promise<SavedObjectsResolveResponse<CaseAttributes>> {
try {
this.log.debug(`Attempting to resolve case ${caseId}`);
const resolveCaseResult = await unsecuredSavedObjectsClient.resolve<ESCaseAttributes>(
CASE_SAVED_OBJECT,
caseId
);
return {
...resolveCaseResult,
saved_object: transformSavedObjectToExternalModel(resolveCaseResult.saved_object),
};
} catch (error) {
this.log.error(`Error on resolve case ${caseId}: ${error}`);
throw error;
}
}
public async getSubCase({
unsecuredSavedObjectsClient,
id,

View file

@ -36,6 +36,7 @@ export const createCaseServiceMock = (): CaseServiceMock => {
getCases: jest.fn(),
getCaseIdsByAlertId: jest.fn(),
getMostRecentSubCase: jest.fn(),
getResolveCase: jest.fn(),
getSubCase: jest.fn(),
getSubCases: jest.fn(),
getTags: jest.fn(),

View file

@ -15,6 +15,7 @@
"home",
"lens",
"licensing",
"spaces",
"usageCollection"
],
"requiredPlugins": [

View file

@ -26,6 +26,7 @@
{ "path": "../cases/tsconfig.json" },
{ "path": "../lens/tsconfig.json" },
{ "path": "../rule_registry/tsconfig.json" },
{ "path": "../spaces/tsconfig.json" },
{ "path": "../timelines/tsconfig.json"},
{ "path": "../translations/tsconfig.json" }
]

View file

@ -47,6 +47,7 @@ import {
AlertResponse,
ConnectorMappings,
CasesByAlertId,
CaseResolveResponse,
} from '../../../../plugins/cases/common/api';
import { getPostCaseRequest, postCollectionReq, postCommentGenAlertReq } from './mock';
import { getCaseUserActionUrl, getSubCasesUrl } from '../../../../plugins/cases/common/api/helpers';
@ -1066,6 +1067,32 @@ export const getCase = async ({
return theCase;
};
export const resolveCase = async ({
supertest,
caseId,
includeComments = false,
expectedHttpCode = 200,
auth = { user: superUser, space: null },
}: {
supertest: SuperTest.SuperTest<SuperTest.Test>;
caseId: string;
includeComments?: boolean;
expectedHttpCode?: number;
auth?: { user: User; space: string | null };
}): Promise<CaseResolveResponse> => {
const { body: theResolvedCase } = await supertest
.get(
`${getSpaceUrlPrefix(
auth?.space
)}${CASES_URL}/${caseId}/resolve?includeComments=${includeComments}`
)
.set('kbn-xsrf', 'true')
.auth(auth.user.username, auth.user.password)
.expect(expectedHttpCode);
return theResolvedCase;
};
export const findCases = async ({
supertest,
query = {},

View file

@ -11,7 +11,7 @@ import {
CASES_URL,
SECURITY_SOLUTION_OWNER,
} from '../../../../../../plugins/cases/common/constants';
import { getCase, getCaseSavedObjectsFromES } from '../../../../common/lib/utils';
import { getCase, getCaseSavedObjectsFromES, resolveCase } from '../../../../common/lib/utils';
// eslint-disable-next-line import/no-default-export
export default function createGetTests({ getService }: FtrProviderContext) {
@ -207,5 +207,76 @@ export default function createGetTests({ getService }: FtrProviderContext) {
});
});
});
describe('7.16.0', () => {
before(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/cases/migrations/7.13.2');
});
after(async () => {
await esArchiver.unload('x-pack/test/functional/es_archives/cases/migrations/7.13.2');
});
describe('resolve', () => {
it('should return exactMatch outcome', async () => {
const { outcome } = await resolveCase({
supertest,
caseId: 'e49ad6e0-cf9d-11eb-a603-13e7747d215c',
});
expect(outcome).to.be('exactMatch');
});
it('should preserve the same case info', async () => {
const { case: theCase } = await resolveCase({
supertest,
caseId: 'e49ad6e0-cf9d-11eb-a603-13e7747d215c',
});
expect(theCase.title).to.be('A case');
expect(theCase.description).to.be('asdf');
expect(theCase.owner).to.be(SECURITY_SOLUTION_OWNER);
});
it('should preserve the same connector', async () => {
const { case: theCase } = await resolveCase({
supertest,
caseId: 'e49ad6e0-cf9d-11eb-a603-13e7747d215c',
});
expect(theCase.connector).to.eql({
fields: {
issueType: '10002',
parent: null,
priority: null,
},
id: 'd68508f0-cf9d-11eb-a603-13e7747d215c',
name: 'Test Jira',
type: '.jira',
});
});
it('should preserve the same external service', async () => {
const { case: theCase } = await resolveCase({
supertest,
caseId: 'e49ad6e0-cf9d-11eb-a603-13e7747d215c',
});
expect(theCase.external_service).to.eql({
connector_id: 'd68508f0-cf9d-11eb-a603-13e7747d215c',
connector_name: 'Test Jira',
external_id: '10106',
external_title: 'TPN-99',
external_url: 'https://cases-testing.atlassian.net/browse/TPN-99',
pushed_at: '2021-06-17T18:57:45.524Z',
pushed_by: {
email: null,
full_name: 'j@j.com',
username: '711621466',
},
});
});
});
});
});
}

View file

@ -0,0 +1,221 @@
/*
* 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 { FtrProviderContext } from '../../../../common/ftr_provider_context';
import { AttributesTypeUser } from '../../../../../../plugins/cases/common/api';
import { CASES_URL } from '../../../../../../plugins/cases/common/constants';
import {
defaultUser,
postCaseReq,
postCaseResp,
postCommentUserReq,
getPostCaseRequest,
} from '../../../../common/lib/mock';
import {
deleteCasesByESQuery,
createCase,
resolveCase,
createComment,
removeServerGeneratedPropertiesFromCase,
removeServerGeneratedPropertiesFromSavedObject,
} from '../../../../common/lib/utils';
import {
secOnly,
obsOnly,
globalRead,
superUser,
secOnlyRead,
obsOnlyRead,
obsSecRead,
noKibanaPrivileges,
obsSec,
} from '../../../../common/lib/authentication/users';
import { getUserInfo } from '../../../../common/lib/authentication';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext): void => {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const es = getService('es');
describe('resolve_case', () => {
afterEach(async () => {
await deleteCasesByESQuery(es);
});
it('should resolve a case with no comments', async () => {
const postedCase = await createCase(supertest, getPostCaseRequest());
const resolvedCase = await resolveCase({
supertest,
caseId: postedCase.id,
includeComments: true,
});
const data = removeServerGeneratedPropertiesFromCase(resolvedCase.case);
expect(data).to.eql(postCaseResp());
expect(data.comments?.length).to.eql(0);
});
it('should resolve a case with comments', async () => {
const postedCase = await createCase(supertest, postCaseReq);
await createComment({ supertest, caseId: postedCase.id, params: postCommentUserReq });
const resolvedCase = await resolveCase({
supertest,
caseId: postedCase.id,
includeComments: true,
});
const comment = removeServerGeneratedPropertiesFromSavedObject(
resolvedCase.case.comments![0] as AttributesTypeUser
);
expect(resolvedCase.case.comments?.length).to.eql(1);
expect(comment).to.eql({
type: postCommentUserReq.type,
comment: postCommentUserReq.comment,
associationType: 'case',
created_by: defaultUser,
pushed_at: null,
pushed_by: null,
updated_by: null,
owner: 'securitySolutionFixture',
});
});
it('should return a 400 when passing the includeSubCaseComments', async () => {
const { body } = await supertest
.get(`${CASES_URL}/case-id/resolve?includeSubCaseComments=true`)
.set('kbn-xsrf', 'true')
.send()
.expect(400);
expect(body.message).to.contain('disabled');
});
it('unhappy path - 404s when case is not there', async () => {
await supertest
.get(`${CASES_URL}/fake-id/resolve`)
.set('kbn-xsrf', 'true')
.send()
.expect(404);
});
describe('rbac', () => {
it('should resolve a case', async () => {
const newCase = await createCase(
supertestWithoutAuth,
getPostCaseRequest({ owner: 'securitySolutionFixture' }),
200,
{
user: superUser,
space: 'space1',
}
);
for (const user of [globalRead, superUser, secOnly, secOnlyRead, obsSec, obsSecRead]) {
const resolvedCase = await resolveCase({
supertest: supertestWithoutAuth,
caseId: newCase.id,
auth: { user, space: 'space1' },
});
expect(resolvedCase.case.owner).to.eql('securitySolutionFixture');
expect(resolvedCase.outcome).to.eql('exactMatch');
expect(resolvedCase.alias_target_id).to.eql(undefined);
}
});
it('should resolve a case with comments', async () => {
const postedCase = await createCase(
supertestWithoutAuth,
getPostCaseRequest({ owner: 'securitySolutionFixture' }),
200,
{
user: secOnly,
space: 'space1',
}
);
await createComment({
supertest: supertestWithoutAuth,
caseId: postedCase.id,
params: postCommentUserReq,
expectedHttpCode: 200,
auth: {
user: secOnly,
space: 'space1',
},
});
const resolvedCase = await resolveCase({
supertest: supertestWithoutAuth,
caseId: postedCase.id,
includeComments: true,
auth: { user: secOnly, space: 'space1' },
});
const comment = removeServerGeneratedPropertiesFromSavedObject(
resolvedCase.case.comments![0] as AttributesTypeUser
);
expect(resolvedCase.case.comments?.length).to.eql(1);
expect(comment).to.eql({
type: postCommentUserReq.type,
comment: postCommentUserReq.comment,
associationType: 'case',
created_by: getUserInfo(secOnly),
pushed_at: null,
pushed_by: null,
updated_by: null,
owner: 'securitySolutionFixture',
});
});
it('should not resolve a case when the user does not have access to owner', async () => {
const newCase = await createCase(
supertestWithoutAuth,
getPostCaseRequest({ owner: 'securitySolutionFixture' }),
200,
{
user: superUser,
space: 'space1',
}
);
for (const user of [noKibanaPrivileges, obsOnly, obsOnlyRead]) {
await resolveCase({
supertest: supertestWithoutAuth,
caseId: newCase.id,
expectedHttpCode: 403,
auth: { user, space: 'space1' },
});
}
});
it('should NOT resolve a case in a space with no permissions', async () => {
const newCase = await createCase(
supertestWithoutAuth,
getPostCaseRequest({ owner: 'securitySolutionFixture' }),
200,
{
user: superUser,
space: 'space2',
}
);
await resolveCase({
supertest: supertestWithoutAuth,
caseId: newCase.id,
expectedHttpCode: 403,
auth: { user: secOnly, space: 'space2' },
});
});
});
});
};

View file

@ -25,6 +25,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => {
loadTestFile(require.resolve('./cases/get_case'));
loadTestFile(require.resolve('./cases/patch_cases'));
loadTestFile(require.resolve('./cases/post_case'));
loadTestFile(require.resolve('./cases/resolve_case'));
loadTestFile(require.resolve('./cases/reporters/get_reporters'));
loadTestFile(require.resolve('./cases/status/get_status'));
loadTestFile(require.resolve('./cases/tags/get_tags'));