[Security Solution][Case] Allow users with Gold license to use Jira (#89406)

This commit is contained in:
Christos Nasikas 2021-01-29 19:19:19 +02:00 committed by GitHub
parent d7b1cbbed5
commit 61d4d870e2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 539 additions and 27 deletions

View file

@ -6,11 +6,12 @@
import * as rt from 'io-ts';
import { ActionResult } from '../../../../actions/common';
import { ActionResult, ActionType } from '../../../../actions/common';
import { UserRT } from '../user';
import { CaseConnectorRt, ConnectorMappingsRt, ESCaseConnector } from '../connectors';
export type ActionConnector = ActionResult;
export type ActionTypeConnector = ActionType;
// TODO: we will need to add this type rt.literal('close-by-third-party')
const ClosureTypeRT = rt.union([rt.literal('close-by-user'), rt.literal('close-by-pushing')]);

View file

@ -14,13 +14,15 @@ import {
CaseConfigureService,
ConnectorMappingsService,
} from '../../../services';
import { getActions } from '../__mocks__/request_responses';
import { getActions, getActionTypes } from '../__mocks__/request_responses';
import { authenticationMock } from '../__fixtures__';
import type { CasesRequestHandlerContext } from '../../../types';
export const createRouteContext = async (client: any, badAuth = false) => {
const actionsMock = actionsClientMock.create();
actionsMock.getAll.mockImplementation(() => Promise.resolve(getActions()));
actionsMock.listTypes.mockImplementation(() => Promise.resolve(getActionTypes()));
const log = loggingSystemMock.create().get('case');
const esClientMock = elasticsearchServiceMock.createClusterClient();

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import {
ActionTypeConnector,
CasePostRequest,
CasesConfigureRequest,
ConnectorTypes,
@ -73,6 +74,49 @@ export const getActions = (): FindActionResult[] => [
},
];
export const getActionTypes = (): ActionTypeConnector[] => [
{
id: '.email',
name: 'Email',
minimumLicenseRequired: 'gold',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
},
{
id: '.index',
name: 'Index',
minimumLicenseRequired: 'basic',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
},
{
id: '.servicenow',
name: 'ServiceNow',
minimumLicenseRequired: 'platinum',
enabled: false,
enabledInConfig: true,
enabledInLicense: true,
},
{
id: '.jira',
name: 'Jira',
minimumLicenseRequired: 'gold',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
},
{
id: '.resilient',
name: 'IBM Resilient',
minimumLicenseRequired: 'platinum',
enabled: false,
enabledInConfig: true,
enabledInLicense: true,
},
];
export const newConfiguration: CasesConfigureRequest = {
connector: {
id: '456',

View file

@ -42,10 +42,72 @@ describe('GET connectors', () => {
expect(res.status).toEqual(200);
const expected = getActions();
// The first connector returned by getActions is of type .webhook and we expect to be filtered
expected.shift();
expect(res.payload).toEqual(expected);
});
it('filters out connectors that are not enabled in license', async () => {
const req = httpServerMock.createKibanaRequest({
path: `${CASE_CONFIGURE_CONNECTORS_URL}/_find`,
method: 'get',
});
const context = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
})
);
const actionsClient = context.actions.getActionsClient();
(actionsClient.listTypes as jest.Mock).mockImplementation(() =>
Promise.resolve([
{
id: '.servicenow',
name: 'ServiceNow',
minimumLicenseRequired: 'platinum',
enabled: false,
enabledInConfig: true,
// User does not have a platinum license
enabledInLicense: false,
},
{
id: '.jira',
name: 'Jira',
minimumLicenseRequired: 'gold',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
},
{
id: '.resilient',
name: 'IBM Resilient',
minimumLicenseRequired: 'platinum',
enabled: false,
enabledInConfig: true,
// User does not have a platinum license
enabledInLicense: false,
},
])
);
const res = await routeHandler(context, req, kibanaResponseFactory);
expect(res.status).toEqual(200);
expect(res.payload).toEqual([
{
id: '456',
actionTypeId: '.jira',
name: 'Connector without isCaseOwned',
config: {
apiUrl: 'https://elastic.jira.com',
},
isPreconfigured: false,
referencedByCount: 0,
},
]);
});
it('it throws an error when actions client is null', async () => {
const req = httpServerMock.createKibanaRequest({
path: `${CASE_CONFIGURE_CONNECTORS_URL}/_find`,

View file

@ -7,6 +7,7 @@
import Boom from '@hapi/boom';
import { RouteDeps } from '../../types';
import { wrapError } from '../../utils';
import { ActionType } from '../../../../../../actions/common';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { FindActionResult } from '../../../../../../actions/server/types';
@ -17,10 +18,13 @@ import {
RESILIENT_ACTION_TYPE_ID,
} from '../../../../../common/constants';
const isConnectorSupported = (action: FindActionResult): boolean =>
const isConnectorSupported = (
action: FindActionResult,
actionTypes: Record<string, ActionType>
): boolean =>
[SERVICENOW_ACTION_TYPE_ID, JIRA_ACTION_TYPE_ID, RESILIENT_ACTION_TYPE_ID].includes(
action.actionTypeId
);
) && actionTypes[action.actionTypeId]?.enabledInLicense;
/*
* Be aware that this api will only return 20 connectors
@ -40,7 +44,14 @@ export function initCaseConfigureGetActionConnector({ router }: RouteDeps) {
throw Boom.notFound('Action client have not been found');
}
const results = (await actionsClient.getAll()).filter(isConnectorSupported);
const actionTypes = (await actionsClient.listTypes()).reduce(
(types, type) => ({ ...types, [type.id]: type }),
{}
);
const results = (await actionsClient.getAll()).filter((action) =>
isConnectorSupported(action, actionTypes)
);
return response.ok({ body: results });
} catch (error) {
return response.customError(wrapError(error));

View file

@ -20,6 +20,7 @@ import { useDeleteCases } from '../../containers/use_delete_cases';
import { useGetCases } from '../../containers/use_get_cases';
import { useGetCasesStatus } from '../../containers/use_get_cases_status';
import { useUpdateCases } from '../../containers/use_bulk_update_case';
import { useGetActionLicense } from '../../containers/use_get_action_license';
import { getCasesColumns } from './columns';
import { AllCases } from '.';
@ -27,12 +28,14 @@ jest.mock('../../containers/use_bulk_update_case');
jest.mock('../../containers/use_delete_cases');
jest.mock('../../containers/use_get_cases');
jest.mock('../../containers/use_get_cases_status');
jest.mock('../../containers/use_get_action_license');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
const useDeleteCasesMock = useDeleteCases as jest.Mock;
const useGetCasesMock = useGetCases as jest.Mock;
const useGetCasesStatusMock = useGetCasesStatus as jest.Mock;
const useUpdateCasesMock = useUpdateCases as jest.Mock;
const useGetActionLicenseMock = useGetActionLicense as jest.Mock;
jest.mock('../../../common/components/link_to');
@ -86,6 +89,12 @@ describe('AllCases', () => {
updateBulkStatus,
};
const defaultActionLicense = {
actionLicense: null,
isLoading: false,
isError: false,
};
let navigateToApp: jest.Mock;
beforeEach(() => {
@ -96,6 +105,7 @@ describe('AllCases', () => {
useGetCasesMock.mockReturnValue(defaultGetCases);
useDeleteCasesMock.mockReturnValue(defaultDeleteCases);
useGetCasesStatusMock.mockReturnValue(defaultCasesStatus);
useGetActionLicenseMock.mockReturnValue(defaultActionLicense);
moment.tz.setDefault('UTC');
});
@ -398,6 +408,7 @@ describe('AllCases', () => {
expect(dispatchResetIsDeleted).toBeCalled();
});
});
it('isUpdated is true, refetch', async () => {
useUpdateCasesMock.mockReturnValue({
...defaultUpdateCases,
@ -627,4 +638,56 @@ describe('AllCases', () => {
);
});
});
it('should not allow the user to enter configuration page with basic license', async () => {
useGetActionLicenseMock.mockReturnValue({
...defaultActionLicense,
actionLicense: {
id: '.jira',
name: 'Jira',
minimumLicenseRequired: 'gold',
enabled: true,
enabledInConfig: true,
enabledInLicense: false,
},
});
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} />
</TestProviders>
);
await waitFor(() => {
expect(
wrapper.find('[data-test-subj="configure-case-button"]').first().prop('isDisabled')
).toBeTruthy();
});
});
it('should allow the user to enter configuration page with gold license and above', async () => {
useGetActionLicenseMock.mockReturnValue({
...defaultActionLicense,
actionLicense: {
id: '.jira',
name: 'Jira',
minimumLicenseRequired: 'gold',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
},
});
const wrapper = mount(
<TestProviders>
<AllCases userCanCrud={true} />
</TestProviders>
);
await waitFor(() => {
expect(
wrapper.find('[data-test-subj="configure-case-button"]').first().prop('isDisabled')
).toBeFalsy();
});
});
});

View file

@ -4,11 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { ConnectorTypes } from '../../../../../../case/common/api';
import { ActionConnector } from '../../../containers/configure/types';
import { UseConnectorsResponse } from '../../../containers/configure/use_connectors';
import { connectorsMock } from '../../../containers/configure/mock';
import { ReturnUseCaseConfigure } from '../../../containers/configure/use_configure';
import { ConnectorTypes } from '../../../../../../case/common/api';
import { UseActionTypesResponse } from '../../../containers/configure/use_action_types';
import { connectorsMock, actionTypesMock } from '../../../containers/configure/mock';
export { mappings } from '../../../containers/configure/mock';
export const connectors: ActionConnector[] = connectorsMock;
@ -51,3 +52,9 @@ export const useConnectorsResponse: UseConnectorsResponse = {
connectors,
refetchConnectors: jest.fn(),
};
export const useActionTypesResponse: UseActionTypesResponse = {
loading: false,
actionTypes: actionTypesMock,
refetchActionTypes: jest.fn(),
};

View file

@ -38,6 +38,7 @@ const ConfigureCaseButtonComponent: React.FC<ConfigureCaseButtonProps> = ({
},
[history, urlSearch]
);
const configureCaseButton = useMemo(
() => (
<LinkButton
@ -53,6 +54,7 @@ const ConfigureCaseButtonComponent: React.FC<ConfigureCaseButtonProps> = ({
),
[label, isDisabled, formatUrl, goToCaseConfigure]
);
return showToolTip ? (
<EuiToolTip
position="top"

View file

@ -22,20 +22,29 @@ import { actionTypeRegistryMock } from '../../../../../triggers_actions_ui/publi
import { useKibana } from '../../../common/lib/kibana';
import { useConnectors } from '../../containers/configure/use_connectors';
import { useCaseConfigure } from '../../containers/configure/use_configure';
import { useActionTypes } from '../../containers/configure/use_action_types';
import { useGetUrlSearch } from '../../../common/components/navigation/use_get_url_search';
import { connectors, searchURL, useCaseConfigureResponse, useConnectorsResponse } from './__mock__';
import {
connectors,
searchURL,
useCaseConfigureResponse,
useConnectorsResponse,
useActionTypesResponse,
} from './__mock__';
import { ConnectorTypes } from '../../../../../case/common/api/connectors';
jest.mock('../../../common/lib/kibana');
jest.mock('../../containers/configure/use_connectors');
jest.mock('../../containers/configure/use_configure');
jest.mock('../../containers/configure/use_action_types');
jest.mock('../../../common/components/navigation/use_get_url_search');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
const useConnectorsMock = useConnectors as jest.Mock;
const useCaseConfigureMock = useCaseConfigure as jest.Mock;
const useGetUrlSearchMock = useGetUrlSearch as jest.Mock;
const useActionTypesMock = useActionTypes as jest.Mock;
describe('ConfigureCases', () => {
beforeEach(() => {
@ -83,6 +92,8 @@ describe('ConfigureCases', () => {
/>
)),
} as unknown) as TriggersAndActionsUIPublicPluginStart;
useActionTypesMock.mockImplementation(() => useActionTypesResponse);
});
describe('rendering', () => {
@ -265,10 +276,12 @@ describe('ConfigureCases', () => {
closureType: 'close-by-user',
},
}));
useConnectorsMock.mockImplementation(() => ({
...useConnectorsResponse,
loading: true,
}));
useGetUrlSearchMock.mockImplementation(() => searchURL);
wrapper = mount(<ConfigureCases userCanCrud />, { wrappingComponent: TestProviders });
});
@ -294,6 +307,18 @@ describe('ConfigureCases', () => {
.prop('disabled')
).toBe(true);
});
test('it shows isLoading when loading action types', () => {
useConnectorsMock.mockImplementation(() => ({
...useConnectorsResponse,
loading: false,
}));
useActionTypesMock.mockImplementation(() => ({ ...useActionTypesResponse, loading: true }));
wrapper = mount(<ConfigureCases userCanCrud />, { wrappingComponent: TestProviders });
expect(wrapper.find(Connectors).prop('isLoading')).toBe(true);
});
});
describe('saving configuration', () => {

View file

@ -9,16 +9,16 @@ import styled, { css } from 'styled-components';
import { EuiCallOut } from '@elastic/eui';
import { SUPPORTED_CONNECTORS } from '../../../../../case/common/constants';
import { useKibana } from '../../../common/lib/kibana';
import { useConnectors } from '../../containers/configure/use_connectors';
import { useActionTypes } from '../../containers/configure/use_action_types';
import { useCaseConfigure } from '../../containers/configure/use_configure';
import { ActionType } from '../../../../../triggers_actions_ui/public';
import { ClosureType } from '../../containers/configure/types';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { ActionConnectorTableItem } from '../../../../../triggers_actions_ui/public/types';
import { connectorsConfiguration } from '../connectors';
import { SectionWrapper } from '../wrappers';
import { Connectors } from './connectors';
@ -49,8 +49,6 @@ const FormWrapper = styled.div`
`}
`;
const actionTypes: ActionType[] = Object.values(connectorsConfiguration);
interface ConfigureCasesComponentProps {
userCanCrud: boolean;
}
@ -78,12 +76,20 @@ const ConfigureCasesComponent: React.FC<ConfigureCasesComponentProps> = ({ userC
} = useCaseConfigure();
const { loading: isLoadingConnectors, connectors, refetchConnectors } = useConnectors();
const { loading: isLoadingActionTypes, actionTypes, refetchActionTypes } = useActionTypes();
const supportedActionTypes = useMemo(
() => actionTypes.filter((actionType) => SUPPORTED_CONNECTORS.includes(actionType.id)),
[actionTypes]
);
const onConnectorUpdate = useCallback(async () => {
refetchConnectors();
refetchActionTypes();
refetchCaseConfigure();
}, [refetchCaseConfigure, refetchConnectors]);
const isLoadingAny = isLoadingConnectors || persistLoading || loadingCaseConfigure;
}, [refetchActionTypes, refetchCaseConfigure, refetchConnectors]);
const isLoadingAny =
isLoadingConnectors || persistLoading || loadingCaseConfigure || isLoadingActionTypes;
const updateConnectorDisabled = isLoadingAny || !connectorIsValid || connector.id === 'none';
const onClickUpdateConnector = useCallback(() => {
setEditFlyoutVisibility(true);
@ -154,11 +160,11 @@ const ConfigureCasesComponent: React.FC<ConfigureCasesComponentProps> = ({ userC
triggersActionsUi.getAddConnectorFlyout({
consumer: 'case',
onClose: onCloseAddFlyout,
actionTypes,
actionTypes: supportedActionTypes,
reloadConnectors: onConnectorUpdate,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
[supportedActionTypes]
);
const ConnectorEditFlyout = useMemo(

View file

@ -17,11 +17,16 @@ export const getLicenseError = () => ({
title: i18n.PUSH_DISABLE_BY_LICENSE_TITLE,
description: (
<FormattedMessage
defaultMessage="To open cases in external systems, you must update your license to Platinum, start a free 30-day trial, or spin up a {link} on AWS, GCP, or Azure."
defaultMessage="Opening cases in external systems is available when you have the {appropriateLicense}, are using a {cloud}, or are testing out a Free Trial."
id="xpack.securitySolution.case.caseView.pushToServiceDisableByLicenseDescription"
values={{
link: (
<EuiLink href="https://www.elastic.co/cloud/" target="_blank">
appropriateLicense: (
<EuiLink href="https://www.elastic.co/subscriptions" target="_blank">
{i18n.LINK_APPROPRIATE_LICENSE}
</EuiLink>
),
cloud: (
<EuiLink href="https://www.elastic.co/cloud/elasticsearch-service/signup" target="_blank">
{i18n.LINK_CLOUD_DEPLOYMENT}
</EuiLink>
),

View file

@ -69,7 +69,7 @@ export const PUSH_DISABLE_BY_KIBANA_CONFIG_TITLE = i18n.translate(
export const PUSH_DISABLE_BY_LICENSE_TITLE = i18n.translate(
'xpack.securitySolution.case.caseView.pushToServiceDisableByLicenseTitle',
{
defaultMessage: 'Upgrade to Elastic Platinum',
defaultMessage: 'Upgrade to an appropriate license',
}
);
@ -80,6 +80,13 @@ export const LINK_CLOUD_DEPLOYMENT = i18n.translate(
}
);
export const LINK_APPROPRIATE_LICENSE = i18n.translate(
'xpack.securitySolution.case.caseView.appropiateLicense',
{
defaultMessage: 'appropriate license',
}
);
export const LINK_CONNECTOR_CONFIGURE = i18n.translate(
'xpack.securitySolution.case.caseView.connectorConfigureLink',
{

View file

@ -8,11 +8,12 @@ import {
CasesConfigurePatch,
CasesConfigureRequest,
ActionConnector,
ActionTypeConnector,
} from '../../../../../../case/common/api';
import { ApiProps } from '../../types';
import { CaseConfigure } from '../types';
import { connectorsMock, caseConfigurationCamelCaseResponseMock } from '../mock';
import { connectorsMock, caseConfigurationCamelCaseResponseMock, actionTypesMock } from '../mock';
export const fetchConnectors = async ({ signal }: ApiProps): Promise<ActionConnector[]> =>
Promise.resolve(connectorsMock);
@ -29,3 +30,6 @@ export const patchCaseConfigure = async (
caseConfiguration: CasesConfigurePatch,
signal: AbortSignal
): Promise<CaseConfigure> => Promise.resolve(caseConfigurationCamelCaseResponseMock);
export const fetchActionTypes = async ({ signal }: ApiProps): Promise<ActionTypeConnector[]> =>
Promise.resolve(actionTypesMock);

View file

@ -5,9 +5,16 @@
*/
import { KibanaServices } from '../../../common/lib/kibana';
import { fetchConnectors, getCaseConfigure, postCaseConfigure, patchCaseConfigure } from './api';
import {
fetchConnectors,
getCaseConfigure,
postCaseConfigure,
patchCaseConfigure,
fetchActionTypes,
} from './api';
import {
connectorsMock,
actionTypesMock,
caseConfigurationMock,
caseConfigurationResposeMock,
caseConfigurationCamelCaseResponseMock,
@ -123,4 +130,24 @@ describe('Case Configuration API', () => {
expect(resp).toEqual(caseConfigurationCamelCaseResponseMock);
});
});
describe('fetch actionTypes', () => {
beforeEach(() => {
fetchMock.mockClear();
fetchMock.mockResolvedValue(actionTypesMock);
});
test('check url, method, signal', async () => {
await fetchActionTypes({ signal: abortCtrl.signal });
expect(fetchMock).toHaveBeenCalledWith('/api/actions/list_action_types', {
method: 'GET',
signal: abortCtrl.signal,
});
});
test('happy path', async () => {
const resp = await fetchActionTypes({ signal: abortCtrl.signal });
expect(resp).toEqual(actionTypesMock);
});
});
});

View file

@ -7,6 +7,7 @@
import { isEmpty } from 'lodash/fp';
import {
ActionConnector,
ActionTypeConnector,
CasesConfigurePatch,
CasesConfigureResponse,
CasesConfigureRequest,
@ -16,6 +17,7 @@ import { KibanaServices } from '../../../common/lib/kibana';
import {
CASE_CONFIGURE_CONNECTORS_URL,
CASE_CONFIGURE_URL,
ACTION_TYPES_URL,
} from '../../../../../case/common/constants';
import { ApiProps } from '../types';
@ -89,3 +91,12 @@ export const patchCaseConfigure = async (
decodeCaseConfigureResponse(response)
);
};
export const fetchActionTypes = async ({ signal }: ApiProps): Promise<ActionTypeConnector[]> => {
const response = await KibanaServices.get().http.fetch(ACTION_TYPES_URL, {
method: 'GET',
signal,
});
return response;
};

View file

@ -6,6 +6,7 @@
import {
ActionConnector,
ActionTypeConnector,
CasesConfigureResponse,
CasesConfigureRequest,
ConnectorTypes,
@ -29,6 +30,7 @@ export const mappings: CaseConnectorMapping[] = [
actionType: 'append',
},
];
export const connectorsMock: ActionConnector[] = [
{
id: 'servicenow-1',
@ -60,6 +62,49 @@ export const connectorsMock: ActionConnector[] = [
},
];
export const actionTypesMock: ActionTypeConnector[] = [
{
id: '.email',
name: 'Email',
minimumLicenseRequired: 'gold',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
},
{
id: '.index',
name: 'Index',
minimumLicenseRequired: 'basic',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
},
{
id: '.servicenow',
name: 'ServiceNow',
minimumLicenseRequired: 'platinum',
enabled: false,
enabledInConfig: true,
enabledInLicense: true,
},
{
id: '.jira',
name: 'Jira',
minimumLicenseRequired: 'gold',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
},
{
id: '.resilient',
name: 'IBM Resilient',
minimumLicenseRequired: 'platinum',
enabled: false,
enabledInConfig: true,
enabledInLicense: true,
},
];
export const caseConfigurationResposeMock: CasesConfigureResponse = {
created_at: '2020-04-06T13:03:18.657Z',
created_by: { username: 'elastic', full_name: 'Elastic', email: 'elastic@elastic.co' },

View file

@ -7,6 +7,7 @@
import { ElasticUser } from '../types';
import {
ActionConnector,
ActionTypeConnector,
ActionType,
CaseConnector,
CaseField,
@ -15,7 +16,15 @@ import {
ThirdPartyField,
} from '../../../../../case/common/api';
export { ActionConnector, ActionType, CaseConnector, CaseField, ClosureType, ThirdPartyField };
export {
ActionConnector,
ActionTypeConnector,
ActionType,
CaseConnector,
CaseField,
ClosureType,
ThirdPartyField,
};
export interface CaseConnectorMapping {
actionType: ActionType;

View file

@ -0,0 +1,101 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { renderHook, act } from '@testing-library/react-hooks';
import { useActionTypes, UseActionTypesResponse } from './use_action_types';
import { actionTypesMock } from './mock';
import * as api from './api';
jest.mock('./api');
describe('useActionTypes', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});
test('init', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseActionTypesResponse>(() =>
useActionTypes()
);
await waitForNextUpdate();
expect(result.current).toEqual({
loading: true,
actionTypes: [],
refetchActionTypes: result.current.refetchActionTypes,
});
});
});
test('fetch action types', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseActionTypesResponse>(() =>
useActionTypes()
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({
loading: false,
actionTypes: actionTypesMock,
refetchActionTypes: result.current.refetchActionTypes,
});
});
});
test('refetch actionTypes', async () => {
const spyOnfetchActionTypes = jest.spyOn(api, 'fetchActionTypes');
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseActionTypesResponse>(() =>
useActionTypes()
);
await waitForNextUpdate();
await waitForNextUpdate();
result.current.refetchActionTypes();
expect(spyOnfetchActionTypes).toHaveBeenCalledTimes(2);
});
});
test('set isLoading to true when refetching actionTypes', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseActionTypesResponse>(() =>
useActionTypes()
);
await waitForNextUpdate();
await waitForNextUpdate();
result.current.refetchActionTypes();
expect(result.current.loading).toBe(true);
});
});
test('unhappy path', async () => {
const spyOnfetchActionTypes = jest.spyOn(api, 'fetchActionTypes');
spyOnfetchActionTypes.mockImplementation(() => {
throw new Error('Something went wrong');
});
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseActionTypesResponse>(() =>
useActionTypes()
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({
loading: false,
actionTypes: [],
refetchActionTypes: result.current.refetchActionTypes,
});
});
});
});

