From e4043b736b800fade10ab80b03aeb7b0292e0540 Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Fri, 26 Jun 2020 14:15:35 -0400 Subject: [PATCH] [SIEM][Exceptions] - Cleaned up and updated exception list item comment structure (#69532) ### Summary This PR is a follow up to #68864 . That PR used a partial to differentiate between new and existing comments, this meant that comments could be updated when they shouldn't. It was decided in our discussion of exception list schemas that comments should be append only. This PR assures that's the case, but also leaves it open to editing comments (via API). It checks to make sure that users can only update their own comments. --- .../create_exception_list_item_schema.test.ts | 54 ++- .../create_exception_list_item_schema.ts | 6 +- .../update_exception_list_item_schema.ts | 8 +- .../common/schemas/types/comments.mock.ts | 21 +- .../common/schemas/types/comments.test.ts | 217 +++++++++ .../lists/common/schemas/types/comments.ts | 36 +- .../schemas/types/create_comments.mock.ts | 12 + .../schemas/types/create_comments.test.ts | 134 ++++++ .../common/schemas/types/create_comments.ts | 18 + .../types/default_comments_array.test.ts | 68 +++ .../schemas/types/default_comments_array.ts | 29 +- .../default_create_comments_array.test.ts | 66 +++ .../types/default_create_comments_array.ts | 28 ++ .../default_update_comments_array.test.ts | 70 +++ .../types/default_update_comments_array.ts | 28 ++ .../lists/common/schemas/types/index.ts | 10 +- .../schemas/types/update_comments.mock.ts | 14 + .../schemas/types/update_comments.test.ts | 108 +++++ .../common/schemas/types/update_comments.ts | 14 + .../lists/public/exceptions/api.test.ts | 2 +- x-pack/plugins/lists/public/exceptions/api.ts | 2 +- .../server/saved_objects/exception_list.ts | 6 + .../updates/simple_update_item.json | 9 +- .../create_exception_list_item.ts | 9 +- .../exception_list_client_types.ts | 7 +- .../update_exception_list_item.ts | 13 +- .../services/exception_lists/utils.test.ts | 437 ++++++++++++++++++ .../server/services/exception_lists/utils.ts | 106 ++++- .../components/exceptions/helpers.test.tsx | 10 +- .../common/components/exceptions/helpers.tsx | 5 +- .../common/components/exceptions/types.ts | 6 - .../exception_item/exception_details.test.tsx | 14 +- .../viewer/exception_item/index.stories.tsx | 6 +- .../viewer/exception_item/index.test.tsx | 6 +- .../public/lists_plugin_deps.ts | 1 + 35 files changed, 1440 insertions(+), 140 deletions(-) create mode 100644 x-pack/plugins/lists/common/schemas/types/comments.test.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/create_comments.mock.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/create_comments.test.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/create_comments.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/default_comments_array.test.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/default_create_comments_array.test.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/default_create_comments_array.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/default_update_comments_array.test.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/default_update_comments_array.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/update_comments.mock.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/update_comments.test.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/update_comments.ts create mode 100644 x-pack/plugins/lists/server/services/exception_lists/utils.test.ts diff --git a/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.test.ts b/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.test.ts index ccafe70406ec..34551b74d8c9 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.test.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.test.ts @@ -8,6 +8,9 @@ import { left } from 'fp-ts/lib/Either'; import { pipe } from 'fp-ts/lib/pipeable'; import { exactCheck, foldLeftRight, getPaths } from '../../siem_common_deps'; +import { getCreateCommentsArrayMock } from '../types/create_comments.mock'; +import { getCommentsMock } from '../types/comments.mock'; +import { CommentsArray } from '../types'; import { CreateExceptionListItemSchema, @@ -26,7 +29,7 @@ describe('create_exception_list_item_schema', () => { expect(message.schema).toEqual(payload); }); - test('it should not accept an undefined for "description"', () => { + test('it should not validate an undefined for "description"', () => { const payload = getCreateExceptionListItemSchemaMock(); delete payload.description; const decoded = createExceptionListItemSchema.decode(payload); @@ -38,7 +41,7 @@ describe('create_exception_list_item_schema', () => { expect(message.schema).toEqual({}); }); - test('it should not accept an undefined for "name"', () => { + test('it should not validate an undefined for "name"', () => { const payload = getCreateExceptionListItemSchemaMock(); delete payload.name; const decoded = createExceptionListItemSchema.decode(payload); @@ -50,7 +53,7 @@ describe('create_exception_list_item_schema', () => { expect(message.schema).toEqual({}); }); - test('it should not accept an undefined for "type"', () => { + test('it should not validate an undefined for "type"', () => { const payload = getCreateExceptionListItemSchemaMock(); delete payload.type; const decoded = createExceptionListItemSchema.decode(payload); @@ -62,7 +65,7 @@ describe('create_exception_list_item_schema', () => { expect(message.schema).toEqual({}); }); - test('it should not accept an undefined for "list_id"', () => { + test('it should not validate an undefined for "list_id"', () => { const inputPayload = getCreateExceptionListItemSchemaMock(); delete inputPayload.list_id; const decoded = createExceptionListItemSchema.decode(inputPayload); @@ -74,7 +77,7 @@ describe('create_exception_list_item_schema', () => { expect(message.schema).toEqual({}); }); - test('it should accept an undefined for "meta" but strip it out and generate a correct body not counting the auto generated uuid', () => { + test('it should validate an undefined for "meta" but strip it out and generate a correct body not counting the auto generated uuid', () => { const payload = getCreateExceptionListItemSchemaMock(); const outputPayload = getCreateExceptionListItemSchemaMock(); delete payload.meta; @@ -87,7 +90,7 @@ describe('create_exception_list_item_schema', () => { expect(message.schema).toEqual(outputPayload); }); - test('it should accept an undefined for "comments" but return an array and generate a correct body not counting the auto generated uuid', () => { + test('it should validate an undefined for "comments" but return an array and generate a correct body not counting the auto generated uuid', () => { const inputPayload = getCreateExceptionListItemSchemaMock(); const outputPayload = getCreateExceptionListItemSchemaMock(); delete inputPayload.comments; @@ -100,7 +103,34 @@ describe('create_exception_list_item_schema', () => { expect(message.schema).toEqual(outputPayload); }); - test('it should accept an undefined for "entries" but return an array', () => { + test('it should validate "comments" array', () => { + const inputPayload = { + ...getCreateExceptionListItemSchemaMock(), + comments: getCreateCommentsArrayMock(), + }; + const decoded = createExceptionListItemSchema.decode(inputPayload); + const checked = exactCheck(inputPayload, decoded); + const message = pipe(checked, foldLeftRight); + delete (message.schema as CreateExceptionListItemSchema).item_id; + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(inputPayload); + }); + + test('it should NOT validate "comments" with "created_at" or "created_by" values', () => { + const inputPayload: Omit & { + comments?: CommentsArray; + } = { + ...getCreateExceptionListItemSchemaMock(), + comments: [getCommentsMock()], + }; + const decoded = createExceptionListItemSchema.decode(inputPayload); + const checked = exactCheck(inputPayload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual(['invalid keys "created_at,created_by"']); + expect(message.schema).toEqual({}); + }); + + test('it should validate an undefined for "entries" but return an array', () => { const inputPayload = getCreateExceptionListItemSchemaMock(); const outputPayload = getCreateExceptionListItemSchemaMock(); delete inputPayload.entries; @@ -113,7 +143,7 @@ describe('create_exception_list_item_schema', () => { expect(message.schema).toEqual(outputPayload); }); - test('it should accept an undefined for "namespace_type" but return enum "single" and generate a correct body not counting the auto generated uuid', () => { + test('it should validate an undefined for "namespace_type" but return enum "single" and generate a correct body not counting the auto generated uuid', () => { const inputPayload = getCreateExceptionListItemSchemaMock(); const outputPayload = getCreateExceptionListItemSchemaMock(); delete inputPayload.namespace_type; @@ -126,7 +156,7 @@ describe('create_exception_list_item_schema', () => { expect(message.schema).toEqual(outputPayload); }); - test('it should accept an undefined for "tags" but return an array and generate a correct body not counting the auto generated uuid', () => { + test('it should validate an undefined for "tags" but return an array and generate a correct body not counting the auto generated uuid', () => { const inputPayload = getCreateExceptionListItemSchemaMock(); const outputPayload = getCreateExceptionListItemSchemaMock(); delete inputPayload.tags; @@ -139,7 +169,7 @@ describe('create_exception_list_item_schema', () => { expect(message.schema).toEqual(outputPayload); }); - test('it should accept an undefined for "_tags" but return an array and generate a correct body not counting the auto generated uuid', () => { + test('it should validate an undefined for "_tags" but return an array and generate a correct body not counting the auto generated uuid', () => { const inputPayload = getCreateExceptionListItemSchemaMock(); const outputPayload = getCreateExceptionListItemSchemaMock(); delete inputPayload._tags; @@ -152,7 +182,7 @@ describe('create_exception_list_item_schema', () => { expect(message.schema).toEqual(outputPayload); }); - test('it should accept an undefined for "item_id" and auto generate a uuid', () => { + test('it should validate an undefined for "item_id" and auto generate a uuid', () => { const inputPayload = getCreateExceptionListItemSchemaMock(); delete inputPayload.item_id; const decoded = createExceptionListItemSchema.decode(inputPayload); @@ -164,7 +194,7 @@ describe('create_exception_list_item_schema', () => { ); }); - test('it should accept an undefined for "item_id" and generate a correct body not counting the uuid', () => { + test('it should validate an undefined for "item_id" and generate a correct body not counting the uuid', () => { const inputPayload = getCreateExceptionListItemSchemaMock(); delete inputPayload.item_id; const decoded = createExceptionListItemSchema.decode(inputPayload); diff --git a/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts index f593b5d16403..fb452ac89576 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts @@ -23,7 +23,7 @@ import { tags, } from '../common/schemas'; import { Identity, RequiredKeepUndefined } from '../../types'; -import { CommentsPartialArray, DefaultCommentsPartialArray, DefaultEntryArray } from '../types'; +import { CreateCommentsArray, DefaultCreateCommentsArray, DefaultEntryArray } from '../types'; import { EntriesArray } from '../types/entries'; import { DefaultUuid } from '../../siem_common_deps'; @@ -39,7 +39,7 @@ export const createExceptionListItemSchema = t.intersection([ t.exact( t.partial({ _tags, // defaults to empty array if not set during decode - comments: DefaultCommentsPartialArray, // defaults to empty array if not set during decode + comments: DefaultCreateCommentsArray, // defaults to empty array if not set during decode entries: DefaultEntryArray, // defaults to empty array if not set during decode item_id: DefaultUuid, // defaults to GUID (uuid v4) if not set during decode meta, // defaults to undefined if not set during decode @@ -63,7 +63,7 @@ export type CreateExceptionListItemSchemaDecoded = Identity< '_tags' | 'tags' | 'item_id' | 'entries' | 'namespace_type' | 'comments' > & { _tags: _Tags; - comments: CommentsPartialArray; + comments: CreateCommentsArray; tags: Tags; item_id: ItemId; entries: EntriesArray; diff --git a/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts index c32b15fecb57..582fabdc160f 100644 --- a/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts @@ -23,10 +23,10 @@ import { } from '../common/schemas'; import { Identity, RequiredKeepUndefined } from '../../types'; import { - CommentsPartialArray, - DefaultCommentsPartialArray, DefaultEntryArray, + DefaultUpdateCommentsArray, EntriesArray, + UpdateCommentsArray, } from '../types'; export const updateExceptionListItemSchema = t.intersection([ @@ -40,7 +40,7 @@ export const updateExceptionListItemSchema = t.intersection([ t.exact( t.partial({ _tags, // defaults to empty array if not set during decode - comments: DefaultCommentsPartialArray, // defaults to empty array if not set during decode + comments: DefaultUpdateCommentsArray, // defaults to empty array if not set during decode entries: DefaultEntryArray, // defaults to empty array if not set during decode id, // defaults to undefined if not set during decode item_id: t.union([t.string, t.undefined]), @@ -65,7 +65,7 @@ export type UpdateExceptionListItemSchemaDecoded = Identity< '_tags' | 'tags' | 'entries' | 'namespace_type' | 'comments' > & { _tags: _Tags; - comments: CommentsPartialArray; + comments: UpdateCommentsArray; tags: Tags; entries: EntriesArray; namespace_type: NamespaceType; diff --git a/x-pack/plugins/lists/common/schemas/types/comments.mock.ts b/x-pack/plugins/lists/common/schemas/types/comments.mock.ts index ee58fafe074c..9e56ac292f8b 100644 --- a/x-pack/plugins/lists/common/schemas/types/comments.mock.ts +++ b/x-pack/plugins/lists/common/schemas/types/comments.mock.ts @@ -6,17 +6,12 @@ import { DATE_NOW, USER } from '../../constants.mock'; -import { CommentsArray } from './comments'; +import { Comments, CommentsArray } from './comments'; -export const getCommentsMock = (): CommentsArray => [ - { - comment: 'some comment', - created_at: DATE_NOW, - created_by: USER, - }, - { - comment: 'some other comment', - created_at: DATE_NOW, - created_by: 'lily', - }, -]; +export const getCommentsMock = (): Comments => ({ + comment: 'some old comment', + created_at: DATE_NOW, + created_by: USER, +}); + +export const getCommentsArrayMock = (): CommentsArray => [getCommentsMock(), getCommentsMock()]; diff --git a/x-pack/plugins/lists/common/schemas/types/comments.test.ts b/x-pack/plugins/lists/common/schemas/types/comments.test.ts new file mode 100644 index 000000000000..29bfde03abcc --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/comments.test.ts @@ -0,0 +1,217 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { DATE_NOW } from '../../constants.mock'; +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { getCommentsArrayMock, getCommentsMock } from './comments.mock'; +import { + Comments, + CommentsArray, + CommentsArrayOrUndefined, + comments, + commentsArray, + commentsArrayOrUndefined, +} from './comments'; + +describe('Comments', () => { + describe('comments', () => { + test('it should validate a comments', () => { + const payload = getCommentsMock(); + const decoded = comments.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate with "updated_at" and "updated_by"', () => { + const payload = getCommentsMock(); + payload.updated_at = DATE_NOW; + payload.updated_by = 'someone'; + const decoded = comments.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not validate when undefined', () => { + const payload = undefined; + const decoded = comments.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)"', + 'Invalid value "undefined" supplied to "({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when "comment" is not a string', () => { + const payload: Omit & { comment: string[] } = { + ...getCommentsMock(), + comment: ['some value'], + }; + const decoded = comments.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "["some value"]" supplied to "comment"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when "created_at" is not a string', () => { + const payload: Omit & { created_at: number } = { + ...getCommentsMock(), + created_at: 1, + }; + const decoded = comments.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "created_at"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when "created_by" is not a string', () => { + const payload: Omit & { created_by: number } = { + ...getCommentsMock(), + created_by: 1, + }; + const decoded = comments.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "created_by"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when "updated_at" is not a string', () => { + const payload: Omit & { updated_at: number } = { + ...getCommentsMock(), + updated_at: 1, + }; + const decoded = comments.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "updated_at"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when "updated_by" is not a string', () => { + const payload: Omit & { updated_by: number } = { + ...getCommentsMock(), + updated_by: 1, + }; + const decoded = comments.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "updated_by"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should strip out extra keys', () => { + const payload: Comments & { + extraKey?: string; + } = getCommentsMock(); + payload.extraKey = 'some value'; + const decoded = comments.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(getCommentsMock()); + }); + }); + + describe('commentsArray', () => { + test('it should validate an array of comments', () => { + const payload = getCommentsArrayMock(); + const decoded = commentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate when a comments includes "updated_at" and "updated_by"', () => { + const commentsPayload = getCommentsMock(); + commentsPayload.updated_at = DATE_NOW; + commentsPayload.updated_by = 'someone'; + const payload = [{ ...commentsPayload }, ...getCommentsArrayMock()]; + const decoded = commentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not validate when undefined', () => { + const payload = undefined; + const decoded = commentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "Array<({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)>"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when array includes non comments types', () => { + const payload = ([1] as unknown) as CommentsArray; + const decoded = commentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "Array<({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)>"', + 'Invalid value "1" supplied to "Array<({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)>"', + ]); + expect(message.schema).toEqual({}); + }); + }); + + describe('commentsArrayOrUndefined', () => { + test('it should validate an array of comments', () => { + const payload = getCommentsArrayMock(); + const decoded = commentsArrayOrUndefined.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate when undefined', () => { + const payload = undefined; + const decoded = commentsArrayOrUndefined.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not validate when array includes non comments types', () => { + const payload = ([1] as unknown) as CommentsArrayOrUndefined; + const decoded = commentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "Array<({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)>"', + 'Invalid value "1" supplied to "Array<({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)>"', + ]); + expect(message.schema).toEqual({}); + }); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/comments.ts b/x-pack/plugins/lists/common/schemas/types/comments.ts index d61608c3508f..0ee3b05c8102 100644 --- a/x-pack/plugins/lists/common/schemas/types/comments.ts +++ b/x-pack/plugins/lists/common/schemas/types/comments.ts @@ -5,36 +5,24 @@ */ import * as t from 'io-ts'; -export const comment = t.exact( - t.type({ - comment: t.string, - created_at: t.string, // TODO: Make this into an ISO Date string check, - created_by: t.string, - }) -); - -export const commentsArray = t.array(comment); -export type CommentsArray = t.TypeOf; -export type Comment = t.TypeOf; -export const commentsArrayOrUndefined = t.union([commentsArray, t.undefined]); -export type CommentsArrayOrUndefined = t.TypeOf; - -export const commentPartial = t.intersection([ +export const comments = t.intersection([ t.exact( t.type({ comment: t.string, - }) - ), - t.exact( - t.partial({ created_at: t.string, // TODO: Make this into an ISO Date string check, created_by: t.string, }) ), + t.exact( + t.partial({ + updated_at: t.string, + updated_by: t.string, + }) + ), ]); -export const commentsPartialArray = t.array(commentPartial); -export type CommentsPartialArray = t.TypeOf; -export type CommentPartial = t.TypeOf; -export const commentsPartialArrayOrUndefined = t.union([commentsPartialArray, t.undefined]); -export type CommentsPartialArrayOrUndefined = t.TypeOf; +export const commentsArray = t.array(comments); +export type CommentsArray = t.TypeOf; +export type Comments = t.TypeOf; +export const commentsArrayOrUndefined = t.union([commentsArray, t.undefined]); +export type CommentsArrayOrUndefined = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/types/create_comments.mock.ts b/x-pack/plugins/lists/common/schemas/types/create_comments.mock.ts new file mode 100644 index 000000000000..60a59432275c --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/create_comments.mock.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import { CreateComments, CreateCommentsArray } from './create_comments'; + +export const getCreateCommentsMock = (): CreateComments => ({ + comment: 'some comments', +}); + +export const getCreateCommentsArrayMock = (): CreateCommentsArray => [getCreateCommentsMock()]; diff --git a/x-pack/plugins/lists/common/schemas/types/create_comments.test.ts b/x-pack/plugins/lists/common/schemas/types/create_comments.test.ts new file mode 100644 index 000000000000..d2680750e05e --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/create_comments.test.ts @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { getCreateCommentsArrayMock, getCreateCommentsMock } from './create_comments.mock'; +import { + CreateComments, + CreateCommentsArray, + CreateCommentsArrayOrUndefined, + createComments, + createCommentsArray, + createCommentsArrayOrUndefined, +} from './create_comments'; + +describe('CreateComments', () => { + describe('createComments', () => { + test('it should validate a comments', () => { + const payload = getCreateCommentsMock(); + const decoded = createComments.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not validate when undefined', () => { + const payload = undefined; + const decoded = createComments.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "{| comment: string |}"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when "comment" is not a string', () => { + const payload: Omit & { comment: string[] } = { + ...getCreateCommentsMock(), + comment: ['some value'], + }; + const decoded = createComments.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "["some value"]" supplied to "comment"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should strip out extra keys', () => { + const payload: CreateComments & { + extraKey?: string; + } = getCreateCommentsMock(); + payload.extraKey = 'some value'; + const decoded = createComments.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(getCreateCommentsMock()); + }); + }); + + describe('createCommentsArray', () => { + test('it should validate an array of comments', () => { + const payload = getCreateCommentsArrayMock(); + const decoded = createCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not validate when undefined', () => { + const payload = undefined; + const decoded = createCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "Array<{| comment: string |}>"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when array includes non comments types', () => { + const payload = ([1] as unknown) as CreateCommentsArray; + const decoded = createCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "Array<{| comment: string |}>"', + ]); + expect(message.schema).toEqual({}); + }); + }); + + describe('createCommentsArrayOrUndefined', () => { + test('it should validate an array of comments', () => { + const payload = getCreateCommentsArrayMock(); + const decoded = createCommentsArrayOrUndefined.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate when undefined', () => { + const payload = undefined; + const decoded = createCommentsArrayOrUndefined.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not validate when array includes non comments types', () => { + const payload = ([1] as unknown) as CreateCommentsArrayOrUndefined; + const decoded = createCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "Array<{| comment: string |}>"', + ]); + expect(message.schema).toEqual({}); + }); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/create_comments.ts b/x-pack/plugins/lists/common/schemas/types/create_comments.ts new file mode 100644 index 000000000000..c34419298ef9 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/create_comments.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as t from 'io-ts'; + +export const createComments = t.exact( + t.type({ + comment: t.string, + }) +); + +export const createCommentsArray = t.array(createComments); +export type CreateCommentsArray = t.TypeOf; +export type CreateComments = t.TypeOf; +export const createCommentsArrayOrUndefined = t.union([createCommentsArray, t.undefined]); +export type CreateCommentsArrayOrUndefined = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/types/default_comments_array.test.ts b/x-pack/plugins/lists/common/schemas/types/default_comments_array.test.ts new file mode 100644 index 000000000000..3a4241aaec82 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/default_comments_array.test.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { DefaultCommentsArray } from './default_comments_array'; +import { CommentsArray } from './comments'; +import { getCommentsArrayMock } from './comments.mock'; + +describe('default_comments_array', () => { + test('it should validate an empty array', () => { + const payload: CommentsArray = []; + const decoded = DefaultCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate an array of comments', () => { + const payload: CommentsArray = getCommentsArrayMock(); + const decoded = DefaultCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should NOT validate an array of numbers', () => { + const payload = [1]; + const decoded = DefaultCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + // TODO: Known weird error formatting that is on our list to address + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "Array<({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)>"', + 'Invalid value "1" supplied to "Array<({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)>"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate an array of strings', () => { + const payload = ['some string']; + const decoded = DefaultCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "some string" supplied to "Array<({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)>"', + 'Invalid value "some string" supplied to "Array<({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)>"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should return a default array entry', () => { + const payload = null; + const decoded = DefaultCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual([]); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/default_comments_array.ts b/x-pack/plugins/lists/common/schemas/types/default_comments_array.ts index e824d481b361..e8be299246ab 100644 --- a/x-pack/plugins/lists/common/schemas/types/default_comments_array.ts +++ b/x-pack/plugins/lists/common/schemas/types/default_comments_array.ts @@ -7,14 +7,9 @@ import * as t from 'io-ts'; import { Either } from 'fp-ts/lib/Either'; -import { CommentsArray, CommentsPartialArray, comment, commentPartial } from './comments'; +import { CommentsArray, comments } from './comments'; export type DefaultCommentsArrayC = t.Type; -export type DefaultCommentsPartialArrayC = t.Type< - CommentsPartialArray, - CommentsPartialArray, - unknown ->; /** * Types the DefaultCommentsArray as: @@ -26,24 +21,8 @@ export const DefaultCommentsArray: DefaultCommentsArrayC = new t.Type< unknown >( 'DefaultCommentsArray', - t.array(comment).is, - (input, context): Either => - input == null ? t.success([]) : t.array(comment).validate(input, context), - t.identity -); - -/** - * Types the DefaultCommentsPartialArray as: - * - If null or undefined, then a default array of type entry will be set - */ -export const DefaultCommentsPartialArray: DefaultCommentsPartialArrayC = new t.Type< - CommentsPartialArray, - CommentsPartialArray, - unknown ->( - 'DefaultCommentsPartialArray', - t.array(commentPartial).is, - (input, context): Either => - input == null ? t.success([]) : t.array(commentPartial).validate(input, context), + t.array(comments).is, + (input): Either => + input == null ? t.success([]) : t.array(comments).decode(input), t.identity ); diff --git a/x-pack/plugins/lists/common/schemas/types/default_create_comments_array.test.ts b/x-pack/plugins/lists/common/schemas/types/default_create_comments_array.test.ts new file mode 100644 index 000000000000..f5ef7d0ad96b --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/default_create_comments_array.test.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { DefaultCreateCommentsArray } from './default_create_comments_array'; +import { CreateCommentsArray } from './create_comments'; +import { getCreateCommentsArrayMock } from './create_comments.mock'; + +describe('default_create_comments_array', () => { + test('it should validate an empty array', () => { + const payload: CreateCommentsArray = []; + const decoded = DefaultCreateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate an array of comments', () => { + const payload: CreateCommentsArray = getCreateCommentsArrayMock(); + const decoded = DefaultCreateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should NOT validate an array of numbers', () => { + const payload = [1]; + const decoded = DefaultCreateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + // TODO: Known weird error formatting that is on our list to address + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "Array<{| comment: string |}>"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate an array of strings', () => { + const payload = ['some string']; + const decoded = DefaultCreateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "some string" supplied to "Array<{| comment: string |}>"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should return a default array entry', () => { + const payload = null; + const decoded = DefaultCreateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual([]); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/default_create_comments_array.ts b/x-pack/plugins/lists/common/schemas/types/default_create_comments_array.ts new file mode 100644 index 000000000000..51431b9c3985 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/default_create_comments_array.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { Either } from 'fp-ts/lib/Either'; + +import { CreateCommentsArray, createComments } from './create_comments'; + +export type DefaultCreateCommentsArrayC = t.Type; + +/** + * Types the DefaultCreateComments as: + * - If null or undefined, then a default array of type entry will be set + */ +export const DefaultCreateCommentsArray: DefaultCreateCommentsArrayC = new t.Type< + CreateCommentsArray, + CreateCommentsArray, + unknown +>( + 'DefaultCreateComments', + t.array(createComments).is, + (input): Either => + input == null ? t.success([]) : t.array(createComments).decode(input), + t.identity +); diff --git a/x-pack/plugins/lists/common/schemas/types/default_update_comments_array.test.ts b/x-pack/plugins/lists/common/schemas/types/default_update_comments_array.test.ts new file mode 100644 index 000000000000..b023e73cb932 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/default_update_comments_array.test.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { DefaultUpdateCommentsArray } from './default_update_comments_array'; +import { UpdateCommentsArray } from './update_comments'; +import { getUpdateCommentsArrayMock } from './update_comments.mock'; + +describe('default_update_comments_array', () => { + test('it should validate an empty array', () => { + const payload: UpdateCommentsArray = []; + const decoded = DefaultUpdateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate an array of comments', () => { + const payload: UpdateCommentsArray = getUpdateCommentsArrayMock(); + const decoded = DefaultUpdateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should NOT validate an array of numbers', () => { + const payload = [1]; + const decoded = DefaultUpdateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + // TODO: Known weird error formatting that is on our list to address + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"', + 'Invalid value "1" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"', + 'Invalid value "1" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate an array of strings', () => { + const payload = ['some string']; + const decoded = DefaultUpdateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "some string" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"', + 'Invalid value "some string" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"', + 'Invalid value "some string" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should return a default array entry', () => { + const payload = null; + const decoded = DefaultUpdateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual([]); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/default_update_comments_array.ts b/x-pack/plugins/lists/common/schemas/types/default_update_comments_array.ts new file mode 100644 index 000000000000..c2593826a635 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/default_update_comments_array.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { Either } from 'fp-ts/lib/Either'; + +import { UpdateCommentsArray, updateCommentsArray } from './update_comments'; + +export type DefaultUpdateCommentsArrayC = t.Type; + +/** + * Types the DefaultCommentsUpdate as: + * - If null or undefined, then a default array of type entry will be set + */ +export const DefaultUpdateCommentsArray: DefaultUpdateCommentsArrayC = new t.Type< + UpdateCommentsArray, + UpdateCommentsArray, + unknown +>( + 'DefaultCreateComments', + updateCommentsArray.is, + (input): Either => + input == null ? t.success([]) : updateCommentsArray.decode(input), + t.identity +); diff --git a/x-pack/plugins/lists/common/schemas/types/index.ts b/x-pack/plugins/lists/common/schemas/types/index.ts index 97f2b0f59a5f..16433e00f2b1 100644 --- a/x-pack/plugins/lists/common/schemas/types/index.ts +++ b/x-pack/plugins/lists/common/schemas/types/index.ts @@ -3,8 +3,12 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -export * from './default_comments_array'; -export * from './default_entries_array'; -export * from './default_namespace'; export * from './comments'; +export * from './create_comments'; +export * from './update_comments'; +export * from './default_comments_array'; +export * from './default_create_comments_array'; +export * from './default_update_comments_array'; +export * from './default_namespace'; +export * from './default_entries_array'; export * from './entries'; diff --git a/x-pack/plugins/lists/common/schemas/types/update_comments.mock.ts b/x-pack/plugins/lists/common/schemas/types/update_comments.mock.ts new file mode 100644 index 000000000000..3e963c2607dc --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/update_comments.mock.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getCommentsMock } from './comments.mock'; +import { getCreateCommentsMock } from './create_comments.mock'; +import { UpdateCommentsArray } from './update_comments'; + +export const getUpdateCommentsArrayMock = (): UpdateCommentsArray => [ + getCommentsMock(), + getCreateCommentsMock(), +]; diff --git a/x-pack/plugins/lists/common/schemas/types/update_comments.test.ts b/x-pack/plugins/lists/common/schemas/types/update_comments.test.ts new file mode 100644 index 000000000000..7668504b031b --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/update_comments.test.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { getUpdateCommentsArrayMock } from './update_comments.mock'; +import { + UpdateCommentsArray, + UpdateCommentsArrayOrUndefined, + updateCommentsArray, + updateCommentsArrayOrUndefined, +} from './update_comments'; +import { getCommentsMock } from './comments.mock'; +import { getCreateCommentsMock } from './create_comments.mock'; + +describe('CommentsUpdate', () => { + describe('updateCommentsArray', () => { + test('it should validate an array of comments', () => { + const payload = getUpdateCommentsArrayMock(); + const decoded = updateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate an array of existing comments', () => { + const payload = [getCommentsMock()]; + const decoded = updateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate an array of new comments', () => { + const payload = [getCreateCommentsMock()]; + const decoded = updateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not validate when undefined', () => { + const payload = undefined; + const decoded = updateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when array includes non comments types', () => { + const payload = ([1] as unknown) as UpdateCommentsArray; + const decoded = updateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"', + 'Invalid value "1" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"', + 'Invalid value "1" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"', + ]); + expect(message.schema).toEqual({}); + }); + }); + + describe('updateCommentsArrayOrUndefined', () => { + test('it should validate an array of comments', () => { + const payload = getUpdateCommentsArrayMock(); + const decoded = updateCommentsArrayOrUndefined.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate when undefined', () => { + const payload = undefined; + const decoded = updateCommentsArrayOrUndefined.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not validate when array includes non comments types', () => { + const payload = ([1] as unknown) as UpdateCommentsArrayOrUndefined; + const decoded = updateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"', + 'Invalid value "1" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"', + 'Invalid value "1" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"', + ]); + expect(message.schema).toEqual({}); + }); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/update_comments.ts b/x-pack/plugins/lists/common/schemas/types/update_comments.ts new file mode 100644 index 000000000000..4a21bfa363d4 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/update_comments.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as t from 'io-ts'; + +import { comments } from './comments'; +import { createComments } from './create_comments'; + +export const updateCommentsArray = t.array(t.union([comments, createComments])); +export type UpdateCommentsArray = t.TypeOf; +export const updateCommentsArrayOrUndefined = t.union([updateCommentsArray, t.undefined]); +export type UpdateCommentsArrayOrUndefined = t.TypeOf; diff --git a/x-pack/plugins/lists/public/exceptions/api.test.ts b/x-pack/plugins/lists/public/exceptions/api.test.ts index 72a689650ea2..975641b9bebe 100644 --- a/x-pack/plugins/lists/public/exceptions/api.test.ts +++ b/x-pack/plugins/lists/public/exceptions/api.test.ts @@ -250,7 +250,7 @@ describe('Exceptions Lists API', () => { }); // TODO Would like to just use getExceptionListSchemaMock() here, but // validation returns object in different order, making the strings not match - expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists', { + expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists/items', { body: JSON.stringify(payload), method: 'PUT', signal: abortCtrl.signal, diff --git a/x-pack/plugins/lists/public/exceptions/api.ts b/x-pack/plugins/lists/public/exceptions/api.ts index 2ab7695d8c17..a581cfd08ecc 100644 --- a/x-pack/plugins/lists/public/exceptions/api.ts +++ b/x-pack/plugins/lists/public/exceptions/api.ts @@ -176,7 +176,7 @@ export const updateExceptionListItem = async ({ if (validatedRequest != null) { try { - const response = await http.fetch(EXCEPTION_LIST_URL, { + const response = await http.fetch(EXCEPTION_LIST_ITEM_URL, { body: JSON.stringify(listItem), method: 'PUT', signal, diff --git a/x-pack/plugins/lists/server/saved_objects/exception_list.ts b/x-pack/plugins/lists/server/saved_objects/exception_list.ts index 57bc63e6f7e3..fc04c5e278d6 100644 --- a/x-pack/plugins/lists/server/saved_objects/exception_list.ts +++ b/x-pack/plugins/lists/server/saved_objects/exception_list.ts @@ -77,6 +77,12 @@ export const exceptionListItemMapping: SavedObjectsType['mappings'] = { created_by: { type: 'keyword', }, + updated_at: { + type: 'keyword', + }, + updated_by: { + type: 'keyword', + }, }, }, entries: { diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update_item.json b/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update_item.json index 33c9303c7b52..08bd95b7d124 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update_item.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update_item.json @@ -5,14 +5,7 @@ "type": "simple", "description": "This is a sample change here this list", "name": "Sample Endpoint Exception List update change", - "comments": [ - { - "comment": "this was an old comment.", - "created_by": "lily", - "created_at": "2020-04-20T15:25:31.830Z" - }, - { "comment": "this is a newly added comment" } - ], + "comments": [{ "comment": "this is a newly added comment" }], "entries": [ { "field": "event.category", diff --git a/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts index 22a9fbcfb53a..a84283aeabbb 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts @@ -8,7 +8,7 @@ import { SavedObjectsClientContract } from 'kibana/server'; import uuid from 'uuid'; import { - CommentsPartialArray, + CreateCommentsArray, Description, EntriesArray, ExceptionListItemSchema, @@ -25,13 +25,13 @@ import { import { getSavedObjectType, - transformComments, + transformCreateCommentsToComments, transformSavedObjectToExceptionListItem, } from './utils'; interface CreateExceptionListItemOptions { _tags: _Tags; - comments: CommentsPartialArray; + comments: CreateCommentsArray; listId: ListId; itemId: ItemId; savedObjectsClient: SavedObjectsClientContract; @@ -64,9 +64,10 @@ export const createExceptionListItem = async ({ }: CreateExceptionListItemOptions): Promise => { const savedObjectType = getSavedObjectType({ namespaceType }); const dateNow = new Date().toISOString(); + const transformedComments = transformCreateCommentsToComments({ comments, user }); const savedObject = await savedObjectsClient.create(savedObjectType, { _tags, - comments: transformComments({ comments, user }), + comments: transformedComments, created_at: dateNow, created_by: user, description, diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts index 03f5de516561..203d32911a6d 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts @@ -7,7 +7,7 @@ import { SavedObjectsClientContract } from 'kibana/server'; import { - CommentsPartialArray, + CreateCommentsArray, Description, DescriptionOrUndefined, EntriesArray, @@ -30,6 +30,7 @@ import { SortOrderOrUndefined, Tags, TagsOrUndefined, + UpdateCommentsArray, _Tags, _TagsOrUndefined, } from '../../../common/schemas'; @@ -88,7 +89,7 @@ export interface GetExceptionListItemOptions { export interface CreateExceptionListItemOptions { _tags: _Tags; - comments: CommentsPartialArray; + comments: CreateCommentsArray; entries: EntriesArray; itemId: ItemId; listId: ListId; @@ -102,7 +103,7 @@ export interface CreateExceptionListItemOptions { export interface UpdateExceptionListItemOptions { _tags: _TagsOrUndefined; - comments: CommentsPartialArray; + comments: UpdateCommentsArray; entries: EntriesArrayOrUndefined; id: IdOrUndefined; itemId: ItemIdOrUndefined; diff --git a/x-pack/plugins/lists/server/services/exception_lists/update_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/update_exception_list_item.ts index 7ca9bfd83ab6..5578063fd9b6 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/update_exception_list_item.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/update_exception_list_item.ts @@ -7,7 +7,6 @@ import { SavedObjectsClientContract } from 'kibana/server'; import { - CommentsPartialArray, DescriptionOrUndefined, EntriesArrayOrUndefined, ExceptionListItemSchema, @@ -19,19 +18,20 @@ import { NameOrUndefined, NamespaceType, TagsOrUndefined, + UpdateCommentsArrayOrUndefined, _TagsOrUndefined, } from '../../../common/schemas'; import { getSavedObjectType, - transformComments, transformSavedObjectUpdateToExceptionListItem, + transformUpdateCommentsToComments, } from './utils'; import { getExceptionListItem } from './get_exception_list_item'; interface UpdateExceptionListItemOptions { id: IdOrUndefined; - comments: CommentsPartialArray; + comments: UpdateCommentsArrayOrUndefined; _tags: _TagsOrUndefined; name: NameOrUndefined; description: DescriptionOrUndefined; @@ -71,12 +71,17 @@ export const updateExceptionListItem = async ({ if (exceptionListItem == null) { return null; } else { + const transformedComments = transformUpdateCommentsToComments({ + comments, + existingComments: exceptionListItem.comments, + user, + }); const savedObject = await savedObjectsClient.update( savedObjectType, exceptionListItem.id, { _tags, - comments: transformComments({ comments, user }), + comments: transformedComments, description, entries, meta, diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils.test.ts b/x-pack/plugins/lists/server/services/exception_lists/utils.test.ts new file mode 100644 index 000000000000..9cc2aacd8845 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils.test.ts @@ -0,0 +1,437 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import sinon from 'sinon'; +import moment from 'moment'; + +import { DATE_NOW, USER } from '../../../common/constants.mock'; + +import { + isCommentEqual, + transformCreateCommentsToComments, + transformUpdateComments, + transformUpdateCommentsToComments, +} from './utils'; + +describe('utils', () => { + const anchor = '2020-06-17T20:34:51.337Z'; + const unix = moment(anchor).valueOf(); + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = sinon.useFakeTimers(unix); + }); + + afterEach(() => { + clock.restore(); + }); + + describe('#transformUpdateCommentsToComments', () => { + test('it returns empty array if "comments" is undefined and no comments exist', () => { + const comments = transformUpdateCommentsToComments({ + comments: undefined, + existingComments: [], + user: 'lily', + }); + + expect(comments).toEqual([]); + }); + + test('it formats newly added comments', () => { + const comments = transformUpdateCommentsToComments({ + comments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + { comment: 'Im a new comment' }, + ], + existingComments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + ], + user: 'lily', + }); + + expect(comments).toEqual([ + { + comment: 'Im an old comment', + created_at: anchor, + created_by: 'lily', + }, + { + comment: 'Im a new comment', + created_at: anchor, + created_by: 'lily', + }, + ]); + }); + + test('it formats multiple newly added comments', () => { + const comments = transformUpdateCommentsToComments({ + comments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + { comment: 'Im a new comment' }, + { comment: 'Im another new comment' }, + ], + existingComments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + ], + user: 'lily', + }); + + expect(comments).toEqual([ + { + comment: 'Im an old comment', + created_at: anchor, + created_by: 'lily', + }, + { + comment: 'Im a new comment', + created_at: anchor, + created_by: 'lily', + }, + { + comment: 'Im another new comment', + created_at: anchor, + created_by: 'lily', + }, + ]); + }); + + test('it should not throw if comments match existing comments', () => { + const comments = transformUpdateCommentsToComments({ + comments: [{ comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }], + existingComments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + ], + user: 'lily', + }); + + expect(comments).toEqual([ + { + comment: 'Im an old comment', + created_at: anchor, + created_by: 'lily', + }, + ]); + }); + + test('it does not throw if user tries to update one of their own existing comments', () => { + const comments = transformUpdateCommentsToComments({ + comments: [ + { + comment: 'Im an old comment that is trying to be updated', + created_at: DATE_NOW, + created_by: 'lily', + }, + ], + existingComments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + ], + user: 'lily', + }); + + expect(comments).toEqual([ + { + comment: 'Im an old comment that is trying to be updated', + created_at: DATE_NOW, + created_by: 'lily', + updated_at: anchor, + updated_by: 'lily', + }, + ]); + }); + + test('it throws an error if user tries to update their comment, without passing in the "created_at" and "created_by" properties', () => { + expect(() => + transformUpdateCommentsToComments({ + comments: [ + { + comment: 'Im an old comment that is trying to be updated', + }, + ], + existingComments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + ], + user: 'lily', + }) + ).toThrowErrorMatchingInlineSnapshot( + `"When trying to update a comment, \\"created_at\\" and \\"created_by\\" must be present"` + ); + }); + + test('it throws an error if user tries to delete comments', () => { + expect(() => + transformUpdateCommentsToComments({ + comments: [], + existingComments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + ], + user: 'lily', + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Comments cannot be deleted, only new comments may be added"` + ); + }); + + test('it throws if user tries to update existing comment timestamp', () => { + expect(() => + transformUpdateCommentsToComments({ + comments: [{ comment: 'Im an old comment', created_at: anchor, created_by: 'lily' }], + existingComments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + ], + user: 'bane', + }) + ).toThrowErrorMatchingInlineSnapshot(`"Not authorized to edit others comments"`); + }); + + test('it throws if user tries to update existing comment author', () => { + expect(() => + transformUpdateCommentsToComments({ + comments: [{ comment: 'Im an old comment', created_at: anchor, created_by: 'lily' }], + existingComments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'me!' }, + ], + user: 'bane', + }) + ).toThrowErrorMatchingInlineSnapshot(`"Not authorized to edit others comments"`); + }); + + test('it throws if user tries to update an existing comment that is not their own', () => { + expect(() => + transformUpdateCommentsToComments({ + comments: [ + { + comment: 'Im an old comment that is trying to be updated', + created_at: DATE_NOW, + created_by: 'lily', + }, + ], + existingComments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + ], + user: 'bane', + }) + ).toThrowErrorMatchingInlineSnapshot(`"Not authorized to edit others comments"`); + }); + + test('it throws if user tries to update order of comments', () => { + expect(() => + transformUpdateCommentsToComments({ + comments: [ + { comment: 'Im a new comment' }, + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + ], + existingComments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + ], + user: 'lily', + }) + ).toThrowErrorMatchingInlineSnapshot( + `"When trying to update a comment, \\"created_at\\" and \\"created_by\\" must be present"` + ); + }); + + test('it throws an error if user tries to add comment formatted as existing comment when none yet exist', () => { + expect(() => + transformUpdateCommentsToComments({ + comments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + { comment: 'Im a new comment' }, + ], + existingComments: [], + user: 'lily', + }) + ).toThrowErrorMatchingInlineSnapshot(`"Only new comments may be added"`); + }); + + test('it throws if empty comment exists', () => { + expect(() => + transformUpdateCommentsToComments({ + comments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + { comment: ' ' }, + ], + existingComments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + ], + user: 'lily', + }) + ).toThrowErrorMatchingInlineSnapshot(`"Empty comments not allowed"`); + }); + }); + + describe('#transformCreateCommentsToComments', () => { + test('it returns "undefined" if "comments" is "undefined"', () => { + const comments = transformCreateCommentsToComments({ + comments: undefined, + user: 'lily', + }); + + expect(comments).toBeUndefined(); + }); + + test('it formats newly added comments', () => { + const comments = transformCreateCommentsToComments({ + comments: [{ comment: 'Im a new comment' }, { comment: 'Im another new comment' }], + user: 'lily', + }); + + expect(comments).toEqual([ + { + comment: 'Im a new comment', + created_at: anchor, + created_by: 'lily', + }, + { + comment: 'Im another new comment', + created_at: anchor, + created_by: 'lily', + }, + ]); + }); + + test('it throws an error if user tries to add an empty comment', () => { + expect(() => + transformCreateCommentsToComments({ + comments: [{ comment: ' ' }], + user: 'lily', + }) + ).toThrowErrorMatchingInlineSnapshot(`"Empty comments not allowed"`); + }); + }); + + describe('#transformUpdateComments', () => { + test('it updates comment and adds "updated_at" and "updated_by"', () => { + const comments = transformUpdateComments({ + comment: { + comment: 'Im an old comment that is trying to be updated', + created_at: DATE_NOW, + created_by: 'lily', + }, + existingComment: { + comment: 'Im an old comment', + created_at: DATE_NOW, + created_by: 'lily', + }, + user: 'lily', + }); + + expect(comments).toEqual({ + comment: 'Im an old comment that is trying to be updated', + created_at: '2020-04-20T15:25:31.830Z', + created_by: 'lily', + updated_at: anchor, + updated_by: 'lily', + }); + }); + + test('it throws if user tries to update an existing comment that is not their own', () => { + expect(() => + transformUpdateComments({ + comment: { + comment: 'Im an old comment that is trying to be updated', + created_at: DATE_NOW, + created_by: 'lily', + }, + existingComment: { + comment: 'Im an old comment', + created_at: DATE_NOW, + created_by: 'lily', + }, + user: 'bane', + }) + ).toThrowErrorMatchingInlineSnapshot(`"Not authorized to edit others comments"`); + }); + + test('it throws if user tries to update an existing comments timestamp', () => { + expect(() => + transformUpdateComments({ + comment: { + comment: 'Im an old comment that is trying to be updated', + created_at: anchor, + created_by: 'lily', + }, + existingComment: { + comment: 'Im an old comment', + created_at: DATE_NOW, + created_by: 'lily', + }, + user: 'lily', + }) + ).toThrowErrorMatchingInlineSnapshot(`"Unable to update comment"`); + }); + }); + + describe('#isCommentEqual', () => { + test('it returns false if "comment" values differ', () => { + const result = isCommentEqual( + { + comment: 'some old comment', + created_at: DATE_NOW, + created_by: USER, + }, + { + comment: 'some older comment', + created_at: DATE_NOW, + created_by: USER, + } + ); + + expect(result).toBeFalsy(); + }); + + test('it returns false if "created_at" values differ', () => { + const result = isCommentEqual( + { + comment: 'some old comment', + created_at: DATE_NOW, + created_by: USER, + }, + { + comment: 'some old comment', + created_at: anchor, + created_by: USER, + } + ); + + expect(result).toBeFalsy(); + }); + + test('it returns false if "created_by" values differ', () => { + const result = isCommentEqual( + { + comment: 'some old comment', + created_at: DATE_NOW, + created_by: USER, + }, + { + comment: 'some old comment', + created_at: DATE_NOW, + created_by: 'lily', + } + ); + + expect(result).toBeFalsy(); + }); + + test('it returns true if comment values are equivalent', () => { + const result = isCommentEqual( + { + comment: 'some old comment', + created_at: DATE_NOW, + created_by: USER, + }, + { + created_at: DATE_NOW, + created_by: USER, + // Disabling to assure that order doesn't matter + // eslint-disable-next-line sort-keys + comment: 'some old comment', + } + ); + + expect(result).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils.ts b/x-pack/plugins/lists/server/services/exception_lists/utils.ts index 5690a42bed87..14b5309f67dc 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/utils.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/utils.ts @@ -6,15 +6,21 @@ import { SavedObject, SavedObjectsFindResponse, SavedObjectsUpdateResponse } from 'kibana/server'; +import { ErrorWithStatusCode } from '../../error_with_status_code'; import { + Comments, + CommentsArray, CommentsArrayOrUndefined, - CommentsPartialArrayOrUndefined, + CreateComments, + CreateCommentsArrayOrUndefined, ExceptionListItemSchema, ExceptionListSchema, ExceptionListSoSchema, FoundExceptionListItemSchema, FoundExceptionListSchema, NamespaceType, + UpdateCommentsArrayOrUndefined, + comments as commentsSchema, } from '../../../common/schemas'; import { SavedObjectType, @@ -251,21 +257,103 @@ export const transformSavedObjectsToFoundExceptionList = ({ }; }; -export const transformComments = ({ +/* + * Determines whether two comments are equal, this is a very + * naive implementation, not meant to be used for deep equality of complex objects + */ +export const isCommentEqual = (commentA: Comments, commentB: Comments): boolean => { + const a = Object.values(commentA).sort().join(); + const b = Object.values(commentB).sort().join(); + + return a === b; +}; + +export const transformUpdateCommentsToComments = ({ + comments, + existingComments, + user, +}: { + comments: UpdateCommentsArrayOrUndefined; + existingComments: CommentsArray; + user: string; +}): CommentsArray => { + const newComments = comments ?? []; + + if (newComments.length < existingComments.length) { + throw new ErrorWithStatusCode( + 'Comments cannot be deleted, only new comments may be added', + 403 + ); + } else { + return newComments.flatMap((c, index) => { + const existingComment = existingComments[index]; + + if (commentsSchema.is(existingComment) && !commentsSchema.is(c)) { + throw new ErrorWithStatusCode( + 'When trying to update a comment, "created_at" and "created_by" must be present', + 403 + ); + } else if (commentsSchema.is(c) && existingComment == null) { + throw new ErrorWithStatusCode('Only new comments may be added', 403); + } else if ( + commentsSchema.is(c) && + existingComment != null && + !isCommentEqual(c, existingComment) + ) { + return transformUpdateComments({ comment: c, existingComment, user }); + } else { + return transformCreateCommentsToComments({ comments: [c], user }) ?? []; + } + }); + } +}; + +export const transformUpdateComments = ({ + comment, + existingComment, + user, +}: { + comment: Comments; + existingComment: Comments; + user: string; +}): Comments => { + if (comment.created_by !== user) { + // existing comment is being edited, can only be edited by author + throw new ErrorWithStatusCode('Not authorized to edit others comments', 401); + } else if (existingComment.created_at !== comment.created_at) { + throw new ErrorWithStatusCode('Unable to update comment', 403); + } else if (comment.comment.trim().length === 0) { + throw new ErrorWithStatusCode('Empty comments not allowed', 403); + } else { + const dateNow = new Date().toISOString(); + + return { + ...comment, + updated_at: dateNow, + updated_by: user, + }; + } +}; + +export const transformCreateCommentsToComments = ({ comments, user, }: { - comments: CommentsPartialArrayOrUndefined; + comments: CreateCommentsArrayOrUndefined; user: string; }): CommentsArrayOrUndefined => { const dateNow = new Date().toISOString(); if (comments != null) { - return comments.map((comment) => { - return { - comment: comment.comment, - created_at: comment.created_at ?? dateNow, - created_by: comment.created_by ?? user, - }; + return comments.map((c: CreateComments) => { + if (c.comment.trim().length === 0) { + throw new ErrorWithStatusCode('Empty comments not allowed', 403); + } else { + return { + comment: c.comment, + created_at: dateNow, + created_by: user, + }; + } }); } else { return comments; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx index 244819080c93..b936aea04769 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx @@ -37,7 +37,7 @@ import { getEntryMatchAnyMock, getEntriesArrayMock, } from '../../../../../lists/common/schemas/types/entries.mock'; -import { getCommentsMock } from '../../../../../lists/common/schemas/types/comments.mock'; +import { getCommentsArrayMock } from '../../../../../lists/common/schemas/types/comments.mock'; describe('Exception helpers', () => { beforeEach(() => { @@ -382,7 +382,7 @@ describe('Exception helpers', () => { describe('#getFormattedComments', () => { test('it returns formatted comment object with username and timestamp', () => { - const payload = getCommentsMock(); + const payload = getCommentsArrayMock(); const result = getFormattedComments(payload); expect(result[0].username).toEqual('some user'); @@ -390,7 +390,7 @@ describe('Exception helpers', () => { }); test('it returns formatted timeline icon with comment users initial', () => { - const payload = getCommentsMock(); + const payload = getCommentsArrayMock(); const result = getFormattedComments(payload); const wrapper = mount(result[0].timelineIcon as React.ReactElement); @@ -399,12 +399,12 @@ describe('Exception helpers', () => { }); test('it returns comment text', () => { - const payload = getCommentsMock(); + const payload = getCommentsArrayMock(); const result = getFormattedComments(payload); const wrapper = mount(result[0].children as React.ReactElement); - expect(wrapper.text()).toEqual('some comment'); + expect(wrapper.text()).toEqual('some old comment'); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx index 164940db619f..ae4131f9f62c 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -10,9 +10,10 @@ import { capitalize } from 'lodash'; import moment from 'moment'; import * as i18n from './translations'; -import { FormattedEntry, OperatorOption, DescriptionListItem, Comment } from './types'; +import { FormattedEntry, OperatorOption, DescriptionListItem } from './types'; import { EXCEPTION_OPERATORS, isOperator } from './operators'; import { + CommentsArray, Entry, EntriesArray, ExceptionListItemSchema, @@ -183,7 +184,7 @@ export const getDescriptionListContent = ( * * @param comments ExceptionItem.comments */ -export const getFormattedComments = (comments: Comment[]): EuiCommentProps[] => +export const getFormattedComments = (comments: CommentsArray): EuiCommentProps[] => comments.map((comment) => ({ username: comment.created_by, timestamp: moment(comment.created_at).format('on MMM Do YYYY @ HH:mm:ss'), diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts index 24c328462ce2..ed2be64b4430 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts @@ -26,12 +26,6 @@ export interface DescriptionListItem { description: NonNullable; } -export interface Comment { - created_by: string; - created_at: string; - comment: string; -} - export enum ExceptionListType { DETECTION_ENGINE = 'detection', ENDPOINT = 'endpoint', diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx index 3ea8507d82a1..f5b34b7838d2 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx @@ -12,7 +12,7 @@ import moment from 'moment-timezone'; import { ExceptionDetails } from './exception_details'; import { getExceptionListItemSchemaMock } from '../../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; -import { getCommentsMock } from '../../../../../../../lists/common/schemas/types/comments.mock'; +import { getCommentsArrayMock } from '../../../../../../../lists/common/schemas/types/comments.mock'; describe('ExceptionDetails', () => { beforeEach(() => { @@ -42,7 +42,7 @@ describe('ExceptionDetails', () => { test('it renders comments button if comments exist', () => { const exceptionItem = getExceptionListItemSchemaMock(); - exceptionItem.comments = getCommentsMock(); + exceptionItem.comments = getCommentsArrayMock(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { test('it renders correct number of comments', () => { const exceptionItem = getExceptionListItemSchemaMock(); - exceptionItem.comments = [getCommentsMock()[0]]; + exceptionItem.comments = [getCommentsArrayMock()[0]]; const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { test('it renders comments plural if more than one', () => { const exceptionItem = getExceptionListItemSchemaMock(); - exceptionItem.comments = getCommentsMock(); + exceptionItem.comments = getCommentsArrayMock(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { test('it renders comments show text if "showComments" is false', () => { const exceptionItem = getExceptionListItemSchemaMock(); - exceptionItem.comments = getCommentsMock(); + exceptionItem.comments = getCommentsArrayMock(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { test('it renders comments hide text if "showComments" is true', () => { const exceptionItem = getExceptionListItemSchemaMock(); - exceptionItem.comments = getCommentsMock(); + exceptionItem.comments = getCommentsArrayMock(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { test('it invokes "onCommentsClick" when comments button clicked', () => { const mockOnCommentsClick = jest.fn(); const exceptionItem = getExceptionListItemSchemaMock(); - exceptionItem.comments = getCommentsMock(); + exceptionItem.comments = getCommentsArrayMock(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> ( ({ eui: euiLightVars, darkMode: false })}>{storyFn()} @@ -68,7 +68,7 @@ storiesOf('Components|ExceptionItem', module) const payload = getExceptionListItemSchemaMock(); payload._tags = []; payload.description = ''; - payload.comments = getCommentsMock(); + payload.comments = getCommentsArrayMock(); payload.entries = [ { field: 'actingProcess.file.signer', @@ -106,7 +106,7 @@ storiesOf('Components|ExceptionItem', module) }) .add('with everything', () => { const payload = getExceptionListItemSchemaMock(); - payload.comments = getCommentsMock(); + payload.comments = getCommentsArrayMock(); return ( { it('it renders ExceptionDetails and ExceptionEntries', () => { @@ -83,7 +83,7 @@ describe('ExceptionItem', () => { it('it renders comment accordion closed to begin with', () => { const mockOnDeleteException = jest.fn(); const exceptionItem = getExceptionListItemSchemaMock(); - exceptionItem.comments = getCommentsMock(); + exceptionItem.comments = getCommentsArrayMock(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { it('it renders comment accordion open when showComments is true', () => { const mockOnDeleteException = jest.fn(); const exceptionItem = getExceptionListItemSchemaMock(); - exceptionItem.comments = getCommentsMock(); + exceptionItem.comments = getCommentsArrayMock(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}>