diff --git a/x-pack/plugins/cases/common/constants/index.ts b/x-pack/plugins/cases/common/constants/index.ts index a8868010d2312..83916f0cb679f 100644 --- a/x-pack/plugins/cases/common/constants/index.ts +++ b/x-pack/plugins/cases/common/constants/index.ts @@ -133,6 +133,10 @@ export const MAX_CUSTOM_FIELDS_PER_CASE = 10 as const; export const MAX_CUSTOM_FIELD_KEY_LENGTH = 36 as const; // uuidv4 length export const MAX_CUSTOM_FIELD_LABEL_LENGTH = 50 as const; export const MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH = 160 as const; +export const MAX_TEMPLATE_KEY_LENGTH = 36 as const; // uuidv4 length +export const MAX_TEMPLATE_NAME_LENGTH = 50 as const; +export const MAX_TEMPLATE_DESCRIPTION_LENGTH = 1000 as const; +export const MAX_TEMPLATES_LENGTH = 10 as const; /** * Cases features diff --git a/x-pack/plugins/cases/common/types/api/case/v1.ts b/x-pack/plugins/cases/common/types/api/case/v1.ts index 0dff1cac0d95d..7a45f92fa4668 100644 --- a/x-pack/plugins/cases/common/types/api/case/v1.ts +++ b/x-pack/plugins/cases/common/types/api/case/v1.ts @@ -58,6 +58,81 @@ export const CaseRequestCustomFieldsRt = limitedArraySchema({ max: MAX_CUSTOM_FIELDS_PER_CASE, }); +export const CaseBaseOptionalFieldsRequestRt = rt.exact( + rt.partial({ + /** + * The description of the case + */ + description: limitedStringSchema({ + fieldName: 'description', + min: 1, + max: MAX_DESCRIPTION_LENGTH, + }), + /** + * The identifying strings for filter a case + */ + tags: limitedArraySchema({ + codec: limitedStringSchema({ fieldName: 'tag', min: 1, max: MAX_LENGTH_PER_TAG }), + min: 0, + max: MAX_TAGS_PER_CASE, + fieldName: 'tags', + }), + /** + * The title of a case + */ + title: limitedStringSchema({ fieldName: 'title', min: 1, max: MAX_TITLE_LENGTH }), + /** + * The external system that the case can be synced with + */ + connector: CaseConnectorRt, + /** + * The severity of the case + */ + severity: CaseSeverityRt, + /** + * The users assigned to this case + */ + assignees: limitedArraySchema({ + codec: CaseUserProfileRt, + fieldName: 'assignees', + min: 0, + max: MAX_ASSIGNEES_PER_CASE, + }), + /** + * The category of the case. + */ + category: rt.union([ + limitedStringSchema({ fieldName: 'category', min: 1, max: MAX_CATEGORY_LENGTH }), + rt.null, + ]), + /** + * Custom fields of the case + */ + customFields: CaseRequestCustomFieldsRt, + /** + * The alert sync settings + */ + settings: CaseSettingsRt, + }) +); + +export const CaseRequestFieldsRt = rt.intersection([ + CaseBaseOptionalFieldsRequestRt, + rt.exact( + rt.partial({ + /** + * The current status of the case (open, closed, in-progress) + */ + status: CaseStatusRt, + + /** + * The plugin owner of the case + */ + owner: rt.string, + }) + ), +]); + /** * Create case */ @@ -356,71 +431,7 @@ export const CasesBulkGetResponseRt = rt.strict({ * Update cases */ export const CasePatchRequestRt = rt.intersection([ - rt.exact( - rt.partial({ - /** - * The description of the case - */ - description: limitedStringSchema({ - fieldName: 'description', - min: 1, - max: MAX_DESCRIPTION_LENGTH, - }), - /** - * The current status of the case (open, closed, in-progress) - */ - status: CaseStatusRt, - /** - * The identifying strings for filter a case - */ - tags: limitedArraySchema({ - codec: limitedStringSchema({ fieldName: 'tag', min: 1, max: MAX_LENGTH_PER_TAG }), - min: 0, - max: MAX_TAGS_PER_CASE, - fieldName: 'tags', - }), - /** - * The title of a case - */ - title: limitedStringSchema({ fieldName: 'title', min: 1, max: MAX_TITLE_LENGTH }), - /** - * The external system that the case can be synced with - */ - connector: CaseConnectorRt, - /** - * The alert sync settings - */ - settings: CaseSettingsRt, - /** - * The plugin owner of the case - */ - owner: rt.string, - /** - * The severity of the case - */ - severity: CaseSeverityRt, - /** - * The users assigned to this case - */ - assignees: limitedArraySchema({ - codec: CaseUserProfileRt, - fieldName: 'assignees', - min: 0, - max: MAX_ASSIGNEES_PER_CASE, - }), - /** - * The category of the case. - */ - category: rt.union([ - limitedStringSchema({ fieldName: 'category', min: 1, max: MAX_CATEGORY_LENGTH }), - rt.null, - ]), - /** - * Custom fields of the case - */ - customFields: CaseRequestCustomFieldsRt, - }) - ), + CaseRequestFieldsRt, /** * The saved object ID and version */ diff --git a/x-pack/plugins/cases/common/types/api/configure/v1.test.ts b/x-pack/plugins/cases/common/types/api/configure/v1.test.ts index 3369cb8473c0c..b8ecbe59224bf 100644 --- a/x-pack/plugins/cases/common/types/api/configure/v1.test.ts +++ b/x-pack/plugins/cases/common/types/api/configure/v1.test.ts @@ -8,11 +8,22 @@ import { PathReporter } from 'io-ts/lib/PathReporter'; import { v4 as uuidv4 } from 'uuid'; import { + MAX_ASSIGNEES_PER_CASE, + MAX_CATEGORY_LENGTH, MAX_CUSTOM_FIELDS_PER_CASE, MAX_CUSTOM_FIELD_KEY_LENGTH, MAX_CUSTOM_FIELD_LABEL_LENGTH, MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH, + MAX_DESCRIPTION_LENGTH, + MAX_LENGTH_PER_TAG, + MAX_TAGS_PER_CASE, + MAX_TEMPLATES_LENGTH, + MAX_TEMPLATE_DESCRIPTION_LENGTH, + MAX_TEMPLATE_KEY_LENGTH, + MAX_TEMPLATE_NAME_LENGTH, + MAX_TITLE_LENGTH, } from '../../../constants'; +import { CaseSeverity } from '../../domain'; import { ConnectorTypes } from '../../domain/connector/v1'; import { CustomFieldTypes } from '../../domain/custom_field/v1'; import { @@ -23,6 +34,7 @@ import { CustomFieldConfigurationWithoutTypeRt, TextCustomFieldConfigurationRt, ToggleCustomFieldConfigurationRt, + TemplateConfigurationRt, } from './v1'; describe('configure', () => { @@ -90,6 +102,49 @@ describe('configure', () => { ); }); + it('has expected attributes in request with templates', () => { + const request = { + ...defaultRequest, + templates: [ + { + key: 'template_key_1', + name: 'Template 1', + description: 'this is first template', + caseFields: { + title: 'case using sample template', + }, + }, + { + key: 'template_key_2', + name: 'Template 2', + description: 'this is second template', + caseFields: null, + }, + ], + }; + const query = ConfigurationRequestRt.decode(request); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: request, + }); + }); + + it(`limits templates to ${MAX_TEMPLATES_LENGTH}`, () => { + const templates = new Array(MAX_TEMPLATES_LENGTH + 1).fill({ + key: 'template_key_1', + name: 'Template 1', + description: 'this is first template', + caseFields: { + title: 'case using sample template', + }, + }); + + expect( + PathReporter.report(ConfigurationRequestRt.decode({ ...defaultRequest, templates }))[0] + ).toContain(`The length of the field templates is too long. Array must be of length <= 10.`); + }); + it('removes foo:bar attributes from request', () => { const query = ConfigurationRequestRt.decode({ ...defaultRequest, foo: 'bar' }); @@ -159,6 +214,49 @@ describe('configure', () => { ); }); + it('has expected attributes in request with templates', () => { + const request = { + ...defaultRequest, + templates: [ + { + key: 'template_key_1', + name: 'Template 1', + description: 'this is first template', + caseFields: { + title: 'case using sample template', + }, + }, + { + key: 'template_key_2', + name: 'Template 2', + description: 'this is second template', + caseFields: null, + }, + ], + }; + const query = ConfigurationPatchRequestRt.decode(request); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: request, + }); + }); + + it(`limits templates to ${MAX_TEMPLATES_LENGTH}`, () => { + const templates = new Array(MAX_TEMPLATES_LENGTH + 1).fill({ + key: 'template_key_1', + name: 'Template 1', + description: 'this is first template', + caseFields: { + title: 'case using sample template', + }, + }); + + expect( + PathReporter.report(ConfigurationPatchRequestRt.decode({ ...defaultRequest, templates }))[0] + ).toContain(`The length of the field templates is too long. Array must be of length <= 10.`); + }); + it('removes foo:bar attributes from request', () => { const query = ConfigurationPatchRequestRt.decode({ ...defaultRequest, foo: 'bar' }); @@ -407,4 +505,288 @@ describe('configure', () => { ).toContain('Invalid value "foobar" supplied'); }); }); + + describe('TemplateConfigurationRt', () => { + const defaultRequest = { + key: 'template_key_1', + name: 'Template 1', + description: 'this is first template', + caseFields: { + title: 'case using sample template', + }, + }; + + it('has expected attributes in request', () => { + const query = TemplateConfigurationRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = TemplateConfigurationRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('limits key to 36 characters', () => { + const longKey = 'x'.repeat(MAX_TEMPLATE_KEY_LENGTH + 1); + + expect( + PathReporter.report(TemplateConfigurationRt.decode({ ...defaultRequest, key: longKey })) + ).toContain('The length of the key is too long. The maximum length is 36.'); + }); + + it('return error if key is empty', () => { + expect( + PathReporter.report(TemplateConfigurationRt.decode({ ...defaultRequest, key: '' })) + ).toContain('The key field cannot be an empty string.'); + }); + + it('returns an error if they key is not in the expected format', () => { + const key = 'Not a proper key'; + + expect( + PathReporter.report(TemplateConfigurationRt.decode({ ...defaultRequest, key })) + ).toContain(`Key must be lower case, a-z, 0-9, '_', and '-' are allowed`); + }); + + it('accepts a uuid as an key', () => { + const key = uuidv4(); + + const query = TemplateConfigurationRt.decode({ ...defaultRequest, key }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, key }, + }); + }); + + it('accepts a slug as an key', () => { + const key = 'abc_key-1'; + + const query = TemplateConfigurationRt.decode({ ...defaultRequest, key }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, key }, + }); + }); + + it('limits name to 50 characters', () => { + const longName = 'x'.repeat(MAX_TEMPLATE_NAME_LENGTH + 1); + + expect( + PathReporter.report(TemplateConfigurationRt.decode({ ...defaultRequest, name: longName })) + ).toContain('The length of the name is too long. The maximum length is 50.'); + }); + + it('limits description to 1000 characters', () => { + const longDesc = 'x'.repeat(MAX_TEMPLATE_DESCRIPTION_LENGTH + 1); + + expect( + PathReporter.report( + TemplateConfigurationRt.decode({ ...defaultRequest, description: longDesc }) + ) + ).toContain('The length of the description is too long. The maximum length is 1000.'); + }); + + describe('caseFields', () => { + it('removes foo:bar attributes from caseFields', () => { + const query = TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { ...defaultRequest.caseFields, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('accepts caseFields as null', () => { + const query = TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: null, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, caseFields: null }, + }); + }); + + it('accepts caseFields as {}', () => { + const query = TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: {}, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, caseFields: {} }, + }); + }); + + it('accepts caseFields with all fields', () => { + const caseFieldsAll = { + title: 'Case with sample template 1', + description: 'case desc', + severity: CaseSeverity.LOW, + category: null, + tags: ['sample-1'], + assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], + customFields: [ + { + key: 'first_custom_field_key', + type: 'text', + value: 'this is a text field value', + }, + ], + connector: { + id: 'none', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, + }; + + const query = TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: caseFieldsAll, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, caseFields: caseFieldsAll }, + }); + }); + + it(`throws an error when the assignees are more than ${MAX_ASSIGNEES_PER_CASE}`, async () => { + const assignees = Array(MAX_ASSIGNEES_PER_CASE + 1).fill({ uid: 'foobar' }); + + expect( + PathReporter.report( + TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { ...defaultRequest.caseFields, assignees }, + }) + ) + ).toContain( + 'The length of the field assignees is too long. Array must be of length <= 10.' + ); + }); + + it(`throws an error when the description contains more than ${MAX_DESCRIPTION_LENGTH} characters`, async () => { + const description = 'a'.repeat(MAX_DESCRIPTION_LENGTH + 1); + + expect( + PathReporter.report( + TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { ...defaultRequest.caseFields, description }, + }) + ) + ).toContain('The length of the description is too long. The maximum length is 30000.'); + }); + + it(`throws an error when there are more than ${MAX_TAGS_PER_CASE} tags`, async () => { + const tags = Array(MAX_TAGS_PER_CASE + 1).fill('foobar'); + + expect( + PathReporter.report( + TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { ...defaultRequest.caseFields, tags }, + }) + ) + ).toContain('The length of the field tags is too long. Array must be of length <= 200.'); + }); + + it(`throws an error when the tag is more than ${MAX_LENGTH_PER_TAG} characters`, async () => { + const tag = 'a'.repeat(MAX_LENGTH_PER_TAG + 1); + + expect( + PathReporter.report( + TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { ...defaultRequest.caseFields, tags: [tag] }, + }) + ) + ).toContain('The length of the tag is too long. The maximum length is 256.'); + }); + + it(`throws an error when the title contains more than ${MAX_TITLE_LENGTH} characters`, async () => { + const title = 'a'.repeat(MAX_TITLE_LENGTH + 1); + + expect( + PathReporter.report( + TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { ...defaultRequest.caseFields, title }, + }) + ) + ).toContain('The length of the title is too long. The maximum length is 160.'); + }); + + it(`throws an error when the category contains more than ${MAX_CATEGORY_LENGTH} characters`, async () => { + const category = 'a'.repeat(MAX_CATEGORY_LENGTH + 1); + + expect( + PathReporter.report( + TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { ...defaultRequest.caseFields, category }, + }) + ) + ).toContain('The length of the category is too long. The maximum length is 50.'); + }); + + it(`limits customFields to ${MAX_CUSTOM_FIELDS_PER_CASE}`, () => { + const customFields = Array(MAX_CUSTOM_FIELDS_PER_CASE + 1).fill({ + key: 'first_custom_field_key', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }); + + expect( + PathReporter.report( + TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { ...defaultRequest.caseFields, customFields }, + }) + ) + ).toContain( + `The length of the field customFields is too long. Array must be of length <= ${MAX_CUSTOM_FIELDS_PER_CASE}.` + ); + }); + + it(`throws an error when a text customFields is longer than ${MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH}`, () => { + expect( + PathReporter.report( + TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { + ...defaultRequest.caseFields, + customFields: [ + { + key: 'first_custom_field_key', + type: CustomFieldTypes.TEXT, + value: '#'.repeat(MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH + 1), + }, + ], + }, + }) + ) + ).toContain( + `The length of the value is too long. The maximum length is ${MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH}.` + ); + }); + }); + }); }); diff --git a/x-pack/plugins/cases/common/types/api/configure/v1.ts b/x-pack/plugins/cases/common/types/api/configure/v1.ts index 8e986677ae8a9..db3ce3276c4c1 100644 --- a/x-pack/plugins/cases/common/types/api/configure/v1.ts +++ b/x-pack/plugins/cases/common/types/api/configure/v1.ts @@ -10,12 +10,17 @@ import { MAX_CUSTOM_FIELDS_PER_CASE, MAX_CUSTOM_FIELD_KEY_LENGTH, MAX_CUSTOM_FIELD_LABEL_LENGTH, + MAX_TEMPLATES_LENGTH, + MAX_TEMPLATE_DESCRIPTION_LENGTH, + MAX_TEMPLATE_KEY_LENGTH, + MAX_TEMPLATE_NAME_LENGTH, } from '../../../constants'; import { limitedArraySchema, limitedStringSchema, regexStringRt } from '../../../schema'; import { CustomFieldTextTypeRt, CustomFieldToggleTypeRt } from '../../domain'; import type { Configurations, Configuration } from '../../domain/configure/v1'; import { ConfigurationBasicWithoutOwnerRt, ClosureTypeRt } from '../../domain/configure/v1'; import { CaseConnectorRt } from '../../domain/connector/v1'; +import { CaseBaseOptionalFieldsRequestRt } from '../case/v1'; import { CaseCustomFieldTextWithValidationValueRt } from '../custom_field/v1'; export const CustomFieldConfigurationWithoutTypeRt = rt.strict({ @@ -64,6 +69,40 @@ export const CustomFieldsConfigurationRt = limitedArraySchema({ fieldName: 'customFields', }); +export const TemplateConfigurationRt = rt.strict({ + /** + * key of template + */ + key: regexStringRt({ + codec: limitedStringSchema({ fieldName: 'key', min: 1, max: MAX_TEMPLATE_KEY_LENGTH }), + pattern: '^[a-z0-9_-]+$', + message: `Key must be lower case, a-z, 0-9, '_', and '-' are allowed`, + }), + /** + * name of template + */ + name: limitedStringSchema({ fieldName: 'name', min: 1, max: MAX_TEMPLATE_NAME_LENGTH }), + /** + * description of templates + */ + description: limitedStringSchema({ + fieldName: 'description', + min: 1, + max: MAX_TEMPLATE_DESCRIPTION_LENGTH, + }), + /** + * case fields + */ + caseFields: rt.union([rt.null, CaseBaseOptionalFieldsRequestRt]), +}); + +export const TemplatesConfigurationRt = limitedArraySchema({ + codec: TemplateConfigurationRt, + min: 0, + max: MAX_TEMPLATES_LENGTH, + fieldName: 'templates', +}); + export const ConfigurationRequestRt = rt.intersection([ rt.strict({ /** @@ -82,6 +121,7 @@ export const ConfigurationRequestRt = rt.intersection([ rt.exact( rt.partial({ customFields: CustomFieldsConfigurationRt, + templates: TemplatesConfigurationRt, }) ), ]); @@ -106,6 +146,7 @@ export const ConfigurationPatchRequestRt = rt.intersection([ closure_type: ConfigurationBasicWithoutOwnerRt.type.props.closure_type, connector: ConfigurationBasicWithoutOwnerRt.type.props.connector, customFields: CustomFieldsConfigurationRt, + templates: TemplatesConfigurationRt, }) ), rt.strict({ version: rt.string }), diff --git a/x-pack/plugins/cases/common/types/domain/case/v1.ts b/x-pack/plugins/cases/common/types/domain/case/v1.ts index d8da843e46a0c..d5dfdcd5ee174 100644 --- a/x-pack/plugins/cases/common/types/domain/case/v1.ts +++ b/x-pack/plugins/cases/common/types/domain/case/v1.ts @@ -52,15 +52,11 @@ export const CaseSettingsRt = rt.strict({ syncAlerts: rt.boolean, }); -const CaseBasicRt = rt.strict({ +const CaseBaseFields = { /** * The description of the case */ description: rt.string, - /** - * The current status of the case (open, closed, in-progress) - */ - status: CaseStatusRt, /** * The identifying strings for filter a case */ @@ -73,14 +69,6 @@ const CaseBasicRt = rt.strict({ * The external system that the case can be synced with */ connector: CaseConnectorRt, - /** - * The alert sync settings - */ - settings: CaseSettingsRt, - /** - * The plugin owner of the case - */ - owner: rt.string, /** * The severity of the case */ @@ -98,6 +86,28 @@ const CaseBasicRt = rt.strict({ * user-configured custom fields. */ customFields: CaseCustomFieldsRt, + /** + * The alert sync settings + */ + settings: CaseSettingsRt, +}; + +export const CaseBaseOptionalFieldsRt = rt.exact( + rt.partial({ + ...CaseBaseFields, + }) +); + +const CaseBasicRt = rt.strict({ + /** + * The current status of the case (open, closed, in-progress) + */ + status: CaseStatusRt, + /** + * The plugin owner of the case + */ + owner: rt.string, + ...CaseBaseFields, }); export const CaseAttributesRt = rt.intersection([ diff --git a/x-pack/plugins/cases/common/types/domain/configure/v1.test.ts b/x-pack/plugins/cases/common/types/domain/configure/v1.test.ts index 400d69700fe12..aa6b8a88d43ee 100644 --- a/x-pack/plugins/cases/common/types/domain/configure/v1.test.ts +++ b/x-pack/plugins/cases/common/types/domain/configure/v1.test.ts @@ -6,12 +6,14 @@ */ import { PathReporter } from 'io-ts/lib/PathReporter'; +import { CaseSeverity } from '../case/v1'; import { ConnectorTypes } from '../connector/v1'; import { CustomFieldTypes } from '../custom_field/v1'; import { ConfigurationAttributesRt, ConfigurationRt, CustomFieldConfigurationWithoutTypeRt, + TemplateConfigurationRt, TextCustomFieldConfigurationRt, ToggleCustomFieldConfigurationRt, } from './v1'; @@ -45,11 +47,59 @@ describe('configure', () => { required: false, }; + const templateWithAllCaseFields = { + key: 'template_sample_1', + name: 'Sample template 1', + description: 'this is first sample template', + caseFields: { + title: 'Case with sample template 1', + description: 'case desc', + severity: CaseSeverity.LOW, + category: null, + tags: ['sample-1'], + assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], + customFields: [ + { + key: 'first_custom_field_key', + type: 'text', + value: 'this is a text field value', + }, + ], + connector: { + id: 'none', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, + settings: { + syncAlerts: true, + }, + }, + }; + + const templateWithFewCaseFields = { + key: 'template_sample_2', + name: 'Sample template 2', + description: 'this is second sample template', + caseFields: { + title: 'Case with sample template 2', + tags: ['sample-2'], + }, + }; + + const templateWithNoCaseFields = { + key: 'template_sample_3', + name: 'Sample template 3', + description: 'this is third sample template', + caseFields: null, + }; + describe('ConfigurationAttributesRt', () => { const defaultRequest = { connector: resilient, closure_type: 'close-by-user', customFields: [textCustomField, toggleCustomField], + templates: [], owner: 'cases', created_at: '2020-02-19T23:06:33.798Z', created_by: { @@ -110,6 +160,7 @@ describe('configure', () => { connector: serviceNow, closure_type: 'close-by-user', customFields: [], + templates: [templateWithAllCaseFields, templateWithFewCaseFields, templateWithNoCaseFields], created_at: '2020-02-19T23:06:33.798Z', created_by: { full_name: 'Leslie Knope', @@ -299,4 +350,71 @@ describe('configure', () => { }); }); }); + + describe('TemplateConfigurationRt', () => { + const defaultRequest = templateWithAllCaseFields; + + it('has expected attributes in request ', () => { + const query = TemplateConfigurationRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = TemplateConfigurationRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('removes foo:bar attributes from caseFields', () => { + const query = TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: { ...templateWithAllCaseFields.caseFields, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('accepts few caseFields', () => { + const query = TemplateConfigurationRt.decode(templateWithFewCaseFields); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...templateWithFewCaseFields }, + }); + }); + + it('accepts null for caseFields', () => { + const query = TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: null, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, caseFields: null }, + }); + }); + + it('accepts {} for caseFields', () => { + const query = TemplateConfigurationRt.decode({ + ...defaultRequest, + caseFields: {}, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, caseFields: {} }, + }); + }); + }); }); diff --git a/x-pack/plugins/cases/common/types/domain/configure/v1.ts b/x-pack/plugins/cases/common/types/domain/configure/v1.ts index 65882ad40753e..86c966b03ca9b 100644 --- a/x-pack/plugins/cases/common/types/domain/configure/v1.ts +++ b/x-pack/plugins/cases/common/types/domain/configure/v1.ts @@ -9,6 +9,7 @@ import * as rt from 'io-ts'; import { CaseConnectorRt, ConnectorMappingsRt } from '../connector/v1'; import { UserRt } from '../user/v1'; import { CustomFieldTextTypeRt, CustomFieldToggleTypeRt } from '../custom_field/v1'; +import { CaseBaseOptionalFieldsRt } from '../case/v1'; export const ClosureTypeRt = rt.union([ rt.literal('close-by-user'), @@ -57,6 +58,27 @@ export const CustomFieldConfigurationRt = rt.union([ export const CustomFieldsConfigurationRt = rt.array(CustomFieldConfigurationRt); +export const TemplateConfigurationRt = rt.strict({ + /** + * key of template + */ + key: rt.string, + /** + * name of template + */ + name: rt.string, + /** + * description of template + */ + description: rt.string, + /** + * case fields of template + */ + caseFields: rt.union([rt.null, CaseBaseOptionalFieldsRt]), +}); + +export const TemplatesConfigurationRt = rt.array(TemplateConfigurationRt); + export const ConfigurationBasicWithoutOwnerRt = rt.strict({ /** * The external connector @@ -70,6 +92,10 @@ export const ConfigurationBasicWithoutOwnerRt = rt.strict({ * The custom fields configured for the case */ customFields: CustomFieldsConfigurationRt, + /** + * Templates configured for the case + */ + templates: TemplatesConfigurationRt, }); export const CasesConfigureBasicRt = rt.intersection([ @@ -109,6 +135,8 @@ export const ConfigurationsRt = rt.array(ConfigurationRt); export type CustomFieldsConfiguration = rt.TypeOf; export type CustomFieldConfiguration = rt.TypeOf; +export type TemplatesConfiguration = rt.TypeOf; +export type TemplateConfiguration = rt.TypeOf; export type ClosureType = rt.TypeOf; export type ConfigurationAttributes = rt.TypeOf; export type Configuration = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index 3854c14c79de8..8676fa2ddc347 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -119,11 +119,20 @@ export interface ResolvedCase { export type CasesConfigurationUI = Pick< SnakeToCamelCase, - 'closureType' | 'connector' | 'mappings' | 'customFields' | 'id' | 'version' | 'owner' + | 'closureType' + | 'connector' + | 'mappings' + | 'customFields' + | 'templates' + | 'id' + | 'version' + | 'owner' >; export type CasesConfigurationUICustomField = CasesConfigurationUI['customFields'][number]; +export type CasesConfigurationUITemplate = CasesConfigurationUI['templates'][number]; + export type SortOrder = 'asc' | 'desc'; export const SORT_ORDER_VALUES: SortOrder[] = ['asc', 'desc']; diff --git a/x-pack/plugins/cases/public/containers/configure/api.ts b/x-pack/plugins/cases/public/containers/configure/api.ts index ae72d839d3ac5..b67e8f53f2268 100644 --- a/x-pack/plugins/cases/public/containers/configure/api.ts +++ b/x-pack/plugins/cases/public/containers/configure/api.ts @@ -115,7 +115,8 @@ export const fetchActionTypes = async ({ signal }: ApiProps): Promise ): CasesConfigurationUI => { - const { id, version, mappings, customFields, closureType, connector, owner } = configuration; + const { id, version, mappings, customFields, templates, closureType, connector, owner } = + configuration; - return { id, version, mappings, customFields, closureType, connector, owner }; + return { id, version, mappings, customFields, templates, closureType, connector, owner }; }; diff --git a/x-pack/plugins/cases/public/containers/configure/mock.ts b/x-pack/plugins/cases/public/containers/configure/mock.ts index a5946ca319641..1124283e5aa94 100644 --- a/x-pack/plugins/cases/public/containers/configure/mock.ts +++ b/x-pack/plugins/cases/public/containers/configure/mock.ts @@ -11,7 +11,7 @@ import { ConnectorTypes } from '../../../common/types/domain'; import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import type { CaseConnectorMapping } from './types'; import type { CasesConfigurationUI } from '../types'; -import { customFieldsConfigurationMock } from '../mock'; +import { customFieldsConfigurationMock, templatesConfigurationMock } from '../mock'; export const mappings: CaseConnectorMapping[] = [ { @@ -49,6 +49,7 @@ export const caseConfigurationResponseMock: Configuration = { owner: SECURITY_SOLUTION_OWNER, version: 'WzHJ12', customFields: customFieldsConfigurationMock, + templates: templatesConfigurationMock, }; export const caseConfigurationRequest: ConfigurationRequest = { @@ -74,5 +75,6 @@ export const casesConfigurationsMock: CasesConfigurationUI = { mappings: [], version: 'WzHJ12', customFields: customFieldsConfigurationMock, + templates: templatesConfigurationMock, owner: 'securitySolution', }; diff --git a/x-pack/plugins/cases/public/containers/configure/use_get_all_case_configurations.test.ts b/x-pack/plugins/cases/public/containers/configure/use_get_all_case_configurations.test.ts index fdd46d640e5fc..cd9e44d1bdaae 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_get_all_case_configurations.test.ts +++ b/x-pack/plugins/cases/public/containers/configure/use_get_all_case_configurations.test.ts @@ -48,6 +48,7 @@ describe('Use get all case configurations hook', () => { closureType: 'close-by-user', connector: { fields: null, id: 'none', name: 'none', type: '.none' }, customFields: [], + templates: [], id: '', mappings: [], version: '', @@ -86,6 +87,7 @@ describe('Use get all case configurations hook', () => { closureType: 'close-by-user', connector: { fields: null, id: 'none', name: 'none', type: '.none' }, customFields: [], + templates: [], id: '', mappings: [], version: '', diff --git a/x-pack/plugins/cases/public/containers/configure/utils.ts b/x-pack/plugins/cases/public/containers/configure/utils.ts index 164b9c0f94945..e4416beb5ce57 100644 --- a/x-pack/plugins/cases/public/containers/configure/utils.ts +++ b/x-pack/plugins/cases/public/containers/configure/utils.ts @@ -16,6 +16,7 @@ export const initialConfiguration: CasesConfigurationUI = { type: ConnectorTypes.none, }, customFields: [], + templates: [], mappings: [], version: '', id: '', diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts index 63fac9c816955..7d5096842173b 100644 --- a/x-pack/plugins/cases/public/containers/mock.ts +++ b/x-pack/plugins/cases/public/containers/mock.ts @@ -45,6 +45,7 @@ import type { AttachmentUI, CaseUICustomField, CasesConfigurationUICustomField, + CasesConfigurationUITemplate, } from '../../common/ui/types'; import { CaseMetricsFeature } from '../../common/types/api'; import { SECURITY_SOLUTION_OWNER } from '../../common/constants'; @@ -1177,3 +1178,54 @@ export const customFieldsConfigurationMock: CasesConfigurationUICustomField[] = { type: CustomFieldTypes.TEXT, key: 'test_key_3', label: 'My test label 3', required: false }, { type: CustomFieldTypes.TOGGLE, key: 'test_key_4', label: 'My test label 4', required: false }, ]; + +export const templatesConfigurationMock: CasesConfigurationUITemplate[] = [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + caseFields: null, + }, + { + key: 'test_template_2', + name: 'Second test template', + description: 'This is a second test template', + caseFields: {}, + }, + { + key: 'test_template_3', + name: 'Third test template', + description: 'This is a third test template with few case fields', + caseFields: { + title: 'This is case title using a test template', + severity: CaseSeverity.MEDIUM, + tags: ['third-template', 'medium'], + }, + }, + { + key: 'test_template_4', + name: 'Fourth test template', + description: 'This is a fourth test template', + caseFields: { + title: 'Case with sample template 4', + description: 'case desc', + severity: CaseSeverity.LOW, + category: null, + tags: ['sample-4'], + assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], + customFields: [ + { + key: 'first_custom_field_key', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + ], + connector: { + id: 'none', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, + }, + }, +]; diff --git a/x-pack/plugins/cases/server/client/cases/bulk_create.test.ts b/x-pack/plugins/cases/server/client/cases/bulk_create.test.ts index 1f6c9b307fe6e..c7f047aa6b385 100644 --- a/x-pack/plugins/cases/server/client/cases/bulk_create.test.ts +++ b/x-pack/plugins/cases/server/client/cases/bulk_create.test.ts @@ -977,7 +977,7 @@ describe('bulkCreate', () => { casesClient ) ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Failed to bulk create cases: Error: Invalid duplicated custom field keys in request: duplicated_key"` + `"Failed to bulk create cases: Error: Invalid duplicated customFields keys in request: duplicated_key"` ); }); diff --git a/x-pack/plugins/cases/server/client/cases/bulk_update.test.ts b/x-pack/plugins/cases/server/client/cases/bulk_update.test.ts index a3fc842dfe3e1..0109e6eda8808 100644 --- a/x-pack/plugins/cases/server/client/cases/bulk_update.test.ts +++ b/x-pack/plugins/cases/server/client/cases/bulk_update.test.ts @@ -1309,7 +1309,7 @@ describe('update', () => { casesClient ) ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Failed to update case, ids: [{\\"id\\":\\"mock-id-1\\",\\"version\\":\\"WzAsMV0=\\"}]: Error: Invalid duplicated custom field keys in request: duplicated_key"` + `"Failed to update case, ids: [{\\"id\\":\\"mock-id-1\\",\\"version\\":\\"WzAsMV0=\\"}]: Error: Invalid duplicated customFields keys in request: duplicated_key"` ); }); diff --git a/x-pack/plugins/cases/server/client/cases/create.test.ts b/x-pack/plugins/cases/server/client/cases/create.test.ts index 315ee14834574..8b24c79c530b0 100644 --- a/x-pack/plugins/cases/server/client/cases/create.test.ts +++ b/x-pack/plugins/cases/server/client/cases/create.test.ts @@ -632,7 +632,7 @@ describe('create', () => { casesClient ) ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Failed to create case: Error: Invalid duplicated custom field keys in request: duplicated_key"` + `"Failed to create case: Error: Invalid duplicated customFields keys in request: duplicated_key"` ); }); diff --git a/x-pack/plugins/cases/server/client/cases/validators.ts b/x-pack/plugins/cases/server/client/cases/validators.ts index eeebbc8c13ca0..b2e286d48c4b1 100644 --- a/x-pack/plugins/cases/server/client/cases/validators.ts +++ b/x-pack/plugins/cases/server/client/cases/validators.ts @@ -9,7 +9,7 @@ import { differenceWith, intersectionWith, isEmpty } from 'lodash'; import Boom from '@hapi/boom'; import type { CustomFieldsConfiguration } from '../../../common/types/domain'; import type { CaseRequestCustomFields, CasesSearchRequest } from '../../../common/types/api'; -import { validateDuplicatedCustomFieldKeysInRequest } from '../validators'; +import { validateDuplicatedKeysInRequest } from '../validators'; import type { ICasesCustomField } from '../../custom_fields'; import { casesCustomFields } from '../../custom_fields'; import { MAX_CUSTOM_FIELDS_PER_CASE } from '../../../common/constants'; @@ -20,7 +20,10 @@ interface CustomFieldValidationParams { } export const validateCustomFields = (params: CustomFieldValidationParams) => { - validateDuplicatedCustomFieldKeysInRequest(params); + validateDuplicatedKeysInRequest({ + requestFields: params.requestCustomFields, + fieldName: 'customFields', + }); validateCustomFieldKeysAgainstConfiguration(params); validateRequiredCustomFields(params); validateCustomFieldTypesInRequest(params); diff --git a/x-pack/plugins/cases/server/client/configure/client.test.ts b/x-pack/plugins/cases/server/client/configure/client.test.ts index b5958c44de080..5bc8c4a19679a 100644 --- a/x-pack/plugins/cases/server/client/configure/client.test.ts +++ b/x-pack/plugins/cases/server/client/configure/client.test.ts @@ -15,8 +15,10 @@ import { createCasesClientInternalMock, createCasesClientMockArgs } from '../moc import { MAX_CUSTOM_FIELDS_PER_CASE, MAX_SUPPORTED_CONNECTORS_RETURNED, + MAX_TEMPLATES_LENGTH, } from '../../../common/constants'; import { ConnectorTypes } from '../../../common'; +import type { TemplatesConfiguration } from '../../../common/types/domain'; import { CustomFieldTypes } from '../../../common/types/domain'; import type { ConfigurationRequest } from '../../../common/types/api'; @@ -306,7 +308,7 @@ describe('client', () => { casesClientInternal ) ).rejects.toThrow( - 'Failed to get patch configure in route: Error: Invalid duplicated custom field keys in request: duplicated_key' + 'Failed to get patch configure in route: Error: Invalid duplicated customFields keys in request: duplicated_key' ); }); @@ -346,6 +348,487 @@ describe('client', () => { 'Failed to get patch configure in route: Error: Invalid custom field types in request for the following labels: "text label"' ); }); + + describe('templates', () => { + it(`does not throw error when trying to update templates`, async () => { + clientArgs.services.caseConfigureService.get.mockResolvedValue({ + // @ts-ignore: these are all the attributes needed for the test + attributes: { + customFields: [], + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + closure_type: 'close-by-user', + owner: 'cases', + templates: [], + }, + version: 'test-version', + }); + + clientArgs.services.caseConfigureService.patch.mockResolvedValue({ + id: 'test-id', + type: 'cases-configure', + version: 'test-version', + namespaces: ['default'], + references: [], + attributes: { + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'test', + caseFields: { + title: 'Case title', + description: 'This is test desc', + tags: ['sample-1'], + assignees: [], + customFields: [], + category: null, + }, + }, + ], + created_at: '2019-11-25T21:54:48.952Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + }, + }); + + await expect( + update( + 'test-id', + { + version: 'test-version', + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'test', + caseFields: { + title: 'Case title', + description: 'This is test desc', + tags: ['sample-1'], + assignees: [], + customFields: [], + category: null, + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).resolves.not.toThrow(); + }); + + it(`does not throw error when trying to update to empty templates`, async () => { + clientArgs.services.caseConfigureService.get.mockResolvedValue({ + // @ts-ignore: these are all the attributes needed for the test + attributes: { + customFields: [], + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + closure_type: 'close-by-user', + owner: 'cases', + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'test', + caseFields: { + title: 'Case title', + description: 'This is test desc', + tags: ['sample-1'], + assignees: [], + customFields: [], + category: null, + }, + }, + ], + }, + version: 'test-version', + }); + + clientArgs.services.caseConfigureService.patch.mockResolvedValue({ + id: 'test-id', + type: 'cases-configure', + version: 'test-version', + namespaces: ['default'], + references: [], + attributes: { + templates: [], + created_at: '2019-11-25T21:54:48.952Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + }, + }); + + await expect( + update( + 'test-id', + { + version: 'test-version', + templates: [], + }, + clientArgs, + casesClientInternal + ) + ).resolves.not.toThrow(); + }); + + it(`throws when trying to update more than ${MAX_TEMPLATES_LENGTH} templates`, async () => { + await expect( + update( + 'test-id', + { + version: 'test-version', + templates: new Array(MAX_TEMPLATES_LENGTH + 1).fill({ + key: 'template_1', + name: 'template 1', + description: 'test', + caseFields: null, + }), + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + `Failed to get patch configure in route: Error: The length of the field templates is too long. Array must be of length <= ${MAX_TEMPLATES_LENGTH}.` + ); + }); + + it('throws when there are duplicated template keys in the request', async () => { + await expect( + update( + 'test-id', + { + version: 'test-version', + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'test', + caseFields: null, + }, + { + key: 'template_1', + name: 'template 2', + description: 'test', + caseFields: { + title: 'Case title', + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to get patch configure in route: Error: Invalid duplicated templates keys in request: template_1' + ); + }); + + describe('customFields', () => { + it('throws when there are no customFields in configure and template has customField in the request', async () => { + clientArgs.services.caseConfigureService.get.mockResolvedValue({ + // @ts-ignore: these are all the attributes needed for the test + attributes: { + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: null, + }, + ], + }, + }); + + await expect( + update( + 'test-id', + { + version: 'test-version', + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + ], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to get patch configure in route: Error: No custom fields configured.' + ); + }); + + it('throws when template has duplicated custom field keys in the request', async () => { + clientArgs.services.caseConfigureService.get.mockResolvedValue({ + // @ts-ignore: these are all the attributes needed for the test + attributes: { + customFields: [ + { + key: 'custom_field_key_1', + label: 'text label', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + ], + }, + }, + ], + }, + }); + + await expect( + update( + 'test-id', + { + version: 'test-version', + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'test', + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 2', + }, + ], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + `Failed to get patch configure in route: Error: Invalid duplicated templates[0]'s customFields keys in request: custom_field_key_1` + ); + }); + + it('throws when there are invalid customField keys in the request', async () => { + clientArgs.services.caseConfigureService.get.mockResolvedValue({ + // @ts-ignore: these are all the attributes needed for the test + attributes: { + customFields: [ + { + key: 'custom_field_key_1', + label: 'text label', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + ], + }, + }, + ], + }, + }); + + await expect( + update( + 'test-id', + { + version: 'test-version', + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_2', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + ], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to get patch configure in route: Error: Invalid custom field keys: custom_field_key_2' + ); + }); + + it('throws when template has customField with invalid type in the request', async () => { + clientArgs.services.caseConfigureService.get.mockResolvedValue({ + // @ts-ignore: these are all the attributes needed for the test + attributes: { + customFields: [ + { + key: 'custom_field_key_1', + label: 'text label', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + ], + }, + }, + ], + }, + }); + + await expect( + update( + 'test-id', + { + version: 'test-version', + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to get patch configure in route: Error: The following custom fields have the wrong type in the request: "text label"' + ); + }); + }); + + describe('assignees', () => { + it('throws if the user does not have the correct license while adding assignees in template ', async () => { + clientArgs.services.licensingService.isAtLeastPlatinum.mockResolvedValue(false); + clientArgs.services.caseConfigureService.get.mockResolvedValue({ + // @ts-ignore: these are all the attributes needed for the test + attributes: { + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: null, + }, + ], + }, + }); + + await expect( + update( + 'test-id', + { + version: 'test-version', + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + assignees: [{ uid: '1' }], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to get patch configure in route: Error: In order to assign users to cases, you must be subscribed to an Elastic Platinum license' + ); + }); + }); + }); }); describe('create', () => { @@ -404,8 +887,329 @@ describe('client', () => { casesClientInternal ) ).rejects.toThrow( - 'Failed to create case configuration: Error: Invalid duplicated custom field keys in request: duplicated_key' + 'Failed to create case configuration: Error: Invalid duplicated customFields keys in request: duplicated_key' ); }); + + describe('templates', () => { + it(`throws when trying to create more than ${MAX_TEMPLATES_LENGTH} templates`, async () => { + await expect( + create( + { + ...baseRequest, + templates: new Array(MAX_TEMPLATES_LENGTH + 1).fill({ + key: 'template_1', + name: 'template 1', + description: 'test', + caseFields: null, + }), + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + `Failed to create case configuration: Error: The length of the field templates is too long. Array must be of length <= ${MAX_TEMPLATES_LENGTH}.` + ); + }); + + it('throws when there are duplicated template keys in the request', async () => { + await expect( + create( + { + ...baseRequest, + templates: [ + { + key: 'duplicated_key', + name: 'template 1', + description: 'test', + caseFields: null, + }, + { + key: 'duplicated_key', + name: 'template 2', + description: 'test', + caseFields: { + title: 'Case title', + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to create case configuration: Error: Invalid duplicated templates keys in request: duplicated_key' + ); + }); + + describe('customFields', () => { + it('does not throw error when creating template with correct custom fields', async () => { + const customFields = [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + label: 'custom field 1', + required: true, + }, + ]; + const templates: TemplatesConfiguration = [ + { + key: 'template_1', + name: 'template 1', + description: 'test', + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + ], + }, + }, + ]; + + clientArgs.services.caseConfigureService.find.mockResolvedValueOnce({ + page: 1, + per_page: 20, + total: 1, + saved_objects: [ + { + id: 'test-id', + type: 'cases-configure', + version: 'test-version', + namespaces: ['default'], + references: [], + attributes: { + ...baseRequest, + customFields, + templates, + created_at: '2019-11-25T21:54:48.952Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + updated_at: null, + updated_by: null, + }, + score: 0, + }, + ], + pit_id: undefined, + }); + + clientArgs.services.caseConfigureService.post.mockResolvedValue({ + id: 'test-id', + type: 'cases-configure', + version: 'test-version', + namespaces: ['default'], + references: [], + attributes: { + ...baseRequest, + customFields, + templates, + created_at: '2019-11-25T21:54:48.952Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + updated_at: null, + updated_by: null, + }, + }); + + await expect( + create( + { + ...baseRequest, + customFields, + templates, + }, + clientArgs, + casesClientInternal + ) + ).resolves.not.toThrow(); + }); + + it('throws when there are no customFields in configure and template has customField in the request', async () => { + await expect( + create( + { + ...baseRequest, + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + ], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to create case configuration: Error: No custom fields configured.' + ); + }); + + it('throws when template has duplicated custom field keys in the request', async () => { + await expect( + create( + { + ...baseRequest, + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + label: 'custom field 1', + required: true, + }, + ], + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'test', + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + value: 'custom field value 2', + }, + ], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + `Failed to create case configuration: Error: Invalid duplicated templates[0]'s customFields keys in request: custom_field_key_1` + ); + }); + + it('throws when there are invalid customField keys in the request', async () => { + await expect( + create( + { + ...baseRequest, + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + label: 'custom field 1', + required: true, + }, + ], + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_2', + type: CustomFieldTypes.TEXT, + value: 'custom field value 1', + }, + ], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to create case configuration: Error: Invalid custom field keys: custom_field_key_2' + ); + }); + + it('throws when template has customField with invalid type in the request', async () => { + await expect( + create( + { + ...baseRequest, + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TEXT, + label: 'custom field 1', + required: true, + }, + ], + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + customFields: [ + { + key: 'custom_field_key_1', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to create case configuration: Error: The following custom fields have the wrong type in the request: "custom field 1"' + ); + }); + }); + + describe('assignees', () => { + it('throws if the user does not have the correct license while adding assignees in template ', async () => { + clientArgs.services.licensingService.isAtLeastPlatinum.mockResolvedValue(false); + + await expect( + create( + { + ...baseRequest, + templates: [ + { + key: 'template_1', + name: 'template 1', + description: 'this is test description', + caseFields: { + assignees: [{ uid: '1' }], + }, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to create case configuration: Error: In order to assign users to cases, you must be subscribed to an Elastic Platinum license' + ); + }); + }); + }); }); }); diff --git a/x-pack/plugins/cases/server/client/configure/client.ts b/x-pack/plugins/cases/server/client/configure/client.ts index 2fc0cc3e72590..9d202b87b0a2c 100644 --- a/x-pack/plugins/cases/server/client/configure/client.ts +++ b/x-pack/plugins/cases/server/client/configure/client.ts @@ -18,6 +18,8 @@ import type { ConfigurationAttributes, Configurations, ConnectorMappings, + CustomFieldsConfiguration, + TemplatesConfiguration, } from '../../../common/types/domain'; import type { ConfigurationPatchRequest, @@ -47,8 +49,12 @@ import type { MappingsArgs, CreateMappingsArgs, UpdateMappingsArgs } from './typ import { createMappings } from './create_mappings'; import { updateMappings } from './update_mappings'; import { ConfigurationRt, ConfigurationsRt } from '../../../common/types/domain'; -import { validateDuplicatedCustomFieldKeysInRequest } from '../validators'; -import { validateCustomFieldTypesInRequest } from './validators'; +import { validateDuplicatedKeysInRequest } from '../validators'; +import { + validateCustomFieldTypesInRequest, + validateTemplatesCustomFieldsInRequest, +} from './validators'; +import { LICENSING_CASE_ASSIGNMENT_FEATURE } from '../../common/constants'; /** * Defines the internal helper functions. @@ -91,6 +97,52 @@ export interface ConfigureSubClient { create(configuration: ConfigurationRequest): Promise; } +/** + * validate templates in configuration + */ +const validateTemplates = async ({ + templates, + clientArgs, + customFields, +}: { + templates: TemplatesConfiguration | undefined; + clientArgs: CasesClientArgs; + customFields: CustomFieldsConfiguration | undefined; +}) => { + const { licensingService } = clientArgs.services; + + validateDuplicatedKeysInRequest({ + requestFields: templates, + fieldName: 'templates', + }); + + if (templates && templates.length) { + /** + * Assign users to a template is only available to Platinum+ + */ + const hasAssigneesInTemplate = templates.some((template) => + Boolean(template.caseFields?.assignees && template.caseFields?.assignees.length > 0) + ); + + const hasPlatinumLicenseOrGreater = await licensingService.isAtLeastPlatinum(); + + if (hasAssigneesInTemplate && !hasPlatinumLicenseOrGreater) { + throw Boom.forbidden( + 'In order to assign users to cases, you must be subscribed to an Elastic Platinum license' + ); + } + + if (hasAssigneesInTemplate) { + licensingService.notifyUsage(LICENSING_CASE_ASSIGNMENT_FEATURE); + } + + validateTemplatesCustomFieldsInRequest({ + templates, + customFieldsConfiguration: customFields, + }); + } +}; + /** * These functions should not be exposed on the plugin contract. They are for internal use to support the CRUD of * configurations. @@ -251,9 +303,12 @@ export async function update( try { const request = decodeWithExcessOrThrow(ConfigurationPatchRequestRt)(req); - validateDuplicatedCustomFieldKeysInRequest({ requestCustomFields: request.customFields }); + validateDuplicatedKeysInRequest({ + requestFields: request.customFields, + fieldName: 'customFields', + }); - const { version, ...queryWithoutVersion } = request; + const { version, templates, ...queryWithoutVersion } = request; const configuration = await caseConfigureService.get({ unsecuredSavedObjectsClient, @@ -265,6 +320,12 @@ export async function update( originalCustomFields: configuration.attributes.customFields, }); + await validateTemplates({ + templates, + clientArgs, + customFields: configuration.attributes.customFields, + }); + await authorization.ensureAuthorized({ operation: Operations.updateConfiguration, entities: [{ owner: configuration.attributes.owner, id: configuration.id }], @@ -320,6 +381,7 @@ export async function update( configurationId: configuration.id, updatedAttributes: { ...queryWithoutVersionAndConnector, + ...(templates && { templates }), ...(connector != null && { connector }), updated_at: updateDate, updated_by: user, @@ -364,8 +426,15 @@ export async function create( const validatedConfigurationRequest = decodeWithExcessOrThrow(ConfigurationRequestRt)(configRequest); - validateDuplicatedCustomFieldKeysInRequest({ - requestCustomFields: validatedConfigurationRequest.customFields, + validateDuplicatedKeysInRequest({ + requestFields: validatedConfigurationRequest.customFields, + fieldName: 'customFields', + }); + + await validateTemplates({ + templates: validatedConfigurationRequest.templates, + clientArgs, + customFields: validatedConfigurationRequest.customFields, }); let error = null; @@ -441,6 +510,7 @@ export async function create( attributes: { ...validatedConfigurationRequest, customFields: validatedConfigurationRequest.customFields ?? [], + templates: validatedConfigurationRequest.templates ?? [], connector: validatedConfigurationRequest.connector, created_at: creationDate, created_by: user, diff --git a/x-pack/plugins/cases/server/client/configure/validators.test.ts b/x-pack/plugins/cases/server/client/configure/validators.test.ts index 0f8e20505fb39..ca81926519d37 100644 --- a/x-pack/plugins/cases/server/client/configure/validators.test.ts +++ b/x-pack/plugins/cases/server/client/configure/validators.test.ts @@ -6,10 +6,16 @@ */ import { CustomFieldTypes } from '../../../common/types/domain'; -import { validateCustomFieldTypesInRequest } from './validators'; +import { + validateCustomFieldTypesInRequest, + validateTemplatesCustomFieldsInRequest, +} from './validators'; describe('validators', () => { describe('validateCustomFieldTypesInRequest', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); it('throws an error with the keys of customFields in request that have invalid types', () => { expect(() => validateCustomFieldTypesInRequest({ @@ -69,4 +75,303 @@ describe('validators', () => { ).not.toThrow(); }); }); + + describe('validateTemplatesCustomFieldsInRequest', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not throw if all custom fields types in request match the configuration', () => { + expect(() => + validateTemplatesCustomFieldsInRequest({ + templates: [ + { + key: 'template_key_1', + name: 'first template', + description: 'this is a first template value', + caseFields: { + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + ], + }, + }, + { + key: 'template_key_2', + name: 'second template', + description: 'this is a second template value', + caseFields: { + title: 'Case title with template 2', + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + ], + }, + }, + ], + customFieldsConfiguration: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ], + }) + ).not.toThrow(); + }); + + it('does not throw if no custom fields are in request', () => { + expect(() => + validateTemplatesCustomFieldsInRequest({ + customFieldsConfiguration: undefined, + templates: [ + { + key: 'template_key_1', + name: 'first template', + description: 'this is a first template value', + caseFields: { + tags: ['first-template'], + }, + }, + { + key: 'template_key_2', + name: 'second template', + description: 'this is a second template value', + caseFields: null, + }, + ], + }) + ).not.toThrow(); + }); + + it('does not throw if no configuration found but no templates are in request', () => { + expect(() => + validateTemplatesCustomFieldsInRequest({ + customFieldsConfiguration: undefined, + templates: [], + }) + ).not.toThrow(); + }); + + it('does not throw if the configuration is undefined but no custom fields are in request', () => { + expect(() => validateTemplatesCustomFieldsInRequest({})).not.toThrow(); + }); + + it('throws if configuration is missing and template has custom fields', () => { + expect(() => + validateTemplatesCustomFieldsInRequest({ + templates: [ + { + key: 'template_key_1', + name: 'first template', + description: 'this is a first template value', + caseFields: { + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + ], + }, + }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot(`"No custom fields configured."`); + }); + + it('throws for a single invalid type', () => { + expect(() => + validateTemplatesCustomFieldsInRequest({ + templates: [ + { + key: 'template_key_1', + name: 'first template', + description: 'this is a first template value', + caseFields: { + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }, + }, + ], + customFieldsConfiguration: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'first label', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"The following custom fields have the wrong type in the request: \\"first label\\""` + ); + }); + + it('throws for multiple custom fields with invalid types', () => { + expect(() => + validateTemplatesCustomFieldsInRequest({ + templates: [ + { + key: 'template_key_1', + name: 'first template', + description: 'this is a first template value', + caseFields: { + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + { + key: 'third_key', + type: CustomFieldTypes.TEXT, + value: 'abc', + }, + ], + }, + }, + ], + + customFieldsConfiguration: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'first label', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TEXT, + label: 'second label', + required: false, + }, + { + key: 'third_key', + type: CustomFieldTypes.TOGGLE, + label: 'third label', + required: false, + }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"The following custom fields have the wrong type in the request: \\"first label\\", \\"second label\\", \\"third label\\""` + ); + }); + + it('throws if there are invalid custom field keys', () => { + expect(() => + validateTemplatesCustomFieldsInRequest({ + templates: [ + { + key: 'template_key_1', + name: 'first template', + description: 'this is a first template value', + caseFields: { + customFields: [ + { + key: 'invalid_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + ], + }, + }, + ], + customFieldsConfiguration: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot(`"Invalid custom field keys: invalid_key"`); + }); + + it('throws if template has duplicated custom field keys', () => { + expect(() => + validateTemplatesCustomFieldsInRequest({ + templates: [ + { + key: 'template_key_1', + name: 'first template', + description: 'this is a first template value', + caseFields: { + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + { + key: 'first_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + ], + }, + }, + ], + + customFieldsConfiguration: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid duplicated templates[0]'s customFields keys in request: first_key"` + ); + }); + }); }); diff --git a/x-pack/plugins/cases/server/client/configure/validators.ts b/x-pack/plugins/cases/server/client/configure/validators.ts index c5929065c631b..1dec647561ab8 100644 --- a/x-pack/plugins/cases/server/client/configure/validators.ts +++ b/x-pack/plugins/cases/server/client/configure/validators.ts @@ -6,7 +6,16 @@ */ import Boom from '@hapi/boom'; -import type { CustomFieldTypes } from '../../../common/types/domain'; +import type { + CustomFieldsConfiguration, + CustomFieldTypes, + TemplatesConfiguration, +} from '../../../common/types/domain'; +import { validateDuplicatedKeysInRequest } from '../validators'; +import { + validateCustomFieldKeysAgainstConfiguration, + validateCustomFieldTypesInRequest as validateCaseCustomFieldTypesInRequest, +} from '../cases/validators'; /** * Throws an error if the request tries to change the type of existing custom fields. @@ -38,3 +47,41 @@ export const validateCustomFieldTypesInRequest = ({ ); } }; + +export const validateTemplatesCustomFieldsInRequest = ({ + templates, + customFieldsConfiguration, +}: { + templates?: TemplatesConfiguration; + customFieldsConfiguration?: CustomFieldsConfiguration; +}) => { + if (!Array.isArray(templates) || !templates.length) { + return; + } + + templates.forEach((template, index) => { + if ( + !template.caseFields || + !template.caseFields.customFields || + !template.caseFields.customFields.length + ) { + return; + } + + if (customFieldsConfiguration === undefined) { + throw Boom.badRequest('No custom fields configured.'); + } + + const params = { + requestCustomFields: template.caseFields.customFields, + customFieldsConfiguration, + }; + + validateDuplicatedKeysInRequest({ + requestFields: params.requestCustomFields, + fieldName: `templates[${index}]'s customFields`, + }); + validateCustomFieldKeysAgainstConfiguration(params); + validateCaseCustomFieldTypesInRequest(params); + }); +}; diff --git a/x-pack/plugins/cases/server/client/validators.test.ts b/x-pack/plugins/cases/server/client/validators.test.ts index 8d6caa218f932..77867aedbcb4a 100644 --- a/x-pack/plugins/cases/server/client/validators.test.ts +++ b/x-pack/plugins/cases/server/client/validators.test.ts @@ -5,14 +5,14 @@ * 2.0. */ -import { validateDuplicatedCustomFieldKeysInRequest } from './validators'; +import { validateDuplicatedKeysInRequest } from './validators'; describe('validators', () => { - describe('validateDuplicatedCustomFieldKeysInRequest', () => { - it('returns customFields in request that have duplicated keys', () => { + describe('validateDuplicatedKeysInRequest', () => { + it('returns fields in request that have duplicated keys', () => { expect(() => - validateDuplicatedCustomFieldKeysInRequest({ - requestCustomFields: [ + validateDuplicatedKeysInRequest({ + requestFields: [ { key: 'triplicated_key', }, @@ -29,16 +29,18 @@ describe('validators', () => { key: 'duplicated_key', }, ], + + fieldName: 'foobar', }) ).toThrowErrorMatchingInlineSnapshot( - `"Invalid duplicated custom field keys in request: triplicated_key,duplicated_key"` + `"Invalid duplicated foobar keys in request: triplicated_key,duplicated_key"` ); }); - it('does not throw if no customFields in request have duplicated keys', () => { + it('does not throw if no fields in request have duplicated keys', () => { expect(() => - validateDuplicatedCustomFieldKeysInRequest({ - requestCustomFields: [ + validateDuplicatedKeysInRequest({ + requestFields: [ { key: '1', }, @@ -46,6 +48,7 @@ describe('validators', () => { key: '2', }, ], + fieldName: 'foobar', }) ).not.toThrow(); }); diff --git a/x-pack/plugins/cases/server/client/validators.ts b/x-pack/plugins/cases/server/client/validators.ts index 88b62640cee88..24527ac81155b 100644 --- a/x-pack/plugins/cases/server/client/validators.ts +++ b/x-pack/plugins/cases/server/client/validators.ts @@ -10,15 +10,17 @@ import Boom from '@hapi/boom'; /** * Throws an error if the request has custom fields with duplicated keys. */ -export const validateDuplicatedCustomFieldKeysInRequest = ({ - requestCustomFields = [], +export const validateDuplicatedKeysInRequest = ({ + requestFields = [], + fieldName, }: { - requestCustomFields?: Array<{ key: string }>; + requestFields?: Array<{ key: string }>; + fieldName: string; }) => { const uniqueKeys = new Set(); const duplicatedKeys = new Set(); - requestCustomFields.forEach((item) => { + requestFields.forEach((item) => { if (uniqueKeys.has(item.key)) { duplicatedKeys.add(item.key); } else { @@ -28,7 +30,7 @@ export const validateDuplicatedCustomFieldKeysInRequest = ({ if (duplicatedKeys.size > 0) { throw Boom.badRequest( - `Invalid duplicated custom field keys in request: ${Array.from(duplicatedKeys.values())}` + `Invalid duplicated ${fieldName} keys in request: ${Array.from(duplicatedKeys.values())}` ); } }; diff --git a/x-pack/plugins/cases/server/common/types/configure.ts b/x-pack/plugins/cases/server/common/types/configure.ts index 94dcaf0a9ce19..3ecb13a6a9fc6 100644 --- a/x-pack/plugins/cases/server/common/types/configure.ts +++ b/x-pack/plugins/cases/server/common/types/configure.ts @@ -8,14 +8,19 @@ import * as rt from 'io-ts'; import type { SavedObject } from '@kbn/core/server'; -import type { ConfigurationAttributes } from '../../../common/types/domain'; +import type { + CaseConnector, + CaseCustomFields, + CaseSeverity, + ConfigurationAttributes, +} from '../../../common/types/domain'; import { ConfigurationActivityFieldsRt, ConfigurationAttributesRt, ConfigurationBasicWithoutOwnerRt, } from '../../../common/types/domain'; import type { ConnectorPersisted } from './connectors'; -import type { User } from './user'; +import type { User, UserProfile } from './user'; export interface ConfigurationPersistedAttributes { connector: ConnectorPersisted; @@ -26,6 +31,7 @@ export interface ConfigurationPersistedAttributes { updated_at: string | null; updated_by: User | null; customFields?: PersistedCustomFieldsConfiguration; + templates?: PersistedTemplatesConfiguration; } type PersistedCustomFieldsConfiguration = Array<{ @@ -36,6 +42,25 @@ type PersistedCustomFieldsConfiguration = Array<{ defaultValue?: string | boolean | null; }>; +type PersistedTemplatesConfiguration = Array<{ + key: string; + name: string; + description: string; + caseFields?: CaseFieldsAttributes | null; +}>; + +export interface CaseFieldsAttributes { + title?: string; + assignees?: UserProfile[]; + connector?: CaseConnector; + description?: string; + severity?: CaseSeverity; + tags?: string[]; + category?: string | null; + customFields?: CaseCustomFields; + settings?: { syncAlerts: boolean }; +} + export type ConfigurationTransformedAttributes = ConfigurationAttributes; export type ConfigurationSavedObjectTransformed = SavedObject; diff --git a/x-pack/plugins/cases/server/services/configure/index.test.ts b/x-pack/plugins/cases/server/services/configure/index.test.ts index d1a79cc1a8d6e..627263de50849 100644 --- a/x-pack/plugins/cases/server/services/configure/index.test.ts +++ b/x-pack/plugins/cases/server/services/configure/index.test.ts @@ -5,8 +5,12 @@ * 2.0. */ -import type { CaseConnector, ConfigurationAttributes } from '../../../common/types/domain'; -import { CustomFieldTypes, ConnectorTypes } from '../../../common/types/domain'; +import type { + CaseConnector, + CaseCustomFields, + ConfigurationAttributes, +} from '../../../common/types/domain'; +import { CustomFieldTypes, ConnectorTypes, CaseSeverity } from '../../../common/types/domain'; import { CASE_CONFIGURE_SAVED_OBJECT, SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import { savedObjectsClientMock } from '@kbn/core/server/mocks'; import type { @@ -59,6 +63,40 @@ const basicConfigFields = { defaultValue: 'foobar', }, ], + templates: [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + caseFields: null, + }, + { + key: 'test_template_4', + name: 'Fourth test template', + description: 'This is a fourth test template', + caseFields: { + title: 'Case with sample template 4', + description: 'case desc', + severity: CaseSeverity.LOW, + category: null, + tags: ['sample-4'], + assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], + customFields: [ + { + key: 'first_custom_field_key', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + ] as CaseCustomFields, + connector: { + id: 'none', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, + }, + }, + ], }; const createConfigUpdateParams = (connector?: CaseConnector): Partial => ({ @@ -204,6 +242,46 @@ describe('CaseConfigureService', () => { }, ], "owner": "securitySolution", + "templates": Array [ + Object { + "caseFields": null, + "description": "This is a first test template", + "key": "test_template_1", + "name": "First test template", + }, + Object { + "caseFields": Object { + "assignees": Array [ + Object { + "uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", + }, + ], + "category": null, + "connector": Object { + "fields": null, + "id": "none", + "name": "My Connector", + "type": ".none", + }, + "customFields": Array [ + Object { + "key": "first_custom_field_key", + "type": "text", + "value": "this is a text field value", + }, + ], + "description": "case desc", + "severity": "low", + "tags": Array [ + "sample-4", + ], + "title": "Case with sample template 4", + }, + "description": "This is a fourth test template", + "key": "test_template_4", + "name": "Fourth test template", + }, + ], "updated_at": "2020-04-09T09:43:51.778Z", "updated_by": Object { "email": "testemail@elastic.co", @@ -490,6 +568,46 @@ describe('CaseConfigureService', () => { }, ], "owner": "securitySolution", + "templates": Array [ + Object { + "caseFields": null, + "description": "This is a first test template", + "key": "test_template_1", + "name": "First test template", + }, + Object { + "caseFields": Object { + "assignees": Array [ + Object { + "uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", + }, + ], + "category": null, + "connector": Object { + "fields": null, + "id": "none", + "name": "My Connector", + "type": ".none", + }, + "customFields": Array [ + Object { + "key": "first_custom_field_key", + "type": "text", + "value": "this is a text field value", + }, + ], + "description": "case desc", + "severity": "low", + "tags": Array [ + "sample-4", + ], + "title": "Case with sample template 4", + }, + "description": "This is a fourth test template", + "key": "test_template_4", + "name": "Fourth test template", + }, + ], "updated_at": "2020-04-09T09:43:51.778Z", "updated_by": Object { "email": "testemail@elastic.co", diff --git a/x-pack/plugins/cases/server/services/configure/index.ts b/x-pack/plugins/cases/server/services/configure/index.ts index 6c367d9a96848..f50ac271bc4ff 100644 --- a/x-pack/plugins/cases/server/services/configure/index.ts +++ b/x-pack/plugins/cases/server/services/configure/index.ts @@ -228,12 +228,17 @@ function transformToExternalModel( ? [] : (configuration.attributes.customFields as ConfigurationTransformedAttributes['customFields']); + const templates = !configuration.attributes.templates + ? [] + : (configuration.attributes.templates as ConfigurationTransformedAttributes['templates']); + return { ...configuration, attributes: { ...castedAttributes, connector, customFields, + templates, }, }; } diff --git a/x-pack/test/cases_api_integration/common/lib/api/configuration.ts b/x-pack/test/cases_api_integration/common/lib/api/configuration.ts index cfaa18b430c11..f69ac73f02433 100644 --- a/x-pack/test/cases_api_integration/common/lib/api/configuration.ts +++ b/x-pack/test/cases_api_integration/common/lib/api/configuration.ts @@ -43,6 +43,7 @@ export const getConfigurationRequest = ({ closure_type: 'close-by-user', owner: 'securitySolutionFixture', customFields: [], + templates: [], ...overrides, }; }; diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/get_configure.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/get_configure.ts index 4a9e99016c801..212bd68f46a54 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/get_configure.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/get_configure.ts @@ -5,7 +5,11 @@ * 2.0. */ -import { CustomFieldTypes } from '@kbn/cases-plugin/common/types/domain'; +import { + CaseSeverity, + ConnectorTypes, + CustomFieldTypes, +} from '@kbn/cases-plugin/common/types/domain'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; @@ -80,6 +84,59 @@ export default ({ getService }: FtrProviderContext): void => { expect(data).to.eql(getConfigurationOutput(false, customFields)); }); + it('should return a configuration with templates', async () => { + const templates = { + templates: [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + caseFields: null, + }, + { + key: 'test_template_2', + name: 'Second test template', + description: 'This is a second test template', + caseFields: { + title: 'Case with sample template 2', + description: 'case desc', + severity: CaseSeverity.LOW, + category: null, + tags: ['sample-4'], + assignees: [], + customFields: [], + connector: { + id: 'none', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, + }, + }, + { + key: 'test_template_3', + name: 'Third test template', + description: 'This is a third test template', + caseFields: { + title: 'Case with sample template 3', + tags: ['sample-3'], + }, + }, + ], + }; + + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: templates, + }) + ); + const configuration = await getConfiguration({ supertest }); + + const data = removeServerGeneratedPropertiesFromSavedObject(configuration[0]); + expect(data).to.eql(getConfigurationOutput(false, templates)); + }); + it('should get a single configuration', async () => { await createConfiguration(supertest, getConfigurationRequest({ id: 'connector-2' })); await createConfiguration(supertest); diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/patch_configure.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/patch_configure.ts index c8e0f092edf3a..6e89a92b6cdb6 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/patch_configure.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/patch_configure.ts @@ -6,7 +6,11 @@ */ import expect from '@kbn/expect'; -import { ConnectorTypes, CustomFieldTypes } from '@kbn/cases-plugin/common/types/domain'; +import { + CaseSeverity, + ConnectorTypes, + CustomFieldTypes, +} from '@kbn/cases-plugin/common/types/domain'; import { ConfigurationPatchRequest } from '@kbn/cases-plugin/common/types/api'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; @@ -127,6 +131,88 @@ export default ({ getService }: FtrProviderContext): void => { ]); }); + it('should patch a configuration with templates', async () => { + const customFieldsConfiguration = [ + { + key: 'text_field_1', + type: CustomFieldTypes.TEXT, + label: 'Text field 1', + required: true, + }, + { + key: 'toggle_field_1', + label: '#2', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + ]; + + const templates = [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + caseFields: null, + }, + { + key: 'test_template_2', + name: 'Second test template', + description: 'This is a second test template', + caseFields: { + title: 'Case with sample template 2', + description: 'case desc', + severity: CaseSeverity.LOW, + category: null, + tags: ['sample-4'], + assignees: [], + customFields: [ + { + key: 'text_field_1', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + { + key: 'toggle_field_1', + value: true, + type: CustomFieldTypes.TOGGLE, + }, + ], + connector: { + id: 'none', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, + }, + }, + { + key: 'test_template_3', + name: 'Third test template', + description: 'This is a third test template', + caseFields: { + title: 'Case with sample template 3', + tags: ['sample-3'], + }, + }, + ] as ConfigurationPatchRequest['templates']; + + const configuration = await createConfiguration(supertest, { + ...getConfigurationRequest(), + customFields: customFieldsConfiguration as ConfigurationPatchRequest['customFields'], + }); + const newConfiguration = await updateConfiguration(supertest, configuration.id, { + version: configuration.version, + templates, + }); + + const data = removeServerGeneratedPropertiesFromSavedObject(newConfiguration); + expect(data).to.eql({ + ...getConfigurationOutput(true), + customFields: customFieldsConfiguration as ConfigurationPatchRequest['customFields'], + templates, + }); + }); + describe('validation', () => { it('should not patch a configuration with unsupported connector type', async () => { const configuration = await createConfiguration(supertest); @@ -270,6 +356,64 @@ export default ({ getService }: FtrProviderContext): void => { 400 ); }); + + it("should not update a configuration with templates with custom fields that don't exist in the configuration", async () => { + const configuration = await createConfiguration(supertest); + + await updateConfiguration( + supertest, + configuration.id, + { + version: configuration.version, + templates: [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + caseFields: { + customFields: [ + { + key: 'random_key', + type: CustomFieldTypes.TEXT, + value: 'Test', + }, + ], + }, + }, + ], + }, + 400 + ); + }); + + it('should not patch a configuration with duplicated template keys', async () => { + const configuration = await createConfiguration(supertest); + await updateConfiguration( + supertest, + configuration.id, + { + version: configuration.version, + templates: [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + caseFields: null, + }, + { + key: 'test_template_1', + name: 'Third test template', + description: 'This is a third test template', + caseFields: { + title: 'Case with sample template 3', + tags: ['sample-3'], + }, + }, + ] as ConfigurationPatchRequest['templates'], + }, + 400 + ); + }); }); describe('rbac', () => { diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/post_configure.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/post_configure.ts index a7461d5f1fc18..0f766525f03d3 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/post_configure.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/post_configure.ts @@ -6,7 +6,11 @@ */ import expect from '@kbn/expect'; -import { ConnectorTypes, CustomFieldTypes } from '@kbn/cases-plugin/common/types/domain'; +import { + CaseSeverity, + ConnectorTypes, + CustomFieldTypes, +} from '@kbn/cases-plugin/common/types/domain'; import { MAX_CUSTOM_FIELD_LABEL_LENGTH } from '@kbn/cases-plugin/common/constants'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; @@ -98,6 +102,82 @@ export default ({ getService }: FtrProviderContext): void => { expect(data).to.eql(getConfigurationOutput(false, customFields)); }); + it('should create a configuration with templates', async () => { + const customFields = [ + { + key: 'text_field_1', + type: CustomFieldTypes.TEXT, + label: 'Text field 1', + required: true, + }, + { + key: 'toggle_field_1', + label: '#2', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + ]; + + const templates = [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + caseFields: null, + }, + { + key: 'test_template_2', + name: 'Second test template', + description: 'This is a second test template', + caseFields: { + title: 'Case with sample template 2', + description: 'case desc', + severity: CaseSeverity.LOW, + category: null, + tags: ['sample-4'], + assignees: [], + customFields: [ + { + key: 'text_field_1', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + { + key: 'toggle_field_1', + value: true, + type: CustomFieldTypes.TOGGLE, + }, + ], + connector: { + id: 'none', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, + }, + }, + { + key: 'test_template_3', + name: 'Third test template', + description: 'This is a third test template', + caseFields: { + title: 'Case with sample template 3', + tags: ['sample-3'], + }, + }, + ]; + + const configuration = await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { customFields, templates }, + }) + ); + + const data = removeServerGeneratedPropertiesFromSavedObject(configuration); + expect(data).to.eql({ ...getConfigurationOutput(false), customFields, templates }); + }); + it('should keep only the latest configuration', async () => { await createConfiguration(supertest, getConfigurationRequest({ id: 'connector-2' })); await createConfiguration(supertest); @@ -410,6 +490,61 @@ export default ({ getService }: FtrProviderContext): void => { 400 ); }); + + it("should not create a configuration with templates with custom fields that don't exist in the configuration", async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + templates: [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + caseFields: { + customFields: [ + { + key: 'random_key', + type: CustomFieldTypes.TEXT, + value: 'Test', + }, + ], + }, + }, + ], + }, + }), + 400 + ); + }); + + it('should not create a configuration with duplicated template keys', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + templates: [ + { + key: 'test_template_1', + name: 'First test template', + description: 'This is a first test template', + caseFields: null, + }, + { + key: 'test_template_1', + name: 'Third test template', + description: 'This is a third test template', + caseFields: { + title: 'Case with sample template 3', + tags: ['sample-3'], + }, + }, + ], + }, + }), + 400 + ); + }); }); describe('rbac', () => { diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/connectors.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/connectors.cy.ts index 6506b0985ee20..9c84a9067fbfe 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/connectors.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/connectors.cy.ts @@ -33,6 +33,7 @@ describe('Cases connectors', { tags: ['@ess', '@serverless'] }, () => { updated_at: null, updated_by: null, customFields: [], + templates: [], mappings: [ { source: 'title', target: 'summary', action_type: 'overwrite' }, { source: 'description', target: 'description', action_type: 'overwrite' },