View file

@ -0,0 +1,72 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { useStateToaster, errorToToaster } from '../../../common/components/toasters';
import * as i18n from '../translations';
import { fetchActionTypes } from './api';
import { ActionTypeConnector } from './types';
export interface UseActionTypesResponse {
loading: boolean;
actionTypes: ActionTypeConnector[];
refetchActionTypes: () => void;
}
export const useActionTypes = (): UseActionTypesResponse => {
const [, dispatchToaster] = useStateToaster();
const [loading, setLoading] = useState(true);
const [actionTypes, setActionTypes] = useState<ActionTypeConnector[]>([]);
const didCancel = useRef(false);
const abortCtrl = useRef(new AbortController());
const queryFirstTime = useRef(true);
const refetchActionTypes = useCallback(async () => {
try {
setLoading(true);
didCancel.current = false;
abortCtrl.current.abort();
abortCtrl.current = new AbortController();
const res = await fetchActionTypes({ signal: abortCtrl.current.signal });
if (!didCancel.current) {
setLoading(false);
setActionTypes(res);
}
} catch (error) {
if (!didCancel.current) {
setLoading(false);
setActionTypes([]);
errorToToaster({
title: i18n.ERROR_TITLE,
error: error.body && error.body.message ? new Error(error.body.message) : error,
dispatchToaster,
});
}
}
}, [dispatchToaster]);
useEffect(() => {
if (queryFirstTime.current) {
refetchActionTypes();
queryFirstTime.current = false;
}
return () => {
didCancel.current = true;
abortCtrl.current.abort();
queryFirstTime.current = true;
};
}, [refetchActionTypes]);
return {
loading,
actionTypes,
refetchActionTypes,
};
};

