[SIEM][Exceptions] - Cleaned up and updated exception list item comment structure (#69532)

### Summary

This PR is a follow up to #68864 . That PR used a partial to differentiate between new and existing comments, this meant that comments could be updated when they shouldn't. It was decided in our discussion of exception list schemas that comments should be append only. This PR assures that's the case, but also leaves it open to editing comments (via API). It checks to make sure that users can only update their own comments.
This commit is contained in:
Yara Tercero 2020-06-26 14:15:35 -04:00 committed by GitHub
parent 8aa2206e04
commit e4043b736b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 1440 additions and 140 deletions

View file

@ -8,6 +8,9 @@ import { left } from 'fp-ts/lib/Either';
import { pipe } from 'fp-ts/lib/pipeable';
import { exactCheck, foldLeftRight, getPaths } from '../../siem_common_deps';
import { getCreateCommentsArrayMock } from '../types/create_comments.mock';
import { getCommentsMock } from '../types/comments.mock';
import { CommentsArray } from '../types';
import {
CreateExceptionListItemSchema,
@ -26,7 +29,7 @@ describe('create_exception_list_item_schema', () => {
expect(message.schema).toEqual(payload);
});
test('it should not accept an undefined for "description"', () => {
test('it should not validate an undefined for "description"', () => {
const payload = getCreateExceptionListItemSchemaMock();
delete payload.description;
const decoded = createExceptionListItemSchema.decode(payload);
@ -38,7 +41,7 @@ describe('create_exception_list_item_schema', () => {
expect(message.schema).toEqual({});
});
test('it should not accept an undefined for "name"', () => {
test('it should not validate an undefined for "name"', () => {
const payload = getCreateExceptionListItemSchemaMock();
delete payload.name;
const decoded = createExceptionListItemSchema.decode(payload);
@ -50,7 +53,7 @@ describe('create_exception_list_item_schema', () => {
expect(message.schema).toEqual({});
});
test('it should not accept an undefined for "type"', () => {
test('it should not validate an undefined for "type"', () => {
const payload = getCreateExceptionListItemSchemaMock();
delete payload.type;
const decoded = createExceptionListItemSchema.decode(payload);
@ -62,7 +65,7 @@ describe('create_exception_list_item_schema', () => {
expect(message.schema).toEqual({});
});
test('it should not accept an undefined for "list_id"', () => {
test('it should not validate an undefined for "list_id"', () => {
const inputPayload = getCreateExceptionListItemSchemaMock();
delete inputPayload.list_id;
const decoded = createExceptionListItemSchema.decode(inputPayload);
@ -74,7 +77,7 @@ describe('create_exception_list_item_schema', () => {
expect(message.schema).toEqual({});
});
test('it should accept an undefined for "meta" but strip it out and generate a correct body not counting the auto generated uuid', () => {
test('it should validate an undefined for "meta" but strip it out and generate a correct body not counting the auto generated uuid', () => {
const payload = getCreateExceptionListItemSchemaMock();
const outputPayload = getCreateExceptionListItemSchemaMock();
delete payload.meta;
@ -87,7 +90,7 @@ describe('create_exception_list_item_schema', () => {
expect(message.schema).toEqual(outputPayload);
});
test('it should accept an undefined for "comments" but return an array and generate a correct body not counting the auto generated uuid', () => {
test('it should validate an undefined for "comments" but return an array and generate a correct body not counting the auto generated uuid', () => {
const inputPayload = getCreateExceptionListItemSchemaMock();
const outputPayload = getCreateExceptionListItemSchemaMock();
delete inputPayload.comments;
@ -100,7 +103,34 @@ describe('create_exception_list_item_schema', () => {
expect(message.schema).toEqual(outputPayload);
});
test('it should accept an undefined for "entries" but return an array', () => {
test('it should validate "comments" array', () => {
const inputPayload = {
...getCreateExceptionListItemSchemaMock(),
comments: getCreateCommentsArrayMock(),
};
const decoded = createExceptionListItemSchema.decode(inputPayload);
const checked = exactCheck(inputPayload, decoded);
const message = pipe(checked, foldLeftRight);
delete (message.schema as CreateExceptionListItemSchema).item_id;
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(inputPayload);
});
test('it should NOT validate "comments" with "created_at" or "created_by" values', () => {
const inputPayload: Omit<CreateExceptionListItemSchema, 'comments'> & {
comments?: CommentsArray;
} = {
...getCreateExceptionListItemSchemaMock(),
comments: [getCommentsMock()],
};
const decoded = createExceptionListItemSchema.decode(inputPayload);
const checked = exactCheck(inputPayload, decoded);
const message = pipe(checked, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual(['invalid keys "created_at,created_by"']);
expect(message.schema).toEqual({});
});
test('it should validate an undefined for "entries" but return an array', () => {
const inputPayload = getCreateExceptionListItemSchemaMock();
const outputPayload = getCreateExceptionListItemSchemaMock();
delete inputPayload.entries;
@ -113,7 +143,7 @@ describe('create_exception_list_item_schema', () => {
expect(message.schema).toEqual(outputPayload);
});
test('it should accept an undefined for "namespace_type" but return enum "single" and generate a correct body not counting the auto generated uuid', () => {
test('it should validate an undefined for "namespace_type" but return enum "single" and generate a correct body not counting the auto generated uuid', () => {
const inputPayload = getCreateExceptionListItemSchemaMock();
const outputPayload = getCreateExceptionListItemSchemaMock();
delete inputPayload.namespace_type;
@ -126,7 +156,7 @@ describe('create_exception_list_item_schema', () => {
expect(message.schema).toEqual(outputPayload);
});
test('it should accept an undefined for "tags" but return an array and generate a correct body not counting the auto generated uuid', () => {
test('it should validate an undefined for "tags" but return an array and generate a correct body not counting the auto generated uuid', () => {
const inputPayload = getCreateExceptionListItemSchemaMock();
const outputPayload = getCreateExceptionListItemSchemaMock();
delete inputPayload.tags;
@ -139,7 +169,7 @@ describe('create_exception_list_item_schema', () => {
expect(message.schema).toEqual(outputPayload);
});
test('it should accept an undefined for "_tags" but return an array and generate a correct body not counting the auto generated uuid', () => {
test('it should validate an undefined for "_tags" but return an array and generate a correct body not counting the auto generated uuid', () => {
const inputPayload = getCreateExceptionListItemSchemaMock();
const outputPayload = getCreateExceptionListItemSchemaMock();
delete inputPayload._tags;
@ -152,7 +182,7 @@ describe('create_exception_list_item_schema', () => {
expect(message.schema).toEqual(outputPayload);
});
test('it should accept an undefined for "item_id" and auto generate a uuid', () => {
test('it should validate an undefined for "item_id" and auto generate a uuid', () => {
const inputPayload = getCreateExceptionListItemSchemaMock();
delete inputPayload.item_id;
const decoded = createExceptionListItemSchema.decode(inputPayload);
@ -164,7 +194,7 @@ describe('create_exception_list_item_schema', () => {
);
});
test('it should accept an undefined for "item_id" and generate a correct body not counting the uuid', () => {
test('it should validate an undefined for "item_id" and generate a correct body not counting the uuid', () => {
const inputPayload = getCreateExceptionListItemSchemaMock();
delete inputPayload.item_id;
const decoded = createExceptionListItemSchema.decode(inputPayload);

View file

@ -23,7 +23,7 @@ import {
tags,
} from '../common/schemas';
import { Identity, RequiredKeepUndefined } from '../../types';
import { CommentsPartialArray, DefaultCommentsPartialArray, DefaultEntryArray } from '../types';
import { CreateCommentsArray, DefaultCreateCommentsArray, DefaultEntryArray } from '../types';
import { EntriesArray } from '../types/entries';
import { DefaultUuid } from '../../siem_common_deps';
@ -39,7 +39,7 @@ export const createExceptionListItemSchema = t.intersection([
t.exact(
t.partial({
_tags, // defaults to empty array if not set during decode
comments: DefaultCommentsPartialArray, // defaults to empty array if not set during decode
comments: DefaultCreateCommentsArray, // defaults to empty array if not set during decode
entries: DefaultEntryArray, // defaults to empty array if not set during decode
item_id: DefaultUuid, // defaults to GUID (uuid v4) if not set during decode
meta, // defaults to undefined if not set during decode
@ -63,7 +63,7 @@ export type CreateExceptionListItemSchemaDecoded = Identity<
'_tags' | 'tags' | 'item_id' | 'entries' | 'namespace_type' | 'comments'
> & {
_tags: _Tags;
comments: CommentsPartialArray;
comments: CreateCommentsArray;
tags: Tags;
item_id: ItemId;
entries: EntriesArray;

View file

@ -23,10 +23,10 @@ import {
} from '../common/schemas';
import { Identity, RequiredKeepUndefined } from '../../types';
import {
CommentsPartialArray,
DefaultCommentsPartialArray,
DefaultEntryArray,
DefaultUpdateCommentsArray,
EntriesArray,
UpdateCommentsArray,
} from '../types';
export const updateExceptionListItemSchema = t.intersection([
@ -40,7 +40,7 @@ export const updateExceptionListItemSchema = t.intersection([
t.exact(
t.partial({
_tags, // defaults to empty array if not set during decode
comments: DefaultCommentsPartialArray, // defaults to empty array if not set during decode
comments: DefaultUpdateCommentsArray, // defaults to empty array if not set during decode
entries: DefaultEntryArray, // defaults to empty array if not set during decode
id, // defaults to undefined if not set during decode
item_id: t.union([t.string, t.undefined]),
@ -65,7 +65,7 @@ export type UpdateExceptionListItemSchemaDecoded = Identity<
'_tags' | 'tags' | 'entries' | 'namespace_type' | 'comments'
> & {
_tags: _Tags;
comments: CommentsPartialArray;
comments: UpdateCommentsArray;
tags: Tags;
entries: EntriesArray;
namespace_type: NamespaceType;

View file

@ -6,17 +6,12 @@
import { DATE_NOW, USER } from '../../constants.mock';
import { CommentsArray } from './comments';
import { Comments, CommentsArray } from './comments';
export const getCommentsMock = (): CommentsArray => [
{
comment: 'some comment',
created_at: DATE_NOW,
created_by: USER,
},
{
comment: 'some other comment',
created_at: DATE_NOW,
created_by: 'lily',
},
];
export const getCommentsMock = (): Comments => ({
comment: 'some old comment',
created_at: DATE_NOW,
created_by: USER,
});
export const getCommentsArrayMock = (): CommentsArray => [getCommentsMock(), getCommentsMock()];

View file

@ -0,0 +1,217 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { pipe } from 'fp-ts/lib/pipeable';
import { left } from 'fp-ts/lib/Either';
import { DATE_NOW } from '../../constants.mock';
import { foldLeftRight, getPaths } from '../../siem_common_deps';
import { getCommentsArrayMock, getCommentsMock } from './comments.mock';
import {
Comments,
CommentsArray,
CommentsArrayOrUndefined,
comments,
commentsArray,
commentsArrayOrUndefined,
} from './comments';
describe('Comments', () => {
describe('comments', () => {
test('it should validate a comments', () => {
const payload = getCommentsMock();
const decoded = comments.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should validate with "updated_at" and "updated_by"', () => {
const payload = getCommentsMock();
payload.updated_at = DATE_NOW;
payload.updated_by = 'someone';
const decoded = comments.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should not validate when undefined', () => {
const payload = undefined;
const decoded = comments.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "undefined" supplied to "({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)"',
'Invalid value "undefined" supplied to "({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)"',
]);
expect(message.schema).toEqual({});
});
test('it should not validate when "comment" is not a string', () => {
const payload: Omit<Comments, 'comment'> & { comment: string[] } = {
...getCommentsMock(),
comment: ['some value'],
};
const decoded = comments.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "["some value"]" supplied to "comment"',
]);
expect(message.schema).toEqual({});
});
test('it should not validate when "created_at" is not a string', () => {
const payload: Omit<Comments, 'created_at'> & { created_at: number } = {
...getCommentsMock(),
created_at: 1,
};
const decoded = comments.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "1" supplied to "created_at"',
]);
expect(message.schema).toEqual({});
});
test('it should not validate when "created_by" is not a string', () => {
const payload: Omit<Comments, 'created_by'> & { created_by: number } = {
...getCommentsMock(),
created_by: 1,
};
const decoded = comments.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "1" supplied to "created_by"',
]);
expect(message.schema).toEqual({});
});
test('it should not validate when "updated_at" is not a string', () => {
const payload: Omit<Comments, 'updated_at'> & { updated_at: number } = {
...getCommentsMock(),
updated_at: 1,
};
const decoded = comments.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "1" supplied to "updated_at"',
]);
expect(message.schema).toEqual({});
});
test('it should not validate when "updated_by" is not a string', () => {
const payload: Omit<Comments, 'updated_by'> & { updated_by: number } = {
...getCommentsMock(),
updated_by: 1,
};
const decoded = comments.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "1" supplied to "updated_by"',
]);
expect(message.schema).toEqual({});
});
test('it should strip out extra keys', () => {
const payload: Comments & {
extraKey?: string;
} = getCommentsMock();
payload.extraKey = 'some value';
const decoded = comments.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(getCommentsMock());
});
});
describe('commentsArray', () => {
test('it should validate an array of comments', () => {
const payload = getCommentsArrayMock();
const decoded = commentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should validate when a comments includes "updated_at" and "updated_by"', () => {
const commentsPayload = getCommentsMock();
commentsPayload.updated_at = DATE_NOW;
commentsPayload.updated_by = 'someone';
const payload = [{ ...commentsPayload }, ...getCommentsArrayMock()];
const decoded = commentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should not validate when undefined', () => {
const payload = undefined;
const decoded = commentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "undefined" supplied to "Array<({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)>"',
]);
expect(message.schema).toEqual({});
});
test('it should not validate when array includes non comments types', () => {
const payload = ([1] as unknown) as CommentsArray;
const decoded = commentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "1" supplied to "Array<({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)>"',
'Invalid value "1" supplied to "Array<({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)>"',
]);
expect(message.schema).toEqual({});
});
});
describe('commentsArrayOrUndefined', () => {
test('it should validate an array of comments', () => {
const payload = getCommentsArrayMock();
const decoded = commentsArrayOrUndefined.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should validate when undefined', () => {
const payload = undefined;
const decoded = commentsArrayOrUndefined.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should not validate when array includes non comments types', () => {
const payload = ([1] as unknown) as CommentsArrayOrUndefined;
const decoded = commentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "1" supplied to "Array<({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)>"',
'Invalid value "1" supplied to "Array<({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)>"',
]);
expect(message.schema).toEqual({});
});
});
});

View file

@ -5,36 +5,24 @@
*/
import * as t from 'io-ts';
export const comment = t.exact(
t.type({
comment: t.string,
created_at: t.string, // TODO: Make this into an ISO Date string check,
created_by: t.string,
})
);
export const commentsArray = t.array(comment);
export type CommentsArray = t.TypeOf<typeof commentsArray>;
export type Comment = t.TypeOf<typeof comment>;
export const commentsArrayOrUndefined = t.union([commentsArray, t.undefined]);
export type CommentsArrayOrUndefined = t.TypeOf<typeof commentsArrayOrUndefined>;
export const commentPartial = t.intersection([
export const comments = t.intersection([
t.exact(
t.type({
comment: t.string,
})
),
t.exact(
t.partial({
created_at: t.string, // TODO: Make this into an ISO Date string check,
created_by: t.string,
})
),
t.exact(
t.partial({
updated_at: t.string,
updated_by: t.string,
})
),
]);
export const commentsPartialArray = t.array(commentPartial);
export type CommentsPartialArray = t.TypeOf<typeof commentsPartialArray>;
export type CommentPartial = t.TypeOf<typeof commentPartial>;
export const commentsPartialArrayOrUndefined = t.union([commentsPartialArray, t.undefined]);
export type CommentsPartialArrayOrUndefined = t.TypeOf<typeof commentsPartialArrayOrUndefined>;
export const commentsArray = t.array(comments);
export type CommentsArray = t.TypeOf<typeof commentsArray>;
export type Comments = t.TypeOf<typeof comments>;
export const commentsArrayOrUndefined = t.union([commentsArray, t.undefined]);
export type CommentsArrayOrUndefined = t.TypeOf<typeof commentsArrayOrUndefined>;

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { CreateComments, CreateCommentsArray } from './create_comments';
export const getCreateCommentsMock = (): CreateComments => ({
comment: 'some comments',
});
export const getCreateCommentsArrayMock = (): CreateCommentsArray => [getCreateCommentsMock()];

View file

@ -0,0 +1,134 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { pipe } from 'fp-ts/lib/pipeable';
import { left } from 'fp-ts/lib/Either';
import { foldLeftRight, getPaths } from '../../siem_common_deps';
import { getCreateCommentsArrayMock, getCreateCommentsMock } from './create_comments.mock';
import {
CreateComments,
CreateCommentsArray,
CreateCommentsArrayOrUndefined,
createComments,
createCommentsArray,
createCommentsArrayOrUndefined,
} from './create_comments';
describe('CreateComments', () => {
describe('createComments', () => {
test('it should validate a comments', () => {
const payload = getCreateCommentsMock();
const decoded = createComments.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should not validate when undefined', () => {
const payload = undefined;
const decoded = createComments.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "undefined" supplied to "{| comment: string |}"',
]);
expect(message.schema).toEqual({});
});
test('it should not validate when "comment" is not a string', () => {
const payload: Omit<CreateComments, 'comment'> & { comment: string[] } = {
...getCreateCommentsMock(),
comment: ['some value'],
};
const decoded = createComments.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "["some value"]" supplied to "comment"',
]);
expect(message.schema).toEqual({});
});
test('it should strip out extra keys', () => {
const payload: CreateComments & {
extraKey?: string;
} = getCreateCommentsMock();
payload.extraKey = 'some value';
const decoded = createComments.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(getCreateCommentsMock());
});
});
describe('createCommentsArray', () => {
test('it should validate an array of comments', () => {
const payload = getCreateCommentsArrayMock();
const decoded = createCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should not validate when undefined', () => {
const payload = undefined;
const decoded = createCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "undefined" supplied to "Array<{| comment: string |}>"',
]);
expect(message.schema).toEqual({});
});
test('it should not validate when array includes non comments types', () => {
const payload = ([1] as unknown) as CreateCommentsArray;
const decoded = createCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "1" supplied to "Array<{| comment: string |}>"',
]);
expect(message.schema).toEqual({});
});
});
describe('createCommentsArrayOrUndefined', () => {
test('it should validate an array of comments', () => {
const payload = getCreateCommentsArrayMock();
const decoded = createCommentsArrayOrUndefined.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should validate when undefined', () => {
const payload = undefined;
const decoded = createCommentsArrayOrUndefined.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should not validate when array includes non comments types', () => {
const payload = ([1] as unknown) as CreateCommentsArrayOrUndefined;
const decoded = createCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "1" supplied to "Array<{| comment: string |}>"',
]);
expect(message.schema).toEqual({});
});
});
});

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as t from 'io-ts';
export const createComments = t.exact(
t.type({
comment: t.string,
})
);
export const createCommentsArray = t.array(createComments);
export type CreateCommentsArray = t.TypeOf<typeof createCommentsArray>;
export type CreateComments = t.TypeOf<typeof createComments>;
export const createCommentsArrayOrUndefined = t.union([createCommentsArray, t.undefined]);
export type CreateCommentsArrayOrUndefined = t.TypeOf<typeof createCommentsArrayOrUndefined>;

View file

@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { pipe } from 'fp-ts/lib/pipeable';
import { left } from 'fp-ts/lib/Either';
import { foldLeftRight, getPaths } from '../../siem_common_deps';
import { DefaultCommentsArray } from './default_comments_array';
import { CommentsArray } from './comments';
import { getCommentsArrayMock } from './comments.mock';
describe('default_comments_array', () => {
test('it should validate an empty array', () => {
const payload: CommentsArray = [];
const decoded = DefaultCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should validate an array of comments', () => {
const payload: CommentsArray = getCommentsArrayMock();
const decoded = DefaultCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should NOT validate an array of numbers', () => {
const payload = [1];
const decoded = DefaultCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
// TODO: Known weird error formatting that is on our list to address
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "1" supplied to "Array<({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)>"',
'Invalid value "1" supplied to "Array<({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)>"',
]);
expect(message.schema).toEqual({});
});
test('it should NOT validate an array of strings', () => {
const payload = ['some string'];
const decoded = DefaultCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "some string" supplied to "Array<({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)>"',
'Invalid value "some string" supplied to "Array<({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)>"',
]);
expect(message.schema).toEqual({});
});
test('it should return a default array entry', () => {
const payload = null;
const decoded = DefaultCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual([]);
});
});

View file

@ -7,14 +7,9 @@
import * as t from 'io-ts';
import { Either } from 'fp-ts/lib/Either';
import { CommentsArray, CommentsPartialArray, comment, commentPartial } from './comments';
import { CommentsArray, comments } from './comments';
export type DefaultCommentsArrayC = t.Type<CommentsArray, CommentsArray, unknown>;
export type DefaultCommentsPartialArrayC = t.Type<
CommentsPartialArray,
CommentsPartialArray,
unknown
>;
/**
* Types the DefaultCommentsArray as:
@ -26,24 +21,8 @@ export const DefaultCommentsArray: DefaultCommentsArrayC = new t.Type<
unknown
>(
'DefaultCommentsArray',
t.array(comment).is,
(input, context): Either<t.Errors, CommentsArray> =>
input == null ? t.success([]) : t.array(comment).validate(input, context),
t.identity
);
/**
* Types the DefaultCommentsPartialArray as:
* - If null or undefined, then a default array of type entry will be set
*/
export const DefaultCommentsPartialArray: DefaultCommentsPartialArrayC = new t.Type<
CommentsPartialArray,
CommentsPartialArray,
unknown
>(
'DefaultCommentsPartialArray',
t.array(commentPartial).is,
(input, context): Either<t.Errors, CommentsPartialArray> =>
input == null ? t.success([]) : t.array(commentPartial).validate(input, context),
t.array(comments).is,
(input): Either<t.Errors, CommentsArray> =>
input == null ? t.success([]) : t.array(comments).decode(input),
t.identity
);

View file

@ -0,0 +1,66 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { pipe } from 'fp-ts/lib/pipeable';
import { left } from 'fp-ts/lib/Either';
import { foldLeftRight, getPaths } from '../../siem_common_deps';
import { DefaultCreateCommentsArray } from './default_create_comments_array';
import { CreateCommentsArray } from './create_comments';
import { getCreateCommentsArrayMock } from './create_comments.mock';
describe('default_create_comments_array', () => {
test('it should validate an empty array', () => {
const payload: CreateCommentsArray = [];
const decoded = DefaultCreateCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should validate an array of comments', () => {
const payload: CreateCommentsArray = getCreateCommentsArrayMock();
const decoded = DefaultCreateCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should NOT validate an array of numbers', () => {
const payload = [1];
const decoded = DefaultCreateCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
// TODO: Known weird error formatting that is on our list to address
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "1" supplied to "Array<{| comment: string |}>"',
]);
expect(message.schema).toEqual({});
});
test('it should NOT validate an array of strings', () => {
const payload = ['some string'];
const decoded = DefaultCreateCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "some string" supplied to "Array<{| comment: string |}>"',
]);
expect(message.schema).toEqual({});
});
test('it should return a default array entry', () => {
const payload = null;
const decoded = DefaultCreateCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual([]);
});
});

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as t from 'io-ts';
import { Either } from 'fp-ts/lib/Either';
import { CreateCommentsArray, createComments } from './create_comments';
export type DefaultCreateCommentsArrayC = t.Type<CreateCommentsArray, CreateCommentsArray, unknown>;
/**
* Types the DefaultCreateComments as:
* - If null or undefined, then a default array of type entry will be set
*/
export const DefaultCreateCommentsArray: DefaultCreateCommentsArrayC = new t.Type<
CreateCommentsArray,
CreateCommentsArray,
unknown
>(
'DefaultCreateComments',
t.array(createComments).is,
(input): Either<t.Errors, CreateCommentsArray> =>
input == null ? t.success([]) : t.array(createComments).decode(input),
t.identity
);

View file

@ -0,0 +1,70 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { pipe } from 'fp-ts/lib/pipeable';
import { left } from 'fp-ts/lib/Either';
import { foldLeftRight, getPaths } from '../../siem_common_deps';
import { DefaultUpdateCommentsArray } from './default_update_comments_array';
import { UpdateCommentsArray } from './update_comments';
import { getUpdateCommentsArrayMock } from './update_comments.mock';
describe('default_update_comments_array', () => {
test('it should validate an empty array', () => {
const payload: UpdateCommentsArray = [];
const decoded = DefaultUpdateCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should validate an array of comments', () => {
const payload: UpdateCommentsArray = getUpdateCommentsArrayMock();
const decoded = DefaultUpdateCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should NOT validate an array of numbers', () => {
const payload = [1];
const decoded = DefaultUpdateCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
// TODO: Known weird error formatting that is on our list to address
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "1" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"',
'Invalid value "1" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"',
'Invalid value "1" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"',
]);
expect(message.schema).toEqual({});
});
test('it should NOT validate an array of strings', () => {
const payload = ['some string'];
const decoded = DefaultUpdateCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "some string" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"',
'Invalid value "some string" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"',
'Invalid value "some string" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"',
]);
expect(message.schema).toEqual({});
});
test('it should return a default array entry', () => {
const payload = null;
const decoded = DefaultUpdateCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual([]);
});
});

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as t from 'io-ts';
import { Either } from 'fp-ts/lib/Either';
import { UpdateCommentsArray, updateCommentsArray } from './update_comments';
export type DefaultUpdateCommentsArrayC = t.Type<UpdateCommentsArray, UpdateCommentsArray, unknown>;
/**
* Types the DefaultCommentsUpdate as:
* - If null or undefined, then a default array of type entry will be set
*/
export const DefaultUpdateCommentsArray: DefaultUpdateCommentsArrayC = new t.Type<
UpdateCommentsArray,
UpdateCommentsArray,
unknown
>(
'DefaultCreateComments',
updateCommentsArray.is,
(input): Either<t.Errors, UpdateCommentsArray> =>
input == null ? t.success([]) : updateCommentsArray.decode(input),
t.identity
);

View file

@ -3,8 +3,12 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export * from './default_comments_array';
export * from './default_entries_array';
export * from './default_namespace';
export * from './comments';
export * from './create_comments';
export * from './update_comments';
export * from './default_comments_array';
export * from './default_create_comments_array';
export * from './default_update_comments_array';
export * from './default_namespace';
export * from './default_entries_array';
export * from './entries';

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { getCommentsMock } from './comments.mock';
import { getCreateCommentsMock } from './create_comments.mock';
import { UpdateCommentsArray } from './update_comments';
export const getUpdateCommentsArrayMock = (): UpdateCommentsArray => [
getCommentsMock(),
getCreateCommentsMock(),
];

View file

@ -0,0 +1,108 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { pipe } from 'fp-ts/lib/pipeable';
import { left } from 'fp-ts/lib/Either';
import { foldLeftRight, getPaths } from '../../siem_common_deps';
import { getUpdateCommentsArrayMock } from './update_comments.mock';
import {
UpdateCommentsArray,
UpdateCommentsArrayOrUndefined,
updateCommentsArray,
updateCommentsArrayOrUndefined,
} from './update_comments';
import { getCommentsMock } from './comments.mock';
import { getCreateCommentsMock } from './create_comments.mock';
describe('CommentsUpdate', () => {
describe('updateCommentsArray', () => {
test('it should validate an array of comments', () => {
const payload = getUpdateCommentsArrayMock();
const decoded = updateCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should validate an array of existing comments', () => {
const payload = [getCommentsMock()];
const decoded = updateCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should validate an array of new comments', () => {
const payload = [getCreateCommentsMock()];
const decoded = updateCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should not validate when undefined', () => {
const payload = undefined;
const decoded = updateCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "undefined" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"',
]);
expect(message.schema).toEqual({});
});
test('it should not validate when array includes non comments types', () => {
const payload = ([1] as unknown) as UpdateCommentsArray;
const decoded = updateCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "1" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"',
'Invalid value "1" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"',
'Invalid value "1" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"',
]);
expect(message.schema).toEqual({});
});
});
describe('updateCommentsArrayOrUndefined', () => {
test('it should validate an array of comments', () => {
const payload = getUpdateCommentsArrayMock();
const decoded = updateCommentsArrayOrUndefined.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should validate when undefined', () => {
const payload = undefined;
const decoded = updateCommentsArrayOrUndefined.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should not validate when array includes non comments types', () => {
const payload = ([1] as unknown) as UpdateCommentsArrayOrUndefined;
const decoded = updateCommentsArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "1" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"',
'Invalid value "1" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"',
'Invalid value "1" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"',
]);
expect(message.schema).toEqual({});
});
});
});

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as t from 'io-ts';
import { comments } from './comments';
import { createComments } from './create_comments';
export const updateCommentsArray = t.array(t.union([comments, createComments]));
export type UpdateCommentsArray = t.TypeOf<typeof updateCommentsArray>;
export const updateCommentsArrayOrUndefined = t.union([updateCommentsArray, t.undefined]);
export type UpdateCommentsArrayOrUndefined = t.TypeOf<typeof updateCommentsArrayOrUndefined>;

View file

@ -250,7 +250,7 @@ describe('Exceptions Lists API', () => {
});
// TODO Would like to just use getExceptionListSchemaMock() here, but
// validation returns object in different order, making the strings not match
expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists', {
expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists/items', {
body: JSON.stringify(payload),
method: 'PUT',
signal: abortCtrl.signal,

View file

@ -176,7 +176,7 @@ export const updateExceptionListItem = async ({
if (validatedRequest != null) {
try {
const response = await http.fetch<ExceptionListItemSchema>(EXCEPTION_LIST_URL, {
const response = await http.fetch<ExceptionListItemSchema>(EXCEPTION_LIST_ITEM_URL, {
body: JSON.stringify(listItem),
method: 'PUT',
signal,

View file

@ -77,6 +77,12 @@ export const exceptionListItemMapping: SavedObjectsType['mappings'] = {
created_by: {
type: 'keyword',
},
updated_at: {
type: 'keyword',
},
updated_by: {
type: 'keyword',
},
},
},
entries: {

View file

@ -5,14 +5,7 @@
"type": "simple",
"description": "This is a sample change here this list",
"name": "Sample Endpoint Exception List update change",
"comments": [
{
"comment": "this was an old comment.",
"created_by": "lily",
"created_at": "2020-04-20T15:25:31.830Z"
},
{ "comment": "this is a newly added comment" }
],
"comments": [{ "comment": "this is a newly added comment" }],
"entries": [
{
"field": "event.category",

View file

@ -8,7 +8,7 @@ import { SavedObjectsClientContract } from 'kibana/server';
import uuid from 'uuid';
import {
CommentsPartialArray,
CreateCommentsArray,
Description,
EntriesArray,
ExceptionListItemSchema,
@ -25,13 +25,13 @@ import {
import {
getSavedObjectType,
transformComments,
transformCreateCommentsToComments,
transformSavedObjectToExceptionListItem,
} from './utils';
interface CreateExceptionListItemOptions {
_tags: _Tags;
comments: CommentsPartialArray;
comments: CreateCommentsArray;
listId: ListId;
itemId: ItemId;
savedObjectsClient: SavedObjectsClientContract;
@ -64,9 +64,10 @@ export const createExceptionListItem = async ({
}: CreateExceptionListItemOptions): Promise<ExceptionListItemSchema> => {
const savedObjectType = getSavedObjectType({ namespaceType });
const dateNow = new Date().toISOString();
const transformedComments = transformCreateCommentsToComments({ comments, user });
const savedObject = await savedObjectsClient.create<ExceptionListSoSchema>(savedObjectType, {
_tags,
comments: transformComments({ comments, user }),
comments: transformedComments,
created_at: dateNow,
created_by: user,
description,

View file

@ -7,7 +7,7 @@
import { SavedObjectsClientContract } from 'kibana/server';
import {
CommentsPartialArray,
CreateCommentsArray,
Description,
DescriptionOrUndefined,
EntriesArray,
@ -30,6 +30,7 @@ import {
SortOrderOrUndefined,
Tags,
TagsOrUndefined,
UpdateCommentsArray,
_Tags,
_TagsOrUndefined,
} from '../../../common/schemas';
@ -88,7 +89,7 @@ export interface GetExceptionListItemOptions {
export interface CreateExceptionListItemOptions {
_tags: _Tags;
comments: CommentsPartialArray;
comments: CreateCommentsArray;
entries: EntriesArray;
itemId: ItemId;
listId: ListId;
@ -102,7 +103,7 @@ export interface CreateExceptionListItemOptions {
export interface UpdateExceptionListItemOptions {
_tags: _TagsOrUndefined;
comments: CommentsPartialArray;
comments: UpdateCommentsArray;
entries: EntriesArrayOrUndefined;
id: IdOrUndefined;
itemId: ItemIdOrUndefined;

View file

@ -7,7 +7,6 @@
import { SavedObjectsClientContract } from 'kibana/server';
import {
CommentsPartialArray,
DescriptionOrUndefined,
EntriesArrayOrUndefined,
ExceptionListItemSchema,
@ -19,19 +18,20 @@ import {
NameOrUndefined,
NamespaceType,
TagsOrUndefined,
UpdateCommentsArrayOrUndefined,
_TagsOrUndefined,
} from '../../../common/schemas';
import {
getSavedObjectType,
transformComments,
transformSavedObjectUpdateToExceptionListItem,
transformUpdateCommentsToComments,
} from './utils';
import { getExceptionListItem } from './get_exception_list_item';
interface UpdateExceptionListItemOptions {
id: IdOrUndefined;
comments: CommentsPartialArray;
comments: UpdateCommentsArrayOrUndefined;
_tags: _TagsOrUndefined;
name: NameOrUndefined;
description: DescriptionOrUndefined;
@ -71,12 +71,17 @@ export const updateExceptionListItem = async ({
if (exceptionListItem == null) {
return null;
} else {
const transformedComments = transformUpdateCommentsToComments({
comments,
existingComments: exceptionListItem.comments,
user,
});
const savedObject = await savedObjectsClient.update<ExceptionListSoSchema>(
savedObjectType,
exceptionListItem.id,
{
_tags,
comments: transformComments({ comments, user }),
comments: transformedComments,
description,
entries,
meta,

View file

@ -0,0 +1,437 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import sinon from 'sinon';
import moment from 'moment';
import { DATE_NOW, USER } from '../../../common/constants.mock';
import {
isCommentEqual,
transformCreateCommentsToComments,
transformUpdateComments,
transformUpdateCommentsToComments,
} from './utils';
describe('utils', () => {
const anchor = '2020-06-17T20:34:51.337Z';
const unix = moment(anchor).valueOf();
let clock: sinon.SinonFakeTimers;
beforeEach(() => {
clock = sinon.useFakeTimers(unix);
});
afterEach(() => {
clock.restore();
});
describe('#transformUpdateCommentsToComments', () => {
test('it returns empty array if "comments" is undefined and no comments exist', () => {
const comments = transformUpdateCommentsToComments({
comments: undefined,
existingComments: [],
user: 'lily',
});
expect(comments).toEqual([]);
});
test('it formats newly added comments', () => {
const comments = transformUpdateCommentsToComments({
comments: [
{ comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' },
{ comment: 'Im a new comment' },
],
existingComments: [
{ comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' },
],
user: 'lily',
});
expect(comments).toEqual([
{
comment: 'Im an old comment',
created_at: anchor,
created_by: 'lily',
},
{
comment: 'Im a new comment',
created_at: anchor,
created_by: 'lily',
},
]);
});
test('it formats multiple newly added comments', () => {
const comments = transformUpdateCommentsToComments({
comments: [
{ comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' },
{ comment: 'Im a new comment' },
{ comment: 'Im another new comment' },
],
existingComments: [
{ comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' },
],
user: 'lily',
});
expect(comments).toEqual([
{
comment: 'Im an old comment',
created_at: anchor,
created_by: 'lily',
},
{
comment: 'Im a new comment',
created_at: anchor,
created_by: 'lily',
},
{
comment: 'Im another new comment',
created_at: anchor,
created_by: 'lily',
},
]);
});
test('it should not throw if comments match existing comments', () => {
const comments = transformUpdateCommentsToComments({
comments: [{ comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }],
existingComments: [
{ comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' },
],
user: 'lily',
});
expect(comments).toEqual([
{
comment: 'Im an old comment',
created_at: anchor,
created_by: 'lily',
},
]);
});
test('it does not throw if user tries to update one of their own existing comments', () => {
const comments = transformUpdateCommentsToComments({
comments: [
{
comment: 'Im an old comment that is trying to be updated',
created_at: DATE_NOW,
created_by: 'lily',
},
],
existingComments: [
{ comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' },
],
user: 'lily',
});
expect(comments).toEqual([
{
comment: 'Im an old comment that is trying to be updated',
created_at: DATE_NOW,
created_by: 'lily',
updated_at: anchor,
updated_by: 'lily',
},
]);
});
test('it throws an error if user tries to update their comment, without passing in the "created_at" and "created_by" properties', () => {
expect(() =>
transformUpdateCommentsToComments({
comments: [
{
comment: 'Im an old comment that is trying to be updated',
},
],
existingComments: [
{ comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' },
],
user: 'lily',
})
).toThrowErrorMatchingInlineSnapshot(
`"When trying to update a comment, \\"created_at\\" and \\"created_by\\" must be present"`
);
});
test('it throws an error if user tries to delete comments', () => {
expect(() =>
transformUpdateCommentsToComments({
comments: [],
existingComments: [
{ comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' },
],
user: 'lily',
})
).toThrowErrorMatchingInlineSnapshot(
`"Comments cannot be deleted, only new comments may be added"`
);
});
test('it throws if user tries to update existing comment timestamp', () => {
expect(() =>
transformUpdateCommentsToComments({
comments: [{ comment: 'Im an old comment', created_at: anchor, created_by: 'lily' }],
existingComments: [
{ comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' },
],
user: 'bane',
})
).toThrowErrorMatchingInlineSnapshot(`"Not authorized to edit others comments"`);
});
test('it throws if user tries to update existing comment author', () => {
expect(() =>
transformUpdateCommentsToComments({
comments: [{ comment: 'Im an old comment', created_at: anchor, created_by: 'lily' }],
existingComments: [
{ comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'me!' },
],
user: 'bane',
})
).toThrowErrorMatchingInlineSnapshot(`"Not authorized to edit others comments"`);
});
test('it throws if user tries to update an existing comment that is not their own', () => {
expect(() =>
transformUpdateCommentsToComments({
comments: [
{
comment: 'Im an old comment that is trying to be updated',
created_at: DATE_NOW,
created_by: 'lily',
},
],
existingComments: [
{ comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' },
],
user: 'bane',
})
).toThrowErrorMatchingInlineSnapshot(`"Not authorized to edit others comments"`);
});
test('it throws if user tries to update order of comments', () => {
expect(() =>
transformUpdateCommentsToComments({
comments: [
{ comment: 'Im a new comment' },
{ comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' },
],
existingComments: [
{ comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' },
],
user: 'lily',
})
).toThrowErrorMatchingInlineSnapshot(
`"When trying to update a comment, \\"created_at\\" and \\"created_by\\" must be present"`
);
});
test('it throws an error if user tries to add comment formatted as existing comment when none yet exist', () => {
expect(() =>
transformUpdateCommentsToComments({
comments: [
{ comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' },
{ comment: 'Im a new comment' },
],
existingComments: [],
user: 'lily',
})
).toThrowErrorMatchingInlineSnapshot(`"Only new comments may be added"`);
});
test('it throws if empty comment exists', () => {
expect(() =>
transformUpdateCommentsToComments({
comments: [
{ comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' },
{ comment: ' ' },
],
existingComments: [
{ comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' },
],
user: 'lily',
})
).toThrowErrorMatchingInlineSnapshot(`"Empty comments not allowed"`);
});
});
describe('#transformCreateCommentsToComments', () => {
test('it returns "undefined" if "comments" is "undefined"', () => {
const comments = transformCreateCommentsToComments({
comments: undefined,
user: 'lily',
});
expect(comments).toBeUndefined();
});
test('it formats newly added comments', () => {
const comments = transformCreateCommentsToComments({
comments: [{ comment: 'Im a new comment' }, { comment: 'Im another new comment' }],
user: 'lily',
});
expect(comments).toEqual([
{
comment: 'Im a new comment',
created_at: anchor,
created_by: 'lily',
},
{
comment: 'Im another new comment',
created_at: anchor,
created_by: 'lily',
},
]);
});
test('it throws an error if user tries to add an empty comment', () => {
expect(() =>
transformCreateCommentsToComments({
comments: [{ comment: ' ' }],
user: 'lily',
})
).toThrowErrorMatchingInlineSnapshot(`"Empty comments not allowed"`);
});
});
describe('#transformUpdateComments', () => {
test('it updates comment and adds "updated_at" and "updated_by"', () => {
const comments = transformUpdateComments({
comment: {
comment: 'Im an old comment that is trying to be updated',
created_at: DATE_NOW,
created_by: 'lily',
},
existingComment: {
comment: 'Im an old comment',
created_at: DATE_NOW,
created_by: 'lily',
},
user: 'lily',
});
expect(comments).toEqual({
comment: 'Im an old comment that is trying to be updated',
created_at: '2020-04-20T15:25:31.830Z',
created_by: 'lily',
updated_at: anchor,
updated_by: 'lily',
});
});
test('it throws if user tries to update an existing comment that is not their own', () => {
expect(() =>
transformUpdateComments({
comment: {
comment: 'Im an old comment that is trying to be updated',
created_at: DATE_NOW,
created_by: 'lily',
},
existingComment: {
comment: 'Im an old comment',
created_at: DATE_NOW,
created_by: 'lily',
},
user: 'bane',
})
).toThrowErrorMatchingInlineSnapshot(`"Not authorized to edit others comments"`);
});
test('it throws if user tries to update an existing comments timestamp', () => {
expect(() =>
transformUpdateComments({
comment: {
comment: 'Im an old comment that is trying to be updated',
created_at: anchor,
created_by: 'lily',
},
existingComment: {
comment: 'Im an old comment',
created_at: DATE_NOW,
created_by: 'lily',
},
user: 'lily',
})
).toThrowErrorMatchingInlineSnapshot(`"Unable to update comment"`);
});
});
describe('#isCommentEqual', () => {
test('it returns false if "comment" values differ', () => {
const result = isCommentEqual(
{
comment: 'some old comment',
created_at: DATE_NOW,
created_by: USER,
},
{
comment: 'some older comment',
created_at: DATE_NOW,
created_by: USER,
}
);
expect(result).toBeFalsy();
});
test('it returns false if "created_at" values differ', () => {
const result = isCommentEqual(
{
comment: 'some old comment',
created_at: DATE_NOW,
created_by: USER,
},
{
comment: 'some old comment',
created_at: anchor,
created_by: USER,
}
);
expect(result).toBeFalsy();
});
test('it returns false if "created_by" values differ', () => {
const result = isCommentEqual(
{
comment: 'some old comment',
created_at: DATE_NOW,
created_by: USER,
},
{
comment: 'some old comment',
created_at: DATE_NOW,
created_by: 'lily',
}
);
expect(result).toBeFalsy();
});
test('it returns true if comment values are equivalent', () => {
const result = isCommentEqual(
{
comment: 'some old comment',
created_at: DATE_NOW,
created_by: USER,
},
{
created_at: DATE_NOW,
created_by: USER,
// Disabling to assure that order doesn't matter
// eslint-disable-next-line sort-keys
comment: 'some old comment',
}
);
expect(result).toBeTruthy();
});
});
});

View file

@ -6,15 +6,21 @@
import { SavedObject, SavedObjectsFindResponse, SavedObjectsUpdateResponse } from 'kibana/server';
import { ErrorWithStatusCode } from '../../error_with_status_code';
import {
Comments,
CommentsArray,
CommentsArrayOrUndefined,
CommentsPartialArrayOrUndefined,
CreateComments,
CreateCommentsArrayOrUndefined,
ExceptionListItemSchema,
ExceptionListSchema,
ExceptionListSoSchema,
FoundExceptionListItemSchema,
FoundExceptionListSchema,
NamespaceType,
UpdateCommentsArrayOrUndefined,
comments as commentsSchema,
} from '../../../common/schemas';
import {
SavedObjectType,
@ -251,21 +257,103 @@ export const transformSavedObjectsToFoundExceptionList = ({
};
};
export const transformComments = ({
/*
* Determines whether two comments are equal, this is a very
* naive implementation, not meant to be used for deep equality of complex objects
*/
export const isCommentEqual = (commentA: Comments, commentB: Comments): boolean => {
const a = Object.values(commentA).sort().join();
const b = Object.values(commentB).sort().join();
return a === b;
};
export const transformUpdateCommentsToComments = ({
comments,
existingComments,
user,
}: {
comments: UpdateCommentsArrayOrUndefined;
existingComments: CommentsArray;
user: string;
}): CommentsArray => {
const newComments = comments ?? [];
if (newComments.length < existingComments.length) {
throw new ErrorWithStatusCode(
'Comments cannot be deleted, only new comments may be added',
403
);
} else {
return newComments.flatMap((c, index) => {
const existingComment = existingComments[index];
if (commentsSchema.is(existingComment) && !commentsSchema.is(c)) {
throw new ErrorWithStatusCode(
'When trying to update a comment, "created_at" and "created_by" must be present',
403
);
} else if (commentsSchema.is(c) && existingComment == null) {
throw new ErrorWithStatusCode('Only new comments may be added', 403);
} else if (
commentsSchema.is(c) &&
existingComment != null &&
!isCommentEqual(c, existingComment)
) {
return transformUpdateComments({ comment: c, existingComment, user });
} else {
return transformCreateCommentsToComments({ comments: [c], user }) ?? [];
}
});
}
};
export const transformUpdateComments = ({
comment,
existingComment,
user,
}: {
comment: Comments;
existingComment: Comments;
user: string;
}): Comments => {
if (comment.created_by !== user) {
// existing comment is being edited, can only be edited by author
throw new ErrorWithStatusCode('Not authorized to edit others comments', 401);
} else if (existingComment.created_at !== comment.created_at) {
throw new ErrorWithStatusCode('Unable to update comment', 403);
} else if (comment.comment.trim().length === 0) {
throw new ErrorWithStatusCode('Empty comments not allowed', 403);
} else {
const dateNow = new Date().toISOString();
return {
...comment,
updated_at: dateNow,
updated_by: user,
};
}
};
export const transformCreateCommentsToComments = ({
comments,
user,
}: {
comments: CommentsPartialArrayOrUndefined;
comments: CreateCommentsArrayOrUndefined;
user: string;
}): CommentsArrayOrUndefined => {
const dateNow = new Date().toISOString();
if (comments != null) {
return comments.map((comment) => {
return {
comment: comment.comment,
created_at: comment.created_at ?? dateNow,
created_by: comment.created_by ?? user,
};
return comments.map((c: CreateComments) => {
if (c.comment.trim().length === 0) {
throw new ErrorWithStatusCode('Empty comments not allowed', 403);
} else {
return {
comment: c.comment,
created_at: dateNow,
created_by: user,
};
}
});
} else {
return comments;

View file

@ -37,7 +37,7 @@ import {
getEntryMatchAnyMock,
getEntriesArrayMock,
} from '../../../../../lists/common/schemas/types/entries.mock';
import { getCommentsMock } from '../../../../../lists/common/schemas/types/comments.mock';
import { getCommentsArrayMock } from '../../../../../lists/common/schemas/types/comments.mock';
describe('Exception helpers', () => {
beforeEach(() => {
@ -382,7 +382,7 @@ describe('Exception helpers', () => {
describe('#getFormattedComments', () => {
test('it returns formatted comment object with username and timestamp', () => {
const payload = getCommentsMock();
const payload = getCommentsArrayMock();
const result = getFormattedComments(payload);
expect(result[0].username).toEqual('some user');
@ -390,7 +390,7 @@ describe('Exception helpers', () => {
});
test('it returns formatted timeline icon with comment users initial', () => {
const payload = getCommentsMock();
const payload = getCommentsArrayMock();
const result = getFormattedComments(payload);
const wrapper = mount<React.ReactElement>(result[0].timelineIcon as React.ReactElement);
@ -399,12 +399,12 @@ describe('Exception helpers', () => {
});
test('it returns comment text', () => {
const payload = getCommentsMock();
const payload = getCommentsArrayMock();
const result = getFormattedComments(payload);
const wrapper = mount<React.ReactElement>(result[0].children as React.ReactElement);
expect(wrapper.text()).toEqual('some comment');
expect(wrapper.text()).toEqual('some old comment');
});
});
});

View file

@ -10,9 +10,10 @@ import { capitalize } from 'lodash';
import moment from 'moment';
import * as i18n from './translations';
import { FormattedEntry, OperatorOption, DescriptionListItem, Comment } from './types';
import { FormattedEntry, OperatorOption, DescriptionListItem } from './types';
import { EXCEPTION_OPERATORS, isOperator } from './operators';
import {
CommentsArray,
Entry,
EntriesArray,
ExceptionListItemSchema,
@ -183,7 +184,7 @@ export const getDescriptionListContent = (
*
* @param comments ExceptionItem.comments
*/
export const getFormattedComments = (comments: Comment[]): EuiCommentProps[] =>
export const getFormattedComments = (comments: CommentsArray): EuiCommentProps[] =>
comments.map((comment) => ({
username: comment.created_by,
timestamp: moment(comment.created_at).format('on MMM Do YYYY @ HH:mm:ss'),

View file

@ -26,12 +26,6 @@ export interface DescriptionListItem {
description: NonNullable<ReactNode>;
}
export interface Comment {
created_by: string;
created_at: string;
comment: string;
}
export enum ExceptionListType {
DETECTION_ENGINE = 'detection',
ENDPOINT = 'endpoint',

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 { getCommentsMock } from '../../../../../../../lists/common/schemas/types/comments.mock';
import { getCommentsArrayMock } from '../../../../../../../lists/common/schemas/types/comments.mock';
describe('ExceptionDetails', () => {
beforeEach(() => {
@ -42,7 +42,7 @@ describe('ExceptionDetails', () => {
test('it renders comments button if comments exist', () => {
const exceptionItem = getExceptionListItemSchemaMock();
exceptionItem.comments = getCommentsMock();
exceptionItem.comments = getCommentsArrayMock();
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ExceptionDetails
@ -60,7 +60,7 @@ describe('ExceptionDetails', () => {
test('it renders correct number of comments', () => {
const exceptionItem = getExceptionListItemSchemaMock();
exceptionItem.comments = [getCommentsMock()[0]];
exceptionItem.comments = [getCommentsArrayMock()[0]];
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ExceptionDetails
@ -78,7 +78,7 @@ describe('ExceptionDetails', () => {
test('it renders comments plural if more than one', () => {
const exceptionItem = getExceptionListItemSchemaMock();
exceptionItem.comments = getCommentsMock();
exceptionItem.comments = getCommentsArrayMock();
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ExceptionDetails
@ -96,7 +96,7 @@ describe('ExceptionDetails', () => {
test('it renders comments show text if "showComments" is false', () => {
const exceptionItem = getExceptionListItemSchemaMock();
exceptionItem.comments = getCommentsMock();
exceptionItem.comments = getCommentsArrayMock();
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ExceptionDetails
@ -114,7 +114,7 @@ describe('ExceptionDetails', () => {
test('it renders comments hide text if "showComments" is true', () => {
const exceptionItem = getExceptionListItemSchemaMock();
exceptionItem.comments = getCommentsMock();
exceptionItem.comments = getCommentsArrayMock();
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ExceptionDetails
@ -133,7 +133,7 @@ describe('ExceptionDetails', () => {
test('it invokes "onCommentsClick" when comments button clicked', () => {
const mockOnCommentsClick = jest.fn();
const exceptionItem = getExceptionListItemSchemaMock();
exceptionItem.comments = getCommentsMock();
exceptionItem.comments = getCommentsArrayMock();
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ExceptionDetails

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 { getCommentsMock } from '../../../../../../../lists/common/schemas/types/comments.mock';
import { getCommentsArrayMock } from '../../../../../../../lists/common/schemas/types/comments.mock';
addDecorator((storyFn) => (
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>{storyFn()}</ThemeProvider>
@ -68,7 +68,7 @@ storiesOf('Components|ExceptionItem', module)
const payload = getExceptionListItemSchemaMock();
payload._tags = [];
payload.description = '';
payload.comments = getCommentsMock();
payload.comments = getCommentsArrayMock();
payload.entries = [
{
field: 'actingProcess.file.signer',
@ -106,7 +106,7 @@ storiesOf('Components|ExceptionItem', module)
})
.add('with everything', () => {
const payload = getExceptionListItemSchemaMock();
payload.comments = getCommentsMock();
payload.comments = getCommentsArrayMock();
return (
<ExceptionItem
loadingItemIds={[]}

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 { getCommentsMock } from '../../../../../../../lists/common/schemas/types/comments.mock';
import { getCommentsArrayMock } from '../../../../../../../lists/common/schemas/types/comments.mock';
describe('ExceptionItem', () => {
it('it renders ExceptionDetails and ExceptionEntries', () => {
@ -83,7 +83,7 @@ describe('ExceptionItem', () => {
it('it renders comment accordion closed to begin with', () => {
const mockOnDeleteException = jest.fn();
const exceptionItem = getExceptionListItemSchemaMock();
exceptionItem.comments = getCommentsMock();
exceptionItem.comments = getCommentsArrayMock();
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ExceptionItem
@ -102,7 +102,7 @@ describe('ExceptionItem', () => {
it('it renders comment accordion open when showComments is true', () => {
const mockOnDeleteException = jest.fn();
const exceptionItem = getExceptionListItemSchemaMock();
exceptionItem.comments = getCommentsMock();
exceptionItem.comments = getCommentsArrayMock();
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ExceptionItem

View file

@ -15,6 +15,7 @@ export {
UseExceptionListSuccess,
} from '../../lists/public';
export {
CommentsArray,
ExceptionListSchema,
ExceptionListItemSchema,
Entry,