[Security Solution][Case] Allow users with Gold license to use Jira (#89406)
This commit is contained in:
parent
d7b1cbbed5
commit
61d4d870e2
|
@ -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')]);
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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`,
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
),
|
||||
|
|
|
@ -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',
|
||||
{
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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' },
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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
|
||||
|
|
|
@ -51,7 +51,7 @@ describe('useGetActionLicense', () => {
|
|||
expect(result.current).toEqual({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
actionLicense: actionLicenses[0],
|
||||
actionLicense: actionLicenses[1],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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": "外部コネクターを選択",
|
||||
|
|
|
@ -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": "选择外部连接器",
|
||||
|
|
Loading…
Reference in a new issue