[Security Solution][Exceptions] - Update exception item comments to include id (#73129)

## Summary

This PR is somewhat of an intermediary step. Comments on exception list items are denormalized. We initially decided that we would not add `uuid` to comments, but found that it is in fact necessary. This is intermediary in the sense that what we ideally want to have is a dedicated `comments` CRUD route. 

Also just note that I added a callout for when a version conflict occurs (ie: exception item was updated by someone else while a user is editing the same item).

With this PR users are able to:
- Create comments when creating exception list items
- Add new comments on exception item update

Users will currently be blocked from:
- Deleting comments
- Updating comments
- Updating exception item if version conflict is found
This commit is contained in:
Yara Tercero 2020-07-27 18:19:16 -04:00 committed by GitHub
parent 57997beed8
commit 94ed783cae
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 722 additions and 803 deletions

View file

@ -6,6 +6,7 @@
import { EntriesArray } from './schemas/types';
export const DATE_NOW = '2020-04-20T15:25:31.830Z';
export const OLD_DATE_RELATIVE_TO_DATE_NOW = '2020-04-19T15:25:31.830Z';
export const USER = 'some user';
export const LIST_INDEX = '.lists';
export const LIST_ITEM_INDEX = '.items';

View file

@ -8,8 +8,8 @@ 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 { getCreateCommentsArrayMock } from '../types/create_comment.mock';
import { getCommentsMock } from '../types/comment.mock';
import { CommentsArray } from '../types';
import {
@ -19,7 +19,7 @@ import {
import { getCreateEndpointListItemSchemaMock } from './create_endpoint_list_item_schema.mock';
describe('create_endpoint_list_item_schema', () => {
test('it should validate a typical list item request not counting the auto generated uuid', () => {
test('it should pass validation when supplied a typical list item request not counting the auto generated uuid', () => {
const payload = getCreateEndpointListItemSchemaMock();
const decoded = createEndpointListItemSchema.decode(payload);
const checked = exactCheck(payload, decoded);
@ -29,7 +29,7 @@ describe('create_endpoint_list_item_schema', () => {
expect(message.schema).toEqual(payload);
});
test('it should not validate an undefined for "description"', () => {
test('it should fail validation when supplied an undefined for "description"', () => {
const payload = getCreateEndpointListItemSchemaMock();
delete payload.description;
const decoded = createEndpointListItemSchema.decode(payload);
@ -41,7 +41,7 @@ describe('create_endpoint_list_item_schema', () => {
expect(message.schema).toEqual({});
});
test('it should not validate an undefined for "name"', () => {
test('it should fail validation when supplied an undefined for "name"', () => {
const payload = getCreateEndpointListItemSchemaMock();
delete payload.name;
const decoded = createEndpointListItemSchema.decode(payload);
@ -53,7 +53,7 @@ describe('create_endpoint_list_item_schema', () => {
expect(message.schema).toEqual({});
});
test('it should not validate an undefined for "type"', () => {
test('it should fail validation when supplied an undefined for "type"', () => {
const payload = getCreateEndpointListItemSchemaMock();
delete payload.type;
const decoded = createEndpointListItemSchema.decode(payload);
@ -65,7 +65,7 @@ describe('create_endpoint_list_item_schema', () => {
expect(message.schema).toEqual({});
});
test('it should not validate a "list_id" since it does not required one', () => {
test('it should fail validation when supplied a "list_id" since it does not required one', () => {
const inputPayload: CreateEndpointListItemSchema & { list_id: string } = {
...getCreateEndpointListItemSchemaMock(),
list_id: 'list-123',
@ -77,7 +77,7 @@ describe('create_endpoint_list_item_schema', () => {
expect(message.schema).toEqual({});
});
test('it should not validate a "namespace_type" since it does not required one', () => {
test('it should fail validation when supplied a "namespace_type" since it does not required one', () => {
const inputPayload: CreateEndpointListItemSchema & { namespace_type: string } = {
...getCreateEndpointListItemSchemaMock(),
namespace_type: 'single',
@ -89,7 +89,7 @@ describe('create_endpoint_list_item_schema', () => {
expect(message.schema).toEqual({});
});
test('it should validate an undefined for "meta" but strip it out and generate a correct body not counting the auto generated uuid', () => {
test('it should pass validation when supplied an undefined for "meta" but strip it out and generate a correct body not counting the auto generated uuid', () => {
const payload = getCreateEndpointListItemSchemaMock();
const outputPayload = getCreateEndpointListItemSchemaMock();
delete payload.meta;
@ -102,7 +102,7 @@ describe('create_endpoint_list_item_schema', () => {
expect(message.schema).toEqual(outputPayload);
});
test('it should validate an undefined for "comments" but return an array and generate a correct body not counting the auto generated uuid', () => {
test('it should pass validation when supplied an undefined for "comments" but return an array and generate a correct body not counting the auto generated uuid', () => {
const inputPayload = getCreateEndpointListItemSchemaMock();
const outputPayload = getCreateEndpointListItemSchemaMock();
delete inputPayload.comments;
@ -115,7 +115,7 @@ describe('create_endpoint_list_item_schema', () => {
expect(message.schema).toEqual(outputPayload);
});
test('it should validate "comments" array', () => {
test('it should pass validation when supplied "comments" array', () => {
const inputPayload = {
...getCreateEndpointListItemSchemaMock(),
comments: getCreateCommentsArrayMock(),
@ -128,7 +128,7 @@ describe('create_endpoint_list_item_schema', () => {
expect(message.schema).toEqual(inputPayload);
});
test('it should NOT validate "comments" with "created_at" or "created_by" values', () => {
test('it should fail validation when supplied "comments" with "created_at", "created_by", or "id" values', () => {
const inputPayload: Omit<CreateEndpointListItemSchema, 'comments'> & {
comments?: CommentsArray;
} = {
@ -138,11 +138,11 @@ describe('create_endpoint_list_item_schema', () => {
const decoded = createEndpointListItemSchema.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(getPaths(left(message.errors))).toEqual(['invalid keys "created_at,created_by,id"']);
expect(message.schema).toEqual({});
});
test('it should NOT validate an undefined for "entries"', () => {
test('it should fail validation when supplied an undefined for "entries"', () => {
const inputPayload = getCreateEndpointListItemSchemaMock();
const outputPayload = getCreateEndpointListItemSchemaMock();
delete inputPayload.entries;
@ -157,7 +157,7 @@ describe('create_endpoint_list_item_schema', () => {
expect(message.schema).toEqual({});
});
test('it should validate an undefined for "tags" but return an array and generate a correct body not counting the auto generated uuid', () => {
test('it should pass validation when supplied an undefined for "tags" but return an array and generate a correct body not counting the auto generated uuid', () => {
const inputPayload = getCreateEndpointListItemSchemaMock();
const outputPayload = getCreateEndpointListItemSchemaMock();
delete inputPayload.tags;
@ -170,7 +170,7 @@ describe('create_endpoint_list_item_schema', () => {
expect(message.schema).toEqual(outputPayload);
});
test('it should validate an undefined for "_tags" but return an array and generate a correct body not counting the auto generated uuid', () => {
test('it should pass validation when supplied an undefined for "_tags" but return an array and generate a correct body not counting the auto generated uuid', () => {
const inputPayload = getCreateEndpointListItemSchemaMock();
const outputPayload = getCreateEndpointListItemSchemaMock();
delete inputPayload._tags;
@ -183,7 +183,7 @@ describe('create_endpoint_list_item_schema', () => {
expect(message.schema).toEqual(outputPayload);
});
test('it should validate an undefined for "item_id" and auto generate a uuid', () => {
test('it should pass validation when supplied an undefined for "item_id" and auto generate a uuid', () => {
const inputPayload = getCreateEndpointListItemSchemaMock();
delete inputPayload.item_id;
const decoded = createEndpointListItemSchema.decode(inputPayload);
@ -195,7 +195,7 @@ describe('create_endpoint_list_item_schema', () => {
);
});
test('it should validate an undefined for "item_id" and generate a correct body not counting the uuid', () => {
test('it should pass validation when supplied an undefined for "item_id" and generate a correct body not counting the uuid', () => {
const inputPayload = getCreateEndpointListItemSchemaMock();
delete inputPayload.item_id;
const decoded = createEndpointListItemSchema.decode(inputPayload);

View file

@ -8,8 +8,8 @@ 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 { getCreateCommentsArrayMock } from '../types/create_comment.mock';
import { getCommentsMock } from '../types/comment.mock';
import { CommentsArray } from '../types';
import {
@ -19,7 +19,7 @@ import {
import { getCreateExceptionListItemSchemaMock } from './create_exception_list_item_schema.mock';
describe('create_exception_list_item_schema', () => {
test('it should validate a typical exception list item request not counting the auto generated uuid', () => {
test('it should pass validation when supplied a typical exception list item request not counting the auto generated uuid', () => {
const payload = getCreateExceptionListItemSchemaMock();
const decoded = createExceptionListItemSchema.decode(payload);
const checked = exactCheck(payload, decoded);
@ -29,7 +29,7 @@ describe('create_exception_list_item_schema', () => {
expect(message.schema).toEqual(payload);
});
test('it should not validate an undefined for "description"', () => {
test('it should fail validation when supplied an undefined for "description"', () => {
const payload = getCreateExceptionListItemSchemaMock();
delete payload.description;
const decoded = createExceptionListItemSchema.decode(payload);
@ -41,7 +41,7 @@ describe('create_exception_list_item_schema', () => {
expect(message.schema).toEqual({});
});
test('it should not validate an undefined for "name"', () => {
test('it should fail validation when supplied an undefined for "name"', () => {
const payload = getCreateExceptionListItemSchemaMock();
delete payload.name;
const decoded = createExceptionListItemSchema.decode(payload);
@ -53,7 +53,7 @@ describe('create_exception_list_item_schema', () => {
expect(message.schema).toEqual({});
});
test('it should not validate an undefined for "type"', () => {
test('it should fail validation when supplied an undefined for "type"', () => {
const payload = getCreateExceptionListItemSchemaMock();
delete payload.type;
const decoded = createExceptionListItemSchema.decode(payload);
@ -65,7 +65,7 @@ describe('create_exception_list_item_schema', () => {
expect(message.schema).toEqual({});
});
test('it should not validate an undefined for "list_id"', () => {
test('it should fail validation when supplied an undefined for "list_id"', () => {
const inputPayload = getCreateExceptionListItemSchemaMock();
delete inputPayload.list_id;
const decoded = createExceptionListItemSchema.decode(inputPayload);
@ -77,7 +77,7 @@ describe('create_exception_list_item_schema', () => {
expect(message.schema).toEqual({});
});
test('it should validate an undefined for "meta" but strip it out and generate a correct body not counting the auto generated uuid', () => {
test('it should pass validation when supplied 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;
@ -90,7 +90,7 @@ describe('create_exception_list_item_schema', () => {
expect(message.schema).toEqual(outputPayload);
});
test('it should validate an undefined for "comments" but return an array and generate a correct body not counting the auto generated uuid', () => {
test('it should pass validation when supplied 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;
@ -103,7 +103,7 @@ describe('create_exception_list_item_schema', () => {
expect(message.schema).toEqual(outputPayload);
});
test('it should validate "comments" array', () => {
test('it should pass validation when supplied "comments" array', () => {
const inputPayload = {
...getCreateExceptionListItemSchemaMock(),
comments: getCreateCommentsArrayMock(),
@ -116,7 +116,7 @@ describe('create_exception_list_item_schema', () => {
expect(message.schema).toEqual(inputPayload);
});
test('it should NOT validate "comments" with "created_at" or "created_by" values', () => {
test('it should fail validation when supplied "comments" with "created_at" or "created_by" values', () => {
const inputPayload: Omit<CreateExceptionListItemSchema, 'comments'> & {
comments?: CommentsArray;
} = {
@ -126,11 +126,11 @@ describe('create_exception_list_item_schema', () => {
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(getPaths(left(message.errors))).toEqual(['invalid keys "created_at,created_by,id"']);
expect(message.schema).toEqual({});
});
test('it should NOT validate an undefined for "entries"', () => {
test('it should fail validation when supplied an undefined for "entries"', () => {
const inputPayload = getCreateExceptionListItemSchemaMock();
const outputPayload = getCreateExceptionListItemSchemaMock();
delete inputPayload.entries;
@ -145,7 +145,7 @@ describe('create_exception_list_item_schema', () => {
expect(message.schema).toEqual({});
});
test('it should validate an undefined for "namespace_type" but return enum "single" and generate a correct body not counting the auto generated uuid', () => {
test('it should pass validation when supplied 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;
@ -158,7 +158,7 @@ describe('create_exception_list_item_schema', () => {
expect(message.schema).toEqual(outputPayload);
});
test('it should validate an undefined for "tags" but return an array and generate a correct body not counting the auto generated uuid', () => {
test('it should pass validation when supplied 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;
@ -171,7 +171,7 @@ describe('create_exception_list_item_schema', () => {
expect(message.schema).toEqual(outputPayload);
});
test('it should validate an undefined for "_tags" but return an array and generate a correct body not counting the auto generated uuid', () => {
test('it should pass validation when supplied 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;
@ -184,7 +184,7 @@ describe('create_exception_list_item_schema', () => {
expect(message.schema).toEqual(outputPayload);
});
test('it should validate an undefined for "item_id" and auto generate a uuid', () => {
test('it should pass validation when supplied an undefined for "item_id" and auto generate a uuid', () => {
const inputPayload = getCreateExceptionListItemSchemaMock();
delete inputPayload.item_id;
const decoded = createExceptionListItemSchema.decode(inputPayload);
@ -196,7 +196,7 @@ describe('create_exception_list_item_schema', () => {
);
});
test('it should validate an undefined for "item_id" and generate a correct body not counting the uuid', () => {
test('it should pass validation when supplied 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);

View file

@ -0,0 +1,43 @@
/*
* 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 { getUpdateExceptionListItemSchemaMock } from './update_exception_list_item_schema.mock';
import { validateComments } from './update_exception_list_item_validation';
describe('update_exception_list_item_validation', () => {
describe('#validateComments', () => {
test('it returns no errors if comments is undefined', () => {
const payload = getUpdateExceptionListItemSchemaMock();
delete payload.comments;
const output = validateComments(payload);
expect(output).toEqual([]);
});
test('it returns no errors if new comments are append only', () => {
const payload = getUpdateExceptionListItemSchemaMock();
payload.comments = [
{ comment: 'Im an old comment', id: '1' },
{ comment: 'Im a new comment' },
];
const output = validateComments(payload);
expect(output).toEqual([]);
});
test('it returns error if comments are not append only', () => {
const payload = getUpdateExceptionListItemSchemaMock();
payload.comments = [
{ comment: 'Im an old comment', id: '1' },
{ comment: 'Im a new comment modifying the order of existing comments' },
{ comment: 'Im an old comment', id: '2' },
];
const output = validateComments(payload);
expect(output).toEqual(['item "comments" are append only']);
});
});
});

View file

@ -0,0 +1,40 @@
/*
* 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 { UpdateExceptionListItemSchema } from './update_exception_list_item_schema';
export const validateComments = (item: UpdateExceptionListItemSchema): string[] => {
if (item.comments == null) {
return [];
}
const [appendOnly] = item.comments.reduce(
(acc, comment) => {
const [, hasNewComments] = acc;
if (comment.id == null) {
return [true, true];
}
if (hasNewComments && comment.id != null) {
return [false, true];
}
return acc;
},
[true, false]
);
if (!appendOnly) {
return ['item "comments" are append only'];
} else {
return [];
}
};
export const updateExceptionListItemValidate = (
schema: UpdateExceptionListItemSchema
): string[] => {
return [...validateComments(schema)];
};

View file

@ -4,14 +4,15 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { DATE_NOW, USER } from '../../constants.mock';
import { DATE_NOW, ID, USER } from '../../constants.mock';
import { Comments, CommentsArray } from './comments';
import { Comment, CommentsArray } from './comment';
export const getCommentsMock = (): Comments => ({
export const getCommentsMock = (): Comment => ({
comment: 'some old comment',
created_at: DATE_NOW,
created_by: USER,
id: ID,
});
export const getCommentsArrayMock = (): CommentsArray => [getCommentsMock(), getCommentsMock()];

View file

@ -10,56 +10,79 @@ 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 { getCommentsArrayMock, getCommentsMock } from './comment.mock';
import {
Comments,
Comment,
CommentsArray,
CommentsArrayOrUndefined,
comments,
comment,
commentsArray,
commentsArrayOrUndefined,
} from './comments';
} from './comment';
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);
describe('Comment', () => {
describe('comment', () => {
test('it fails validation when "id" is undefined', () => {
const payload = { ...getCommentsMock(), id: undefined };
const decoded = comment.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 |}>)"',
'Invalid value "undefined" supplied to "id"',
]);
expect(message.schema).toEqual({});
});
test('it should not validate when "comment" is not a string', () => {
const payload: Omit<Comments, 'comment'> & { comment: string[] } = {
test('it passes validation with a typical comment', () => {
const payload = getCommentsMock();
const decoded = comment.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it passes validation with "updated_at" and "updated_by" fields included', () => {
const payload = getCommentsMock();
payload.updated_at = DATE_NOW;
payload.updated_by = 'someone';
const decoded = comment.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it fails validation when undefined', () => {
const payload = undefined;
const decoded = comment.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "undefined" supplied to "({| comment: NonEmptyString, created_at: string, created_by: string, id: NonEmptyString |} & Partial<{| updated_at: string, updated_by: string |}>)"',
'Invalid value "undefined" supplied to "({| comment: NonEmptyString, created_at: string, created_by: string, id: NonEmptyString |} & Partial<{| updated_at: string, updated_by: string |}>)"',
]);
expect(message.schema).toEqual({});
});
test('it fails validation when "comment" is an empty string', () => {
const payload: Omit<Comment, 'comment'> & { comment: string } = {
...getCommentsMock(),
comment: '',
};
const decoded = comment.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to "comment"']);
expect(message.schema).toEqual({});
});
test('it fails validation when "comment" is not a string', () => {
const payload: Omit<Comment, 'comment'> & { comment: string[] } = {
...getCommentsMock(),
comment: ['some value'],
};
const decoded = comments.decode(payload);
const decoded = comment.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
@ -68,12 +91,12 @@ describe('Comments', () => {
expect(message.schema).toEqual({});
});
test('it should not validate when "created_at" is not a string', () => {
const payload: Omit<Comments, 'created_at'> & { created_at: number } = {
test('it fails validation when "created_at" is not a string', () => {
const payload: Omit<Comment, 'created_at'> & { created_at: number } = {
...getCommentsMock(),
created_at: 1,
};
const decoded = comments.decode(payload);
const decoded = comment.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
@ -82,12 +105,12 @@ describe('Comments', () => {
expect(message.schema).toEqual({});
});
test('it should not validate when "created_by" is not a string', () => {
const payload: Omit<Comments, 'created_by'> & { created_by: number } = {
test('it fails validation when "created_by" is not a string', () => {
const payload: Omit<Comment, 'created_by'> & { created_by: number } = {
...getCommentsMock(),
created_by: 1,
};
const decoded = comments.decode(payload);
const decoded = comment.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
@ -96,12 +119,12 @@ describe('Comments', () => {
expect(message.schema).toEqual({});
});
test('it should not validate when "updated_at" is not a string', () => {
const payload: Omit<Comments, 'updated_at'> & { updated_at: number } = {
test('it fails validation when "updated_at" is not a string', () => {
const payload: Omit<Comment, 'updated_at'> & { updated_at: number } = {
...getCommentsMock(),
updated_at: 1,
};
const decoded = comments.decode(payload);
const decoded = comment.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
@ -110,12 +133,12 @@ describe('Comments', () => {
expect(message.schema).toEqual({});
});
test('it should not validate when "updated_by" is not a string', () => {
const payload: Omit<Comments, 'updated_by'> & { updated_by: number } = {
test('it fails validation when "updated_by" is not a string', () => {
const payload: Omit<Comment, 'updated_by'> & { updated_by: number } = {
...getCommentsMock(),
updated_by: 1,
};
const decoded = comments.decode(payload);
const decoded = comment.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
@ -125,11 +148,11 @@ describe('Comments', () => {
});
test('it should strip out extra keys', () => {
const payload: Comments & {
const payload: Comment & {
extraKey?: string;
} = getCommentsMock();
payload.extraKey = 'some value';
const decoded = comments.decode(payload);
const decoded = comment.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
@ -138,7 +161,7 @@ describe('Comments', () => {
});
describe('commentsArray', () => {
test('it should validate an array of comments', () => {
test('it passes validation an array of Comment', () => {
const payload = getCommentsArrayMock();
const decoded = commentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
@ -147,7 +170,7 @@ describe('Comments', () => {
expect(message.schema).toEqual(payload);
});
test('it should validate when a comments includes "updated_at" and "updated_by"', () => {
test('it passes validation when a Comment includes "updated_at" and "updated_by"', () => {
const commentsPayload = getCommentsMock();
commentsPayload.updated_at = DATE_NOW;
commentsPayload.updated_by = 'someone';
@ -159,32 +182,32 @@ describe('Comments', () => {
expect(message.schema).toEqual(payload);
});
test('it should not validate when undefined', () => {
test('it fails validation 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 |}>)>"',
'Invalid value "undefined" supplied to "Array<({| comment: NonEmptyString, created_at: string, created_by: string, id: NonEmptyString |} & Partial<{| updated_at: string, updated_by: string |}>)>"',
]);
expect(message.schema).toEqual({});
});
test('it should not validate when array includes non comments types', () => {
test('it fails validation when array includes non Comment 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 |}>)>"',
'Invalid value "1" supplied to "Array<({| comment: NonEmptyString, created_at: string, created_by: string, id: NonEmptyString |} & Partial<{| updated_at: string, updated_by: string |}>)>"',
'Invalid value "1" supplied to "Array<({| comment: NonEmptyString, created_at: string, created_by: string, id: NonEmptyString |} & Partial<{| updated_at: string, updated_by: string |}>)>"',
]);
expect(message.schema).toEqual({});
});
});
describe('commentsArrayOrUndefined', () => {
test('it should validate an array of comments', () => {
test('it passes validation an array of Comment', () => {
const payload = getCommentsArrayMock();
const decoded = commentsArrayOrUndefined.decode(payload);
const message = pipe(decoded, foldLeftRight);
@ -193,7 +216,7 @@ describe('Comments', () => {
expect(message.schema).toEqual(payload);
});
test('it should validate when undefined', () => {
test('it passes validation when undefined', () => {
const payload = undefined;
const decoded = commentsArrayOrUndefined.decode(payload);
const message = pipe(decoded, foldLeftRight);
@ -202,14 +225,14 @@ describe('Comments', () => {
expect(message.schema).toEqual(payload);
});
test('it should not validate when array includes non comments types', () => {
test('it fails validation when array includes non Comment 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 |}>)>"',
'Invalid value "1" supplied to "Array<({| comment: NonEmptyString, created_at: string, created_by: string, id: NonEmptyString |} & Partial<{| updated_at: string, updated_by: string |}>)>"',
'Invalid value "1" supplied to "Array<({| comment: NonEmptyString, created_at: string, created_by: string, id: NonEmptyString |} & Partial<{| updated_at: string, updated_by: string |}>)>"',
]);
expect(message.schema).toEqual({});
});

View file

@ -3,26 +3,33 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
/* eslint-disable @typescript-eslint/camelcase */
import * as t from 'io-ts';
export const comments = t.intersection([
import { NonEmptyString } from '../../siem_common_deps';
import { created_at, created_by, id, updated_at, updated_by } from '../common/schemas';
export const comment = t.intersection([
t.exact(
t.type({
comment: t.string,
created_at: t.string, // TODO: Make this into an ISO Date string check,
created_by: t.string,
comment: NonEmptyString,
created_at,
created_by,
id,
})
),
t.exact(
t.partial({
updated_at: t.string,
updated_by: t.string,
updated_at,
updated_by,
})
),
]);
export const commentsArray = t.array(comments);
export const commentsArray = t.array(comment);
export type CommentsArray = t.TypeOf<typeof commentsArray>;
export type Comments = t.TypeOf<typeof comments>;
export type Comment = t.TypeOf<typeof comment>;
export const commentsArrayOrUndefined = t.union([commentsArray, t.undefined]);
export type CommentsArrayOrUndefined = t.TypeOf<typeof commentsArrayOrUndefined>;

View file

@ -3,9 +3,9 @@
* 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';
import { CreateComment, CreateCommentsArray } from './create_comment';
export const getCreateCommentsMock = (): CreateComments => ({
export const getCreateCommentsMock = (): CreateComment => ({
comment: 'some comments',
});

View file

@ -9,44 +9,44 @@ import { left } from 'fp-ts/lib/Either';
import { foldLeftRight, getPaths } from '../../siem_common_deps';
import { getCreateCommentsArrayMock, getCreateCommentsMock } from './create_comments.mock';
import { getCreateCommentsArrayMock, getCreateCommentsMock } from './create_comment.mock';
import {
CreateComments,
CreateComment,
CreateCommentsArray,
CreateCommentsArrayOrUndefined,
createComments,
createComment,
createCommentsArray,
createCommentsArrayOrUndefined,
} from './create_comments';
} from './create_comment';
describe('CreateComments', () => {
describe('createComments', () => {
test('it should validate a comments', () => {
describe('CreateComment', () => {
describe('createComment', () => {
test('it passes validation with a default comment', () => {
const payload = getCreateCommentsMock();
const decoded = createComments.decode(payload);
const decoded = createComment.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', () => {
test('it fails validation when undefined', () => {
const payload = undefined;
const decoded = createComments.decode(payload);
const decoded = createComment.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "undefined" supplied to "{| comment: string |}"',
'Invalid value "undefined" supplied to "{| comment: NonEmptyString |}"',
]);
expect(message.schema).toEqual({});
});
test('it should not validate when "comment" is not a string', () => {
const payload: Omit<CreateComments, 'comment'> & { comment: string[] } = {
test('it fails validation when "comment" is not a string', () => {
const payload: Omit<CreateComment, 'comment'> & { comment: string[] } = {
...getCreateCommentsMock(),
comment: ['some value'],
};
const decoded = createComments.decode(payload);
const decoded = createComment.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
@ -56,11 +56,11 @@ describe('CreateComments', () => {
});
test('it should strip out extra keys', () => {
const payload: CreateComments & {
const payload: CreateComment & {
extraKey?: string;
} = getCreateCommentsMock();
payload.extraKey = 'some value';
const decoded = createComments.decode(payload);
const decoded = createComment.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
@ -69,7 +69,7 @@ describe('CreateComments', () => {
});
describe('createCommentsArray', () => {
test('it should validate an array of comments', () => {
test('it passes validation an array of comments', () => {
const payload = getCreateCommentsArrayMock();
const decoded = createCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
@ -78,31 +78,31 @@ describe('CreateComments', () => {
expect(message.schema).toEqual(payload);
});
test('it should not validate when undefined', () => {
test('it fails validation 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 |}>"',
'Invalid value "undefined" supplied to "Array<{| comment: NonEmptyString |}>"',
]);
expect(message.schema).toEqual({});
});
test('it should not validate when array includes non comments types', () => {
test('it fails validation 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 |}>"',
'Invalid value "1" supplied to "Array<{| comment: NonEmptyString |}>"',
]);
expect(message.schema).toEqual({});
});
});
describe('createCommentsArrayOrUndefined', () => {
test('it should validate an array of comments', () => {
test('it passes validation an array of comments', () => {
const payload = getCreateCommentsArrayMock();
const decoded = createCommentsArrayOrUndefined.decode(payload);
const message = pipe(decoded, foldLeftRight);
@ -111,7 +111,7 @@ describe('CreateComments', () => {
expect(message.schema).toEqual(payload);
});
test('it should validate when undefined', () => {
test('it passes validation when undefined', () => {
const payload = undefined;
const decoded = createCommentsArrayOrUndefined.decode(payload);
const message = pipe(decoded, foldLeftRight);
@ -120,13 +120,13 @@ describe('CreateComments', () => {
expect(message.schema).toEqual(payload);
});
test('it should not validate when array includes non comments types', () => {
test('it fails validation 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 |}>"',
'Invalid value "1" supplied to "Array<{| comment: NonEmptyString |}>"',
]);
expect(message.schema).toEqual({});
});

View file

@ -5,14 +5,17 @@
*/
import * as t from 'io-ts';
export const createComments = t.exact(
import { NonEmptyString } from '../../siem_common_deps';
export const createComment = t.exact(
t.type({
comment: t.string,
comment: NonEmptyString,
})
);
export const createCommentsArray = t.array(createComments);
export type CreateComment = t.TypeOf<typeof createComment>;
export const createCommentsArray = t.array(createComment);
export type CreateCommentsArray = t.TypeOf<typeof createCommentsArray>;
export type CreateComments = t.TypeOf<typeof createComments>;
export type CreateComments = t.TypeOf<typeof createComment>;
export const createCommentsArrayOrUndefined = t.union([createCommentsArray, t.undefined]);
export type CreateCommentsArrayOrUndefined = t.TypeOf<typeof createCommentsArrayOrUndefined>;

View file

@ -10,11 +10,11 @@ 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';
import { CommentsArray } from './comment';
import { getCommentsArrayMock } from './comment.mock';
describe('default_comments_array', () => {
test('it should validate an empty array', () => {
test('it should pass validation when supplied an empty array', () => {
const payload: CommentsArray = [];
const decoded = DefaultCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
@ -23,7 +23,7 @@ describe('default_comments_array', () => {
expect(message.schema).toEqual(payload);
});
test('it should validate an array of comments', () => {
test('it should pass validation when supplied an array of comments', () => {
const payload: CommentsArray = getCommentsArrayMock();
const decoded = DefaultCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
@ -32,27 +32,26 @@ describe('default_comments_array', () => {
expect(message.schema).toEqual(payload);
});
test('it should NOT validate an array of numbers', () => {
test('it should fail validation when supplied 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 |}>)>"',
'Invalid value "1" supplied to "Array<({| comment: NonEmptyString, created_at: string, created_by: string, id: NonEmptyString |} & Partial<{| updated_at: string, updated_by: string |}>)>"',
'Invalid value "1" supplied to "Array<({| comment: NonEmptyString, created_at: string, created_by: string, id: NonEmptyString |} & Partial<{| updated_at: string, updated_by: string |}>)>"',
]);
expect(message.schema).toEqual({});
});
test('it should NOT validate an array of strings', () => {
test('it should fail validation when supplied 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 |}>)>"',
'Invalid value "some string" supplied to "Array<({| comment: NonEmptyString, created_at: string, created_by: string, id: NonEmptyString |} & Partial<{| updated_at: string, updated_by: string |}>)>"',
'Invalid value "some string" supplied to "Array<({| comment: NonEmptyString, created_at: string, created_by: string, id: NonEmptyString |} & Partial<{| updated_at: string, updated_by: string |}>)>"',
]);
expect(message.schema).toEqual({});
});

View file

@ -7,7 +7,7 @@
import * as t from 'io-ts';
import { Either } from 'fp-ts/lib/Either';
import { CommentsArray, comments } from './comments';
import { CommentsArray, comment } from './comment';
/**
* Types the DefaultCommentsArray as:
@ -15,8 +15,8 @@ import { CommentsArray, comments } from './comments';
*/
export const DefaultCommentsArray = new t.Type<CommentsArray, CommentsArray, unknown>(
'DefaultCommentsArray',
t.array(comments).is,
t.array(comment).is,
(input): Either<t.Errors, CommentsArray> =>
input == null ? t.success([]) : t.array(comments).decode(input),
input == null ? t.success([]) : t.array(comment).decode(input),
t.identity
);

View file

@ -10,11 +10,12 @@ 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';
import { CreateCommentsArray } from './create_comment';
import { getCreateCommentsArrayMock } from './create_comment.mock';
import { getCommentsArrayMock } from './comment.mock';
describe('default_create_comments_array', () => {
test('it should validate an empty array', () => {
test('it should pass validation when an empty array', () => {
const payload: CreateCommentsArray = [];
const decoded = DefaultCreateCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
@ -23,7 +24,7 @@ describe('default_create_comments_array', () => {
expect(message.schema).toEqual(payload);
});
test('it should validate an array of comments', () => {
test('it should pass validation when an array of comments', () => {
const payload: CreateCommentsArray = getCreateCommentsArrayMock();
const decoded = DefaultCreateCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
@ -32,25 +33,38 @@ describe('default_create_comments_array', () => {
expect(message.schema).toEqual(payload);
});
test('it should NOT validate an array of numbers', () => {
test('it should strip out "created_at" and "created_by" if they are passed in', () => {
const payload = getCommentsArrayMock();
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([]);
expect(message.schema).toEqual([
{ comment: 'some old comment' },
{ comment: 'some old comment' },
]);
});
test('it should not pass validation when 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 |}>"',
'Invalid value "1" supplied to "Array<{| comment: NonEmptyString |}>"',
]);
expect(message.schema).toEqual({});
});
test('it should NOT validate an array of strings', () => {
test('it should not pass validation when 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 |}>"',
'Invalid value "some string" supplied to "Array<{| comment: NonEmptyString |}>"',
]);
expect(message.schema).toEqual({});
});

View file

@ -7,7 +7,7 @@
import * as t from 'io-ts';
import { Either } from 'fp-ts/lib/Either';
import { CreateCommentsArray, createComments } from './create_comments';
import { CreateCommentsArray, createComment } from './create_comment';
/**
* Types the DefaultCreateComments as:
@ -19,8 +19,8 @@ export const DefaultCreateCommentsArray = new t.Type<
unknown
>(
'DefaultCreateComments',
t.array(createComments).is,
t.array(createComment).is,
(input): Either<t.Errors, CreateCommentsArray> =>
input == null ? t.success([]) : t.array(createComments).decode(input),
input == null ? t.success([]) : t.array(createComment).decode(input),
t.identity
);

View file

@ -10,11 +10,11 @@ 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';
import { UpdateCommentsArray } from './update_comment';
import { getUpdateCommentsArrayMock } from './update_comment.mock';
describe('default_update_comments_array', () => {
test('it should validate an empty array', () => {
test('it should pass validation when supplied an empty array', () => {
const payload: UpdateCommentsArray = [];
const decoded = DefaultUpdateCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
@ -23,7 +23,7 @@ describe('default_update_comments_array', () => {
expect(message.schema).toEqual(payload);
});
test('it should validate an array of comments', () => {
test('it should pass validation when supplied an array of comments', () => {
const payload: UpdateCommentsArray = getUpdateCommentsArrayMock();
const decoded = DefaultUpdateCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
@ -32,29 +32,26 @@ describe('default_update_comments_array', () => {
expect(message.schema).toEqual(payload);
});
test('it should NOT validate an array of numbers', () => {
test('it should fail validation when supplied 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 |})>"',
'Invalid value "1" supplied to "Array<({| comment: NonEmptyString |} & Partial<{| id: NonEmptyString |}>)>"',
'Invalid value "1" supplied to "Array<({| comment: NonEmptyString |} & Partial<{| id: NonEmptyString |}>)>"',
]);
expect(message.schema).toEqual({});
});
test('it should NOT validate an array of strings', () => {
test('it should fail validation when supplied 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 |})>"',
'Invalid value "some string" supplied to "Array<({| comment: NonEmptyString |} & Partial<{| id: NonEmptyString |}>)>"',
'Invalid value "some string" supplied to "Array<({| comment: NonEmptyString |} & Partial<{| id: NonEmptyString |}>)>"',
]);
expect(message.schema).toEqual({});
});

View file

@ -7,7 +7,7 @@
import * as t from 'io-ts';
import { Either } from 'fp-ts/lib/Either';
import { UpdateCommentsArray, updateCommentsArray } from './update_comments';
import { UpdateCommentsArray, updateCommentsArray } from './update_comment';
/**
* Types the DefaultCommentsUpdate as:

View file

@ -3,9 +3,9 @@
* 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 './comments';
export * from './create_comments';
export * from './update_comments';
export * from './comment';
export * from './create_comment';
export * from './update_comment';
export * from './default_comments_array';
export * from './default_create_comments_array';
export * from './default_update_comments_array';

View file

@ -4,11 +4,16 @@
* 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';
import { ID } from '../../constants.mock';
import { UpdateComment, UpdateCommentsArray } from './update_comment';
export const getUpdateCommentMock = (): UpdateComment => ({
comment: 'some comment',
id: ID,
});
export const getUpdateCommentsArrayMock = (): UpdateCommentsArray => [
getCommentsMock(),
getCreateCommentsMock(),
getUpdateCommentMock(),
getUpdateCommentMock(),
];

View file

@ -0,0 +1,150 @@
/*
* 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 { getUpdateCommentMock, getUpdateCommentsArrayMock } from './update_comment.mock';
import {
UpdateComment,
UpdateCommentsArray,
UpdateCommentsArrayOrUndefined,
updateComment,
updateCommentsArray,
updateCommentsArrayOrUndefined,
} from './update_comment';
describe('CommentsUpdate', () => {
describe('updateComment', () => {
test('it should pass validation when supplied typical comment update', () => {
const payload = getUpdateCommentMock();
const decoded = updateComment.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should fail validation when supplied an undefined for "comment"', () => {
const payload = getUpdateCommentMock();
delete payload.comment;
const decoded = updateComment.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "undefined" supplied to "comment"',
]);
expect(message.schema).toEqual({});
});
test('it should fail validation when supplied an empty string for "comment"', () => {
const payload = { ...getUpdateCommentMock(), comment: '' };
const decoded = updateComment.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to "comment"']);
expect(message.schema).toEqual({});
});
test('it should pass validation when supplied an undefined for "id"', () => {
const payload = getUpdateCommentMock();
delete payload.id;
const decoded = updateComment.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should fail validation when supplied an empty string for "id"', () => {
const payload = { ...getUpdateCommentMock(), id: '' };
const decoded = updateComment.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to "id"']);
expect(message.schema).toEqual({});
});
test('it should strip out extra key passed in', () => {
const payload: UpdateComment & {
extraKey?: string;
} = { ...getUpdateCommentMock(), extraKey: 'some new value' };
const decoded = updateComment.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(getUpdateCommentMock());
});
});
describe('updateCommentsArray', () => {
test('it should pass validation when supplied 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 fail validation 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: NonEmptyString |} & Partial<{| id: NonEmptyString |}>)>"',
]);
expect(message.schema).toEqual({});
});
test('it should fail validation 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: NonEmptyString |} & Partial<{| id: NonEmptyString |}>)>"',
'Invalid value "1" supplied to "Array<({| comment: NonEmptyString |} & Partial<{| id: NonEmptyString |}>)>"',
]);
expect(message.schema).toEqual({});
});
});
describe('updateCommentsArrayOrUndefined', () => {
test('it should pass validation when supplied 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 pass validation when supplied 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 fail validation 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: NonEmptyString |} & Partial<{| id: NonEmptyString |}>)>"',
'Invalid value "1" supplied to "Array<({| comment: NonEmptyString |} & Partial<{| id: NonEmptyString |}>)>"',
]);
expect(message.schema).toEqual({});
});
});
});

View file

@ -5,10 +5,24 @@
*/
import * as t from 'io-ts';
import { comments } from './comments';
import { createComments } from './create_comments';
import { NonEmptyString } from '../../siem_common_deps';
import { id } from '../common/schemas';
export const updateCommentsArray = t.array(t.union([comments, createComments]));
export const updateComment = t.intersection([
t.exact(
t.type({
comment: NonEmptyString,
})
),
t.exact(
t.partial({
id,
})
),
]);
export type UpdateComment = t.TypeOf<typeof updateComment>;
export const updateCommentsArray = t.array(updateComment);
export type UpdateCommentsArray = t.TypeOf<typeof updateCommentsArray>;
export const updateCommentsArrayOrUndefined = t.union([updateCommentsArray, t.undefined]);
export type UpdateCommentsArrayOrUndefined = t.TypeOf<typeof updateCommentsArrayOrUndefined>;

View file

@ -1,108 +0,0 @@
/*
* 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({});
});
});
});

View file

@ -8,8 +8,8 @@ export {
ListSchema,
CommentsArray,
CreateCommentsArray,
Comments,
CreateComments,
Comment,
CreateComment,
ExceptionListSchema,
ExceptionListItemSchema,
CreateExceptionListSchema,
@ -28,6 +28,7 @@ export {
OperatorType,
OperatorTypeEnum,
ExceptionListTypeEnum,
comment,
exceptionListItemSchema,
exceptionListType,
createExceptionListItemSchema,

View file

@ -14,6 +14,7 @@ import {
exceptionListItemSchema,
updateExceptionListItemSchema,
} from '../../common/schemas';
import { updateExceptionListItemValidate } from '../../common/schemas/request/update_exception_list_item_validation';
import { getExceptionListClient } from '.';
@ -33,6 +34,11 @@ export const updateExceptionListItemRoute = (router: IRouter): void => {
},
async (context, request, response) => {
const siemResponse = buildSiemResponse(response);
const validationErrors = updateExceptionListItemValidate(request.body);
if (validationErrors.length) {
return siemResponse.error({ body: validationErrors, statusCode: 400 });
}
try {
const {
description,

View file

@ -83,6 +83,9 @@ export const exceptionListItemMapping: SavedObjectsType['mappings'] = {
created_by: {
type: 'keyword',
},
id: {
type: 'keyword',
},
updated_at: {
type: 'keyword',
},

View file

@ -1,17 +1,18 @@
{
"item_id": "simple_list_item",
"_tags": ["endpoint", "process", "malware", "os:windows"],
"tags": ["user added string for a tag", "malware"],
"type": "simple",
"description": "This is a sample change here this list",
"name": "Sample Endpoint Exception List update change",
"comments": [{ "comment": "this is a newly added comment" }],
"_tags": ["detection"],
"comments": [],
"description": "Test comments - exception list item",
"entries": [
{
"field": "event.category",
"operator": "included",
"type": "match_any",
"value": ["process", "malware"]
"field": "host.name",
"type": "match",
"value": "rock01",
"operator": "included"
}
]
],
"item_id": "993f43f7-325d-4df3-9338-964e77c37053",
"name": "Test comments - exception list item",
"namespace_type": "single",
"tags": [],
"type": "simple"
}

View file

@ -64,7 +64,10 @@ export const createExceptionListItem = async ({
}: CreateExceptionListItemOptions): Promise<ExceptionListItemSchema> => {
const savedObjectType = getSavedObjectType({ namespaceType });
const dateNow = new Date().toISOString();
const transformedComments = transformCreateCommentsToComments({ comments, user });
const transformedComments = transformCreateCommentsToComments({
incomingComments: comments,
user,
});
const savedObject = await savedObjectsClient.create<ExceptionListSoSchema>(savedObjectType, {
_tags,
comments: transformedComments,

View file

@ -5,15 +5,11 @@
*/
import sinon from 'sinon';
import moment from 'moment';
import uuid from 'uuid';
import { USER } from '../../../common/constants.mock';
import { transformCreateCommentsToComments, transformUpdateCommentsToComments } from './utils';
import {
isCommentEqual,
transformCreateCommentsToComments,
transformUpdateComments,
transformUpdateCommentsToComments,
} from './utils';
jest.mock('uuid/v4');
describe('utils', () => {
const oldDate = '2020-03-17T20:34:51.337Z';
@ -22,32 +18,43 @@ describe('utils', () => {
let clock: sinon.SinonFakeTimers;
beforeEach(() => {
((uuid.v4 as unknown) as jest.Mock)
.mockImplementationOnce(() => '123')
.mockImplementationOnce(() => '456');
clock = sinon.useFakeTimers(unix);
});
afterEach(() => {
clock.restore();
jest.clearAllMocks();
jest.restoreAllMocks();
jest.resetAllMocks();
});
describe('#transformUpdateCommentsToComments', () => {
test('it returns empty array if "comments" is undefined and no comments exist', () => {
test('it formats new comments', () => {
const comments = transformUpdateCommentsToComments({
comments: undefined,
comments: [{ comment: 'Im a new comment' }],
existingComments: [],
user: 'lily',
});
expect(comments).toEqual([]);
expect(comments).toEqual([
{
comment: 'Im a new comment',
created_at: dateNow,
created_by: 'lily',
id: '123',
},
]);
});
test('it formats newly added comments', () => {
test('it formats new comments and preserves existing comments', () => {
const comments = transformUpdateCommentsToComments({
comments: [
{ comment: 'Im an old comment', created_at: oldDate, created_by: 'bane' },
{ comment: 'Im a new comment' },
],
comments: [{ comment: 'Im an old comment', id: '1' }, { comment: 'Im a new comment' }],
existingComments: [
{ comment: 'Im an old comment', created_at: oldDate, created_by: 'bane' },
{ comment: 'Im an old comment', created_at: oldDate, created_by: 'bane', id: '1' },
],
user: 'lily',
});
@ -57,24 +64,22 @@ describe('utils', () => {
comment: 'Im an old comment',
created_at: oldDate,
created_by: 'bane',
id: '1',
},
{
comment: 'Im a new comment',
created_at: dateNow,
created_by: 'lily',
id: '123',
},
]);
});
test('it formats multiple newly added comments', () => {
test('it returns existing comments if empty array passed for "comments"', () => {
const comments = transformUpdateCommentsToComments({
comments: [
{ comment: 'Im an old comment', created_at: oldDate, created_by: 'lily' },
{ comment: 'Im a new comment' },
{ comment: 'Im another new comment' },
],
comments: [],
existingComments: [
{ comment: 'Im an old comment', created_at: oldDate, created_by: 'lily' },
{ comment: 'Im an old comment', created_at: oldDate, created_by: 'bane', id: '1' },
],
user: 'lily',
});
@ -83,26 +88,17 @@ describe('utils', () => {
{
comment: 'Im an old comment',
created_at: oldDate,
created_by: 'lily',
},
{
comment: 'Im a new comment',
created_at: dateNow,
created_by: 'lily',
},
{
comment: 'Im another new comment',
created_at: dateNow,
created_by: 'lily',
created_by: 'bane',
id: '1',
},
]);
});
test('it should not throw if comments match existing comments', () => {
test('it acts as append only, only modifying new comments', () => {
const comments = transformUpdateCommentsToComments({
comments: [{ comment: 'Im an old comment', created_at: oldDate, created_by: 'lily' }],
comments: [{ comment: 'Im a new comment' }],
existingComments: [
{ comment: 'Im an old comment', created_at: oldDate, created_by: 'lily' },
{ comment: 'Im an old comment', created_at: oldDate, created_by: 'bane', id: '1' },
],
user: 'lily',
});
@ -111,170 +107,23 @@ describe('utils', () => {
{
comment: 'Im an old comment',
created_at: oldDate,
created_by: 'lily',
created_by: 'bane',
id: '1',
},
]);
});
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: oldDate,
created_by: 'lily',
},
],
existingComments: [
{ comment: 'Im an old comment', created_at: oldDate, created_by: 'lily' },
],
user: 'lily',
});
expect(comments).toEqual([
{
comment: 'Im an old comment that is trying to be updated',
created_at: oldDate,
comment: 'Im a new comment',
created_at: dateNow,
created_by: 'lily',
updated_at: dateNow,
updated_by: 'lily',
id: '123',
},
]);
});
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: oldDate, 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: oldDate, 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: dateNow, created_by: 'lily' }],
existingComments: [
{ comment: 'Im an old comment', created_at: oldDate, 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: oldDate, created_by: 'lily' }],
existingComments: [
{ comment: 'Im an old comment', created_at: oldDate, 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: oldDate,
created_by: 'lily',
},
],
existingComments: [
{ comment: 'Im an old comment', created_at: oldDate, 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: oldDate, created_by: 'lily' },
],
existingComments: [
{ comment: 'Im an old comment', created_at: oldDate, 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: oldDate, 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: oldDate, created_by: 'lily' },
{ comment: ' ' },
],
existingComments: [
{ comment: 'Im an old comment', created_at: oldDate, 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' }],
incomingComments: [{ comment: 'Im a new comment' }, { comment: 'Im another new comment' }],
user: 'lily',
});
@ -283,178 +132,15 @@ describe('utils', () => {
comment: 'Im a new comment',
created_at: dateNow,
created_by: 'lily',
id: '123',
},
{
comment: 'Im another new comment',
created_at: dateNow,
created_by: 'lily',
id: '456',
},
]);
});
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" if content differs', () => {
const comments = transformUpdateComments({
comment: {
comment: 'Im an old comment that is trying to be updated',
created_at: oldDate,
created_by: 'lily',
},
existingComment: {
comment: 'Im an old comment',
created_at: oldDate,
created_by: 'lily',
},
user: 'lily',
});
expect(comments).toEqual({
comment: 'Im an old comment that is trying to be updated',
created_at: oldDate,
created_by: 'lily',
updated_at: dateNow,
updated_by: 'lily',
});
});
test('it does not update comment and add "updated_at" and "updated_by" if content is the same', () => {
const comments = transformUpdateComments({
comment: {
comment: 'Im an old comment ',
created_at: oldDate,
created_by: 'lily',
},
existingComment: {
comment: 'Im an old comment',
created_at: oldDate,
created_by: 'lily',
},
user: 'lily',
});
expect(comments).toEqual({
comment: 'Im an old comment',
created_at: oldDate,
created_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: oldDate,
created_by: 'lily',
},
existingComment: {
comment: 'Im an old comment',
created_at: oldDate,
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',
created_at: dateNow,
created_by: 'lily',
},
existingComment: {
comment: 'Im an old comment',
created_at: oldDate,
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: oldDate,
created_by: USER,
},
{
comment: 'some older comment',
created_at: oldDate,
created_by: USER,
}
);
expect(result).toBeFalsy();
});
test('it returns false if "created_at" values differ', () => {
const result = isCommentEqual(
{
comment: 'some old comment',
created_at: oldDate,
created_by: USER,
},
{
comment: 'some old comment',
created_at: dateNow,
created_by: USER,
}
);
expect(result).toBeFalsy();
});
test('it returns false if "created_by" values differ', () => {
const result = isCommentEqual(
{
comment: 'some old comment',
created_at: oldDate,
created_by: USER,
},
{
comment: 'some old comment',
created_at: oldDate,
created_by: 'lily',
}
);
expect(result).toBeFalsy();
});
test('it returns true if comment values are equivalent', () => {
const result = isCommentEqual(
{
comment: 'some old comment',
created_at: oldDate,
created_by: USER,
},
{
created_at: oldDate,
created_by: USER,
// Disabling to assure that order doesn't matter
// eslint-disable-next-line sort-keys
comment: 'some old comment',
}
);
expect(result).toBeTruthy();
});
});
});

View file

@ -3,17 +3,14 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import uuid from 'uuid';
import { SavedObject, SavedObjectsFindResponse, SavedObjectsUpdateResponse } from 'kibana/server';
import { NamespaceTypeArray } from '../../../common/schemas/types/default_namespace_array';
import { ErrorWithStatusCode } from '../../error_with_status_code';
import {
Comments,
CommentsArray,
CommentsArrayOrUndefined,
CreateComments,
CreateCommentsArrayOrUndefined,
CreateComment,
CreateCommentsArray,
ExceptionListItemSchema,
ExceptionListSchema,
ExceptionListSoSchema,
@ -21,7 +18,6 @@ import {
FoundExceptionListSchema,
NamespaceType,
UpdateCommentsArrayOrUndefined,
comments as commentsSchema,
exceptionListItemType,
exceptionListType,
} from '../../../common/schemas';
@ -296,17 +292,6 @@ export const transformSavedObjectsToFoundExceptionList = ({
};
};
/*
* 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,
@ -316,90 +301,28 @@ export const transformUpdateCommentsToComments = ({
existingComments: CommentsArray;
user: string;
}): CommentsArray => {
const newComments = comments ?? [];
const incomingComments = comments ?? [];
const newComments = incomingComments.filter((comment) => comment.id == null);
const newCommentsFormatted = transformCreateCommentsToComments({
incomingComments: newComments,
user,
});
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 (existingComment == null && commentsSchema.is(c)) {
throw new ErrorWithStatusCode('Only new comments may be added', 403);
} else if (
commentsSchema.is(c) &&
existingComment != null &&
isCommentEqual(c, existingComment)
) {
return existingComment;
} else if (commentsSchema.is(c) && existingComment != null) {
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 if (comment.comment.trim() !== existingComment.comment) {
const dateNow = new Date().toISOString();
return {
...existingComment,
comment: comment.comment,
updated_at: dateNow,
updated_by: user,
};
} else {
return existingComment;
}
return [...existingComments, ...newCommentsFormatted];
};
export const transformCreateCommentsToComments = ({
comments,
incomingComments,
user,
}: {
comments: CreateCommentsArrayOrUndefined;
incomingComments: CreateCommentsArray;
user: string;
}): CommentsArrayOrUndefined => {
}): CommentsArray => {
const dateNow = new Date().toISOString();
if (comments != null) {
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;
}
return incomingComments.map((comment: CreateComment) => ({
comment: comment.comment,
created_at: dateNow,
created_by: user,
id: uuid.v4(),
}));
};

View file

@ -8,8 +8,8 @@ export {
ListSchema,
CommentsArray,
CreateCommentsArray,
Comments,
CreateComments,
Comment,
CreateComment,
ExceptionListSchema,
ExceptionListItemSchema,
CreateExceptionListSchema,
@ -30,6 +30,7 @@ export {
ExceptionListTypeEnum,
exceptionListItemSchema,
exceptionListType,
comment,
createExceptionListItemSchema,
listSchema,
entry,

View file

@ -16,13 +16,13 @@ import {
EuiCommentProps,
EuiText,
} from '@elastic/eui';
import { Comments } from '../../../lists_plugin_deps';
import { Comment } from '../../../shared_imports';
import * as i18n from './translations';
import { useCurrentUser } from '../../lib/kibana';
import { getFormattedComments } from './helpers';
interface AddExceptionCommentsProps {
exceptionItemComments?: Comments[];
exceptionItemComments?: Comment[];
newCommentValue: string;
newCommentOnChange: (value: string) => void;
}

View file

@ -38,7 +38,7 @@ import { useSignalIndex } from '../../../../detections/containers/detection_engi
import { useFetchOrCreateRuleExceptionList } from '../use_fetch_or_create_rule_exception_list';
import { AddExceptionComments } from '../add_exception_comments';
import {
enrichExceptionItemsWithComments,
enrichNewExceptionItemsWithComments,
enrichExceptionItemsWithOS,
defaultEndpointExceptionItems,
entryHasListType,
@ -251,7 +251,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({
let enriched: Array<ExceptionListItemSchema | CreateExceptionListItemSchema> = [];
enriched =
comment !== ''
? enrichExceptionItemsWithComments(exceptionItemsToAdd, [{ comment }])
? enrichNewExceptionItemsWithComments(exceptionItemsToAdd, [{ comment }])
: exceptionItemsToAdd;
if (exceptionListType === 'endpoint') {
const osTypes = retrieveAlertOsTypes();

View file

@ -392,7 +392,7 @@ export const ExceptionBuilder = ({
)}
<EuiFlexItem grow={1}>
<BuilderButtonOptions
isOrDisabled={disableOr}
isOrDisabled={isOrDisabled ? isOrDisabled : disableOr}
isAndDisabled={disableAnd}
isNestedDisabled={disableNested}
isNested={addNested}

View file

@ -19,6 +19,7 @@ import {
EuiSpacer,
EuiFormRow,
EuiText,
EuiCallOut,
} from '@elastic/eui';
import { useFetchIndexPatterns } from '../../../../detections/containers/detection_engine/rules';
import { useSignalIndex } from '../../../../detections/containers/detection_engine/alerts/use_signal_index';
@ -34,7 +35,7 @@ import { ExceptionBuilder } from '../builder';
import { useAddOrUpdateException } from '../use_add_exception';
import { AddExceptionComments } from '../add_exception_comments';
import {
enrichExceptionItemsWithComments,
enrichExistingExceptionItemWithComments,
enrichExceptionItemsWithOS,
getOperatingSystems,
entryHasListType,
@ -88,6 +89,7 @@ export const EditExceptionModal = memo(function EditExceptionModal({
}: EditExceptionModalProps) {
const { http } = useKibana().services;
const [comment, setComment] = useState('');
const [hasVersionConflict, setHasVersionConflict] = useState(false);
const [shouldBulkCloseAlert, setShouldBulkCloseAlert] = useState(false);
const [shouldDisableBulkClose, setShouldDisableBulkClose] = useState(false);
const [exceptionItemsToAdd, setExceptionItemsToAdd] = useState<
@ -106,8 +108,12 @@ export const EditExceptionModal = memo(function EditExceptionModal({
const onError = useCallback(
(error) => {
addError(error, { title: i18n.EDIT_EXCEPTION_ERROR });
onCancel();
if (error.message.includes('Conflict')) {
setHasVersionConflict(true);
} else {
addError(error, { title: i18n.EDIT_EXCEPTION_ERROR });
onCancel();
}
},
[addError, onCancel]
);
@ -147,8 +153,8 @@ export const EditExceptionModal = memo(function EditExceptionModal({
}, [shouldDisableBulkClose]);
const isSubmitButtonDisabled = useMemo(
() => exceptionItemsToAdd.every((item) => item.entries.length === 0),
[exceptionItemsToAdd]
() => exceptionItemsToAdd.every((item) => item.entries.length === 0) || hasVersionConflict,
[exceptionItemsToAdd, hasVersionConflict]
);
const handleBuilderOnChange = useCallback(
@ -177,11 +183,15 @@ export const EditExceptionModal = memo(function EditExceptionModal({
);
const enrichExceptionItems = useCallback(() => {
let enriched: Array<ExceptionListItemSchema | CreateExceptionListItemSchema> = [];
enriched = enrichExceptionItemsWithComments(exceptionItemsToAdd, [
...(exceptionItem.comments ? exceptionItem.comments : []),
...(comment !== '' ? [{ comment }] : []),
]);
const [exceptionItemToEdit] = exceptionItemsToAdd;
let enriched: Array<ExceptionListItemSchema | CreateExceptionListItemSchema> = [
{
...enrichExistingExceptionItemWithComments(exceptionItemToEdit, [
...exceptionItem.comments,
...(comment.trim() !== '' ? [{ comment }] : []),
]),
},
];
if (exceptionListType === 'endpoint') {
const osTypes = exceptionItem._tags ? getOperatingSystems(exceptionItem._tags) : [];
enriched = enrichExceptionItemsWithOS(enriched, osTypes);
@ -222,7 +232,7 @@ export const EditExceptionModal = memo(function EditExceptionModal({
listId={exceptionItem.list_id}
listNamespaceType={exceptionItem.namespace_type}
ruleName={ruleName}
isOrDisabled={false}
isOrDisabled
isAndDisabled={false}
isNestedDisabled={false}
data-test-subj="edit-exception-modal-builder"
@ -263,6 +273,14 @@ export const EditExceptionModal = memo(function EditExceptionModal({
</>
)}
{hasVersionConflict && (
<ModalBodySection>
<EuiCallOut title={i18n.VERSION_CONFLICT_ERROR_TITLE} color="danger" iconType="alert">
<p>{i18n.VERSION_CONFLICT_ERROR_DESCRIPTION}</p>
</EuiCallOut>
</ModalBodySection>
)}
<EuiModalFooter>
<EuiButtonEmpty onClick={onCancel}>{i18n.CANCEL}</EuiButtonEmpty>

View file

@ -67,3 +67,18 @@ export const EXCEPTION_BUILDER_INFO = i18n.translate(
defaultMessage: "Alerts are generated when the rule's conditions are met, except when:",
}
);
export const VERSION_CONFLICT_ERROR_TITLE = i18n.translate(
'xpack.securitySolution.exceptions.editException.versionConflictTitle',
{
defaultMessage: 'Sorry, there was an error',
}
);
export const VERSION_CONFLICT_ERROR_DESCRIPTION = i18n.translate(
'xpack.securitySolution.exceptions.editException.versionConflictDescription',
{
defaultMessage:
"It appears this exception was updated since you first selected to edit it. Try clicking 'Cancel' and editing the exception again.",
}
);

View file

@ -18,7 +18,8 @@ import {
formatOperatingSystems,
getEntryValue,
formatExceptionItemForUpdate,
enrichExceptionItemsWithComments,
enrichNewExceptionItemsWithComments,
enrichExistingExceptionItemWithComments,
enrichExceptionItemsWithOS,
entryHasListType,
entryHasNonEcsType,
@ -35,14 +36,14 @@ import {
existsOperator,
doesNotExistOperator,
} from '../autocomplete/operators';
import { OperatorTypeEnum, OperatorEnum, EntryNested } from '../../../lists_plugin_deps';
import { OperatorTypeEnum, OperatorEnum, EntryNested } from '../../../shared_imports';
import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock';
import { getEntryMatchMock } from '../../../../../lists/common/schemas/types/entry_match.mock';
import { getEntryMatchAnyMock } from '../../../../../lists/common/schemas/types/entry_match_any.mock';
import { getEntryExistsMock } from '../../../../../lists/common/schemas/types/entry_exists.mock';
import { getEntryListMock } from '../../../../../lists/common/schemas/types/entry_list.mock';
import { getCommentsArrayMock } from '../../../../../lists/common/schemas/types/comments.mock';
import { ENTRIES } from '../../../../../lists/common/constants.mock';
import { getCommentsArrayMock } from '../../../../../lists/common/schemas/types/comment.mock';
import { ENTRIES, OLD_DATE_RELATIVE_TO_DATE_NOW } from '../../../../../lists/common/constants.mock';
import {
CreateExceptionListItemSchema,
ExceptionListItemSchema,
@ -410,12 +411,52 @@ describe('Exception helpers', () => {
expect(result).toEqual(expected);
});
});
describe('#enrichExistingExceptionItemWithComments', () => {
test('it should return exception item with comments stripped of "created_by", "created_at", "updated_by", "updated_at" fields', () => {
const payload = getExceptionListItemSchemaMock();
const comments = [
{
comment: 'Im an existing comment',
created_at: OLD_DATE_RELATIVE_TO_DATE_NOW,
created_by: 'lily',
id: '1',
},
{
comment: 'Im another existing comment',
created_at: OLD_DATE_RELATIVE_TO_DATE_NOW,
created_by: 'lily',
id: '2',
},
{
comment: 'Im a new comment',
},
];
const result = enrichExistingExceptionItemWithComments(payload, comments);
const expected = {
...getExceptionListItemSchemaMock(),
comments: [
{
comment: 'Im an existing comment',
id: '1',
},
{
comment: 'Im another existing comment',
id: '2',
},
{
comment: 'Im a new comment',
},
],
};
expect(result).toEqual(expected);
});
});
describe('#enrichExceptionItemsWithComments', () => {
describe('#enrichNewExceptionItemsWithComments', () => {
test('it should add comments to an exception item', () => {
const payload = [getExceptionListItemSchemaMock()];
const comments = getCommentsArrayMock();
const result = enrichExceptionItemsWithComments(payload, comments);
const result = enrichNewExceptionItemsWithComments(payload, comments);
const expected = [
{
...getExceptionListItemSchemaMock(),
@ -428,7 +469,7 @@ describe('Exception helpers', () => {
test('it should add comments to multiple exception items', () => {
const payload = [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()];
const comments = getCommentsArrayMock();
const result = enrichExceptionItemsWithComments(payload, comments);
const result = enrichNewExceptionItemsWithComments(payload, comments);
const expected = [
{
...getExceptionListItemSchemaMock(),

View file

@ -20,13 +20,14 @@ import { EXCEPTION_OPERATORS, isOperator } from '../autocomplete/operators';
import { OperatorOption } from '../autocomplete/types';
import {
CommentsArray,
Comments,
CreateComments,
Comment,
CreateComment,
Entry,
ExceptionListItemSchema,
NamespaceType,
OperatorTypeEnum,
CreateExceptionListItemSchema,
comment,
entry,
entriesNested,
createExceptionListItemSchema,
@ -34,7 +35,7 @@ import {
UpdateExceptionListItemSchema,
ExceptionListType,
EntryNested,
} from '../../../lists_plugin_deps';
} from '../../../shared_imports';
import { IIndexPattern } from '../../../../../../../src/plugins/data/common';
import { validate } from '../../../../common/validate';
import { TimelineNonEcsData } from '../../../graphql/types';
@ -140,16 +141,16 @@ export const getTagsInclude = ({
* @param comments ExceptionItem.comments
*/
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'),
comments.map((commentItem) => ({
username: commentItem.created_by,
timestamp: moment(commentItem.created_at).format('on MMM Do YYYY @ HH:mm:ss'),
event: i18n.COMMENT_EVENT,
timelineIcon: <EuiAvatar size="l" name={comment.created_by.toUpperCase()} />,
children: <EuiText size="s">{comment.comment}</EuiText>,
timelineIcon: <EuiAvatar size="l" name={commentItem.created_by.toUpperCase()} />,
children: <EuiText size="s">{commentItem.comment}</EuiText>,
actions: (
<WithCopyToClipboard
data-test-subj="copy-to-clipboard"
text={comment.comment}
text={commentItem.comment}
titleSummary={i18n.ADD_TO_CLIPBOARD}
/>
),
@ -271,11 +272,11 @@ export const prepareExceptionItemsForBulkClose = (
/**
* Adds new and existing comments to all new exceptionItems if not present already
* @param exceptionItems new or existing ExceptionItem[]
* @param comments new Comments
* @param comments new Comment
*/
export const enrichExceptionItemsWithComments = (
export const enrichNewExceptionItemsWithComments = (
exceptionItems: Array<ExceptionListItemSchema | CreateExceptionListItemSchema>,
comments: Array<Comments | CreateComments>
comments: Array<Comment | CreateComment>
): Array<ExceptionListItemSchema | CreateExceptionListItemSchema> => {
return exceptionItems.map((item: ExceptionListItemSchema | CreateExceptionListItemSchema) => {
return {
@ -285,6 +286,36 @@ export const enrichExceptionItemsWithComments = (
});
};
/**
* Adds new and existing comments to exceptionItem
* @param exceptionItem existing ExceptionItem
* @param comments array of comments that can include existing
* and new comments
*/
export const enrichExistingExceptionItemWithComments = (
exceptionItem: ExceptionListItemSchema | CreateExceptionListItemSchema,
comments: Array<Comment | CreateComment>
): ExceptionListItemSchema | CreateExceptionListItemSchema => {
const formattedComments = comments.map((item) => {
if (comment.is(item)) {
const { id, comment: existingComment } = item;
return {
id,
comment: existingComment,
};
} else {
return {
comment: item.comment,
};
}
});
return {
...exceptionItem,
comments: formattedComments,
};
};
/**
* Adds provided osTypes to all exceptionItems if not present already
* @param exceptionItems new or existing ExceptionItem[]

View file

@ -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 { getCommentsArrayMock } from '../../../../../../../lists/common/schemas/types/comments.mock';
import { getCommentsArrayMock } from '../../../../../../../lists/common/schemas/types/comment.mock';
describe('ExceptionDetails', () => {
beforeEach(() => {

View file

@ -11,7 +11,7 @@ import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
import { ExceptionItem } from './';
import { getExceptionListItemSchemaMock } from '../../../../../../../lists/common/schemas/response/exception_list_item_schema.mock';
import { getCommentsArrayMock } from '../../../../../../../lists/common/schemas/types/comments.mock';
import { getCommentsArrayMock } from '../../../../../../../lists/common/schemas/types/comment.mock';
addDecorator((storyFn) => (
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>{storyFn()}</ThemeProvider>

View file

@ -11,7 +11,7 @@ import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
import { ExceptionItem } from './';
import { getExceptionListItemSchemaMock } from '../../../../../../../lists/common/schemas/response/exception_list_item_schema.mock';
import { getCommentsArrayMock } from '../../../../../../../lists/common/schemas/types/comments.mock';
import { getCommentsArrayMock } from '../../../../../../../lists/common/schemas/types/comment.mock';
jest.mock('../../../../lib/kibana');

View file

@ -190,7 +190,8 @@ const ExceptionsViewerComponent = ({
const handleOnCancelExceptionModal = useCallback((): void => {
setCurrentModal(null);
}, [setCurrentModal]);
handleFetchList();
}, [setCurrentModal, handleFetchList]);
const handleOnConfirmExceptionModal = useCallback((): void => {
setCurrentModal(null);