View file

@ -199,6 +199,13 @@ export const actionLicenses: ActionLicense[] = [
enabledInConfig: true,
enabledInLicense: true,
},
{
id: '.jira',
name: 'Jira',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
},
];
// Snake case for mock api responses

View file

@ -51,7 +51,7 @@ describe('useGetActionLicense', () => {
expect(result.current).toEqual({
isLoading: false,
isError: false,
actionLicense: actionLicenses[0],
actionLicense: actionLicenses[1],
});
});
});

View file

@ -23,6 +23,8 @@ export const initialData: ActionLicenseState = {
isError: false,
};
const MINIMUM_LICENSE_REQUIRED_CONNECTOR = '.jira';
export const useGetActionLicense = (): ActionLicenseState => {
const [actionLicenseState, setActionLicensesState] = useState<ActionLicenseState>(initialData);
@ -40,7 +42,8 @@ export const useGetActionLicense = (): ActionLicenseState => {
const response = await getActionLicense(abortCtrl.signal);
if (!didCancel) {
setActionLicensesState({
actionLicense: response.find((l) => l.id === '.servicenow') ?? null,
actionLicense:
response.find((l) => l.id === MINIMUM_LICENSE_REQUIRED_CONNECTOR) ?? null,
isLoading: false,
isError: false,
});

View file

@ -17338,7 +17338,6 @@
"xpack.securitySolution.case.caseView.pushToServiceDisableByConfigDescription": "kibana.ymlファイルは、特定のコネクターのみを許可するように構成されています。外部システムでケースを開けるようにするには、xpack.actions.enabledActiontypes設定に.[actionTypeId](例:.servicenow | .jiraを追加します。詳細は{link}をご覧ください。",
"xpack.securitySolution.case.caseView.pushToServiceDisableByConfigTitle": "Kibanaの構成ファイルで外部サービスを有効にする",
"xpack.securitySolution.case.caseView.pushToServiceDisableByInvalidConnector": "外部サービスに更新を送信するために使用されるコネクターが削除されました。外部システムでケースを更新するには、別のコネクターを選択するか、新しいコネクターを作成してください。",
"xpack.securitySolution.case.caseView.pushToServiceDisableByLicenseDescription": "外部システムでケースを開くには、ライセンスをプラチナに更新するか、30日間の無料トライアルを開始するか、AWS、GCP、またはAzureで{link}にサインアップする必要があります。",
"xpack.securitySolution.case.caseView.pushToServiceDisableByLicenseTitle": "E lastic Platinumへのアップグレード",
"xpack.securitySolution.case.caseView.pushToServiceDisableByNoCaseConfigDescription": "外部システムでケースを開いて更新するには、このケースの外部インシデント管理システムを選択する必要があります。",
"xpack.securitySolution.case.caseView.pushToServiceDisableByNoCaseConfigTitle": "外部コネクターを選択",

View file

@ -17382,7 +17382,6 @@
"xpack.securitySolution.case.caseView.pushToServiceDisableByConfigDescription": "kibana.yml 文件已配置为仅允许特定连接器。要在外部系统中打开案例,请将 .[actionTypeId](例如:.servicenow | .jira添加到 xpack.actions.enabledActiontypes 设置。有关更多信息,请参阅{link}。",
"xpack.securitySolution.case.caseView.pushToServiceDisableByConfigTitle": "在 Kibana 配置文件中启用外部服务",
"xpack.securitySolution.case.caseView.pushToServiceDisableByInvalidConnector": "用于将更新发送到外部服务的连接器已删除。要在外部系统中更新案例,请选择不同的连接器或创建新的连接器。",
"xpack.securitySolution.case.caseView.pushToServiceDisableByLicenseDescription": "要在外部系统中打开案例,必须将许可证更新到白金级,开始为期 30 天的免费试用,或在 AWS、GCP 或 Azure 上快速部署 {link}。",
"xpack.securitySolution.case.caseView.pushToServiceDisableByLicenseTitle": "升级到 Elastic 白金级",
"xpack.securitySolution.case.caseView.pushToServiceDisableByNoCaseConfigDescription": "要在外部系统中打开和更新案例,必须为此案例选择外部事件管理系统。",
"xpack.securitySolution.case.caseView.pushToServiceDisableByNoCaseConfigTitle": "选择外部连接器",