Skip to content

Commit

Permalink
ITSM: Add category & subcategory fields
Browse files Browse the repository at this point in the history
  • Loading branch information
cnasikas committed Feb 9, 2021
1 parent a0d4b04 commit ce779a1
Show file tree
Hide file tree
Showing 16 changed files with 357 additions and 107 deletions.
2 changes: 2 additions & 0 deletions x-pack/plugins/actions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,8 @@ The following table describes the properties of the `incident` object.
| severity | The name of the severity in ServiceNow. | string _(optional)_ |
| urgency | The name of the urgency in ServiceNow. | string _(optional)_ |
| impact | The name of the impact in ServiceNow. | string _(optional)_ |
| category | The name of the category in ServiceNow. | string _(optional)_ |
| subcategory | The name of the subcategory in ServiceNow. | string _(optional)_ |

#### `subActionParams (getFields)`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ const CommonAttributes = {
short_description: schema.string(),
description: schema.nullable(schema.string()),
externalId: schema.nullable(schema.string()),
category: schema.nullable(schema.string()),
subcategory: schema.nullable(schema.string()),
};

// Schema for ServiceNow Incident Management (ITSM)
Expand All @@ -62,13 +64,11 @@ export const ExecutorSubActionPushParamsSchemaITSM = schema.object({
export const ExecutorSubActionPushParamsSchemaSIR = schema.object({
incident: schema.object({
...CommonAttributes,
category: schema.nullable(schema.string()),
dest_ip: schema.nullable(schema.string()),
malware_hash: schema.nullable(schema.string()),
malware_url: schema.nullable(schema.string()),
priority: schema.nullable(schema.string()),
source_ip: schema.nullable(schema.string()),
subcategory: schema.nullable(schema.string()),
priority: schema.nullable(schema.string()),
}),
comments: CommentsSchema,
});
Expand Down
2 changes: 2 additions & 0 deletions x-pack/plugins/case/common/api/connectors/servicenow_itsm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const ServiceNowITSMFieldsRT = rt.type({
impact: rt.union([rt.string, rt.null]),
severity: rt.union([rt.string, rt.null]),
urgency: rt.union([rt.string, rt.null]),
category: rt.union([rt.string, rt.null]),
subcategory: rt.union([rt.string, rt.null]),
});

export type ServiceNowITSMFieldsType = rt.TypeOf<typeof ServiceNowITSMFieldsRT>;
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import { ServiceNowITSMFieldsType, ConnectorServiceNowITSMTypeFields } from '../
import { ExternalServiceFormatter } from '../types';

const format: ExternalServiceFormatter<ServiceNowITSMFieldsType>['format'] = (theCase) => {
const { severity = null, urgency = null, impact = null } =
const { severity = null, urgency = null, impact = null, category = null, subcategory = null } =
(theCase.connector.fields as ConnectorServiceNowITSMTypeFields['fields']) ?? {};
return { severity, urgency, impact };
return { severity, urgency, impact, category, subcategory };
};

export const serviceNowITSMExternalServiceFormatter: ExternalServiceFormatter<ServiceNowITSMFieldsType> = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import { serviceNowITSMExternalServiceFormatter } from './itsm_formatter';

describe('ITSM formatter', () => {
const theCase = {
connector: { fields: { severity: '2', urgency: '2', impact: '2' } },
connector: {
fields: { severity: '2', urgency: '2', impact: '2', category: 'software', subcategory: 'os' },
},
} as CaseResponse;

it('it formats correctly', async () => {
Expand All @@ -21,6 +23,12 @@ describe('ITSM formatter', () => {
it('it formats correctly when fields do not exist ', async () => {
const invalidFields = { connector: { fields: null } } as CaseResponse;
const res = await serviceNowITSMExternalServiceFormatter.format(invalidFields, []);
expect(res).toEqual({ severity: null, urgency: null, impact: null });
expect(res).toEqual({
severity: null,
urgency: null,
impact: null,
category: null,
subcategory: null,
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,18 @@ export const choices = [
value: 'inbound_ddos',
element: 'subcategory',
},
{
dependent_value: '',
label: 'Software',
value: 'software',
element: 'category',
},
{
dependent_value: 'software',
label: 'Operation System',
value: 'os',
element: 'subcategory',
},
...['severity', 'urgency', 'impact', 'priority']
.map((element) => [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*
* 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 { EuiSelectOption } from '@elastic/eui';
import { Choice } from './types';

export const choicesToEuiOptions = (choices: Choice[]): EuiSelectOption[] =>
choices.map((choice) => ({ value: choice.value, text: choice.label }));
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@ jest.mock('./use_get_choices', () => ({
}));

describe('ServiceNowITSM Fields', () => {
const fields = { severity: '1', urgency: '2', impact: '3' };
const fields = {
severity: '1',
urgency: '2',
impact: '3',
category: 'software',
subcategory: 'os',
};
const onChange = jest.fn();

beforeEach(() => {
Expand All @@ -37,6 +43,8 @@ describe('ServiceNowITSM Fields', () => {
expect(wrapper.find('[data-test-subj="severitySelect"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="urgencySelect"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="impactSelect"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="categorySelect"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="subcategorySelect"]').exists()).toBeTruthy();
});

it('all params fields are rendered - isEdit: false', () => {
Expand All @@ -58,6 +66,42 @@ describe('ServiceNowITSM Fields', () => {
);
});

test('it transforms the categories to options correctly', async () => {
const wrapper = mount(<Fields fields={fields} onChange={onChange} connector={connector} />);
act(() => {
onChoicesSuccess(mockChoices);
});

wrapper.update();
expect(wrapper.find('[data-test-subj="categorySelect"]').first().prop('options')).toEqual([
{ value: 'Priviledge Escalation', text: 'Priviledge Escalation' },
{
value: 'Criminal activity/investigation',
text: 'Criminal activity/investigation',
},
{ value: 'Denial of Service', text: 'Denial of Service' },
{
value: 'software',
text: 'Software',
},
]);
});

test('it transforms the subcategories to options correctly', async () => {
const wrapper = mount(<Fields fields={fields} onChange={onChange} connector={connector} />);
act(() => {
onChoicesSuccess(mockChoices);
});

wrapper.update();
expect(wrapper.find('[data-test-subj="subcategorySelect"]').first().prop('options')).toEqual([
{
text: 'Operation System',
value: 'os',
},
]);
});

it('it transforms the options correctly', async () => {
const wrapper = mount(<Fields fields={fields} onChange={onChange} connector={connector} />);
act(() => {
Expand All @@ -81,7 +125,7 @@ describe('ServiceNowITSM Fields', () => {

expect(onChange).toHaveBeenCalledWith(fields);

const testers = ['severity', 'urgency', 'impact'];
const testers = ['severity', 'urgency', 'impact', 'category', 'subcategory'];
testers.forEach((subj) =>
test(`${subj.toUpperCase()}`, async () => {
await waitFor(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,66 +17,109 @@ import {
import { useKibana } from '../../../../common/lib/kibana';
import { ConnectorCard } from '../card';
import { useGetChoices } from './use_get_choices';
import { Options, Choice } from './types';
import { Fields, Choice } from './types';
import { choicesToEuiOptions } from './helpers';

const useGetChoicesFields = ['urgency', 'severity', 'impact'];
const defaultOptions: Options = {
const useGetChoicesFields = ['urgency', 'severity', 'impact', 'category', 'subcategory'];
const defaultFields: Fields = {
urgency: [],
severity: [],
impact: [],
category: [],
subcategory: [],
};

const ServiceNowITSMFieldsComponent: React.FunctionComponent<
ConnectorFieldsProps<ServiceNowITSMFieldsType>
> = ({ isEdit = true, fields, connector, onChange }) => {
const init = useRef(true);
const { severity = null, urgency = null, impact = null } = fields ?? {};
const { severity = null, urgency = null, impact = null, category = null, subcategory = null } =
fields ?? {};
const { http, notifications } = useKibana().services;
const [options, setOptions] = useState<Options>(defaultOptions);
const [choices, setChoices] = useState<Fields>(defaultFields);

const categoryOptions = useMemo(() => choicesToEuiOptions(choices.category), [choices.category]);
const urgencyOptions = useMemo(() => choicesToEuiOptions(choices.urgency), [choices.urgency]);
const severityOptions = useMemo(() => choicesToEuiOptions(choices.severity), [choices.severity]);
const impactOptions = useMemo(() => choicesToEuiOptions(choices.impact), [choices.impact]);

const subcategoryOptions = useMemo(
() =>
choicesToEuiOptions(
choices.subcategory.filter((choice) => choice.dependent_value === category)
),
[choices.subcategory, category]
);

const listItems = useMemo(
() => [
...(urgency != null && urgency.length > 0
? [
{
title: i18n.URGENCY,
description: options.urgency.find((option) => `${option.value}` === urgency)?.text,
description: urgencyOptions.find((option) => `${option.value}` === urgency)?.text,
},
]
: []),
...(severity != null && severity.length > 0
? [
{
title: i18n.SEVERITY,
description: options.severity.find((option) => `${option.value}` === severity)?.text,
description: severityOptions.find((option) => `${option.value}` === severity)?.text,
},
]
: []),
...(impact != null && impact.length > 0
? [
{
title: i18n.IMPACT,
description: options.impact.find((option) => `${option.value}` === impact)?.text,
description: impactOptions.find((option) => `${option.value}` === impact)?.text,
},
]
: []),
...(category != null && category.length > 0
? [
{
title: i18n.CATEGORY,
description: categoryOptions.find((option) => `${option.value}` === category)?.text,
},
]
: []),
...(subcategory != null && subcategory.length > 0
? [
{
title: i18n.SUBCATEGORY,
description: subcategoryOptions.find((option) => `${option.value}` === subcategory)
?.text,
},
]
: []),
],
[urgency, options.urgency, options.severity, options.impact, severity, impact]
[
category,
categoryOptions,
impact,
impactOptions,
severity,
severityOptions,
subcategory,
subcategoryOptions,
urgency,
urgencyOptions,
]
);

const onChoicesSuccess = (choices: Choice[]) =>
setOptions(
choices.reduce(
(acc, choice) => ({
const onChoicesSuccess = (values: Choice[]) => {
setChoices(
values.reduce(
(acc, value) => ({
...acc,
[choice.element]: [
...(acc[choice.element] != null ? acc[choice.element] : []),
{ value: choice.value, text: choice.label },
],
[value.element]: [...(acc[value.element] != null ? acc[value.element] : []), value],
}),
defaultOptions
defaultFields
)
);
};

const { isLoading: isLoadingChoices } = useGetChoices({
http,
Expand All @@ -100,17 +143,17 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent<
useEffect(() => {
if (init.current) {
init.current = false;
onChange({ urgency, severity, impact });
onChange({ urgency, severity, impact, category, subcategory });
}
}, [impact, onChange, severity, urgency]);
}, [category, impact, onChange, severity, subcategory, urgency]);

return isEdit ? (
<div data-test-subj={'connector-fields-sn'}>
<EuiFormRow fullWidth label={i18n.URGENCY}>
<EuiSelect
fullWidth
data-test-subj="urgencySelect"
options={options.urgency}
options={urgencyOptions}
value={urgency ?? undefined}
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
Expand All @@ -125,7 +168,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent<
<EuiSelect
fullWidth
data-test-subj="severitySelect"
options={options.severity}
options={severityOptions}
value={severity ?? undefined}
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
Expand All @@ -139,7 +182,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent<
<EuiSelect
fullWidth
data-test-subj="impactSelect"
options={options.impact}
options={impactOptions}
value={impact ?? undefined}
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
Expand All @@ -149,6 +192,37 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent<
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.CATEGORY}>
<EuiSelect
fullWidth
data-test-subj="categorySelect"
options={categoryOptions}
value={category ?? undefined}
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
hasNoInitialSelection
onChange={(e) => onChangeCb('category', e.target.value)}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.SUBCATEGORY}>
<EuiSelect
fullWidth
data-test-subj="subcategorySelect"
options={subcategoryOptions}
// Needs an empty string instead of undefined to select the blank option when changing categories
value={subcategory ?? ''}
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
hasNoInitialSelection
onChange={(e) => onChangeCb('subcategory', e.target.value)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</div>
) : (
<ConnectorCard
Expand Down
Loading

0 comments on commit ce779a1

Please sign in to comment.