[8.6] Fixing delete action and empty message bugs (#145463) (#146134)

# Backport

This will backport the following commits from `main` to `8.6`:
- [Fixing delete action and empty message bugs
(#145463)](https://github.com/elastic/kibana/pull/145463)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Jonathan
Buttner","email":"56361221+jonathan-buttner@users.noreply.github.com"},"sourceCommit":{"committedDate":"2022-11-23T11:40:12Z","message":"Fixing
delete action and empty message bugs (#145463)\n\nThis PR addresses two
issues:\r\n\r\n1. This bug:
https://github.com/elastic/kibana/issues/144128 when a user\r\ndeletes
an action at say index 0 within the rule form , if the action
at\r\nindex 1 was a different type (`createAlert` vs `closeAlert`) the
user\r\nwould lose the information in the action.\r\n - Now the
information from the action at index 1 should persistent\r\n2. A user
could save the rule when the `message` field was a string of\r\nall
spaces\r\n - Now the user should not be able to save the
rule\r\n\r\nFixes:
https://github.com/elastic/kibana/issues/144128\r\n\r\n### Deleting the
action\r\n\r\n\r\n202298074-34878a4c-5fa0-4d8d-8192-ae5f43439cd0.mov\r\n\r\n\r\n\r\n###
Whitespace message
field\r\n\r\n\r\n\r\nhttps://user-images.githubusercontent.com/56361221/202298085-95f1fe3f-2652-4b92-86c7-b49b0b23d913.mov","sha":"ba16f55c5e3357737ae7f4ffc5381988a1c31b8d","branchLabelMapping":{"^v8.7.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["bug","release_note:skip","Feature:Actions","Team:ResponseOps","v8.6.0","v8.7.0"],"number":145463,"url":"https://github.com/elastic/kibana/pull/145463","mergeCommit":{"message":"Fixing
delete action and empty message bugs (#145463)\n\nThis PR addresses two
issues:\r\n\r\n1. This bug:
https://github.com/elastic/kibana/issues/144128 when a user\r\ndeletes
an action at say index 0 within the rule form , if the action
at\r\nindex 1 was a different type (`createAlert` vs `closeAlert`) the
user\r\nwould lose the information in the action.\r\n - Now the
information from the action at index 1 should persistent\r\n2. A user
could save the rule when the `message` field was a string of\r\nall
spaces\r\n - Now the user should not be able to save the
rule\r\n\r\nFixes:
https://github.com/elastic/kibana/issues/144128\r\n\r\n### Deleting the
action\r\n\r\n\r\n202298074-34878a4c-5fa0-4d8d-8192-ae5f43439cd0.mov\r\n\r\n\r\n\r\n###
Whitespace message
field\r\n\r\n\r\n\r\nhttps://user-images.githubusercontent.com/56361221/202298085-95f1fe3f-2652-4b92-86c7-b49b0b23d913.mov","sha":"ba16f55c5e3357737ae7f4ffc5381988a1c31b8d"}},"sourceBranch":"main","suggestedTargetBranches":["8.6"],"targetPullRequestStates":[{"branch":"8.6","label":"v8.6.0","labelRegex":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v8.7.0","labelRegex":"^v8.7.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/145463","number":145463,"mergeCommit":{"message":"Fixing
delete action and empty message bugs (#145463)\n\nThis PR addresses two
issues:\r\n\r\n1. This bug:
https://github.com/elastic/kibana/issues/144128 when a user\r\ndeletes
an action at say index 0 within the rule form , if the action
at\r\nindex 1 was a different type (`createAlert` vs `closeAlert`) the
user\r\nwould lose the information in the action.\r\n - Now the
information from the action at index 1 should persistent\r\n2. A user
could save the rule when the `message` field was a string of\r\nall
spaces\r\n - Now the user should not be able to save the
rule\r\n\r\nFixes:
https://github.com/elastic/kibana/issues/144128\r\n\r\n### Deleting the
action\r\n\r\n\r\n202298074-34878a4c-5fa0-4d8d-8192-ae5f43439cd0.mov\r\n\r\n\r\n\r\n###
Whitespace message
field\r\n\r\n\r\n\r\nhttps://user-images.githubusercontent.com/56361221/202298085-95f1fe3f-2652-4b92-86c7-b49b0b23d913.mov","sha":"ba16f55c5e3357737ae7f4ffc5381988a1c31b8d"}}]}]
BACKPORT-->

Co-authored-by: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2022-11-23 08:37:19 -05:00 committed by GitHub
parent 411da5f0db
commit d2bd1a4992
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 486 additions and 213 deletions

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { OpsgenieCloseAlertExample } from '../../../../server/connector_types/stack/opsgenie/test_schema';
import { isPartialCloseAlertSchema } from './close_alert_schema';
describe('close_alert_schema', () => {
describe('isPartialCloseAlertSchema', () => {
it('returns true with an empty object', () => {
expect(isPartialCloseAlertSchema({})).toBeTruthy();
});
it('returns false with undefined', () => {
expect(isPartialCloseAlertSchema(undefined)).toBeFalsy();
});
it('returns false with an invalid field', () => {
expect(isPartialCloseAlertSchema({ invalidField: 'a' })).toBeFalsy();
});
it('returns true with only the note field', () => {
expect(isPartialCloseAlertSchema({ note: 'a' })).toBeTruthy();
});
it('returns true with the Opsgenie close alert example', () => {
expect(isPartialCloseAlertSchema(OpsgenieCloseAlertExample)).toBeTruthy();
});
});
});

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as rt from 'io-ts';
import { decodeSchema } from './schema_utils';
/**
* This schema must match the CloseAlertParamsSchema in x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/schema.ts
* except that it makes all fields partial.
*/
const CloseAlertSchema = rt.exact(
rt.partial({
alias: rt.string,
user: rt.string,
source: rt.string,
note: rt.string,
})
);
type CloseAlertSchemaType = rt.TypeOf<typeof CloseAlertSchema>;
export const isPartialCloseAlertSchema = (data: unknown): data is CloseAlertSchemaType => {
try {
decodeSchema(CloseAlertSchema, data);
return true;
} catch (error) {
return false;
}
};

View file

@ -207,3 +207,5 @@ const CreateAlertComponent: React.FC<CreateAlertProps> = ({
CreateAlertComponent.displayName = 'CreateAlert';
export const CreateAlert = React.memo(CreateAlertComponent);
export { isPartialCreateAlertSchema } from './schema';

View file

@ -11,7 +11,8 @@ import { JsonEditorWithMessageVariables } from '@kbn/triggers-actions-ui-plugin/
import type { OpsgenieCreateAlertParams } from '../../../../../server/connector_types/stack';
import * as i18n from './translations';
import { CreateAlertProps } from '.';
import { decodeCreateAlert, isDecodeError } from './schema';
import { decodeCreateAlert } from './schema';
import { isDecodeError } from '../schema_utils';
export type JsonEditorProps = Pick<
CreateAlertProps,

View file

@ -5,104 +5,137 @@
* 2.0.
*/
import { decodeCreateAlert } from './schema';
import { decodeCreateAlert, isPartialCreateAlertSchema } from './schema';
import {
OpsgenieCreateAlertExample,
ValidCreateAlertSchema,
} from '../../../../../server/connector_types/stack/opsgenie/test_schema';
describe('decodeCreateAlert', () => {
it('throws an error when the message field is not present', () => {
expect(() => decodeCreateAlert({ alias: '123' })).toThrowErrorMatchingInlineSnapshot(
`"[message]: expected value of type [string] but got [undefined]"`
);
describe('schema', () => {
describe('decodeCreateAlert', () => {
it('throws an error when the message field is not present', () => {
expect(() => decodeCreateAlert({ alias: '123' })).toThrowErrorMatchingInlineSnapshot(
`"[message]: expected value of type [string] but got [undefined]"`
);
});
it('throws an error when the message field is only spaces', () => {
expect(() => decodeCreateAlert({ message: ' ' })).toThrowErrorMatchingInlineSnapshot(
`"[message]: must be populated with a value other than just whitespace"`
);
});
it('throws an error when the message field is an empty string', () => {
expect(() => decodeCreateAlert({ message: '' })).toThrowErrorMatchingInlineSnapshot(
`"[message]: must be populated with a value other than just whitespace"`
);
});
it('throws an error when additional fields are present in the data that are not defined in the schema', () => {
expect(() =>
decodeCreateAlert({ invalidField: 'hi', message: 'hi' })
).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`);
});
it('throws an error when additional fields are present in responders with name field than in the schema', () => {
expect(() =>
decodeCreateAlert({
message: 'hi',
responders: [{ name: 'sam', type: 'team', invalidField: 'scott' }],
})
).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`);
});
it('throws an error when additional fields are present in responders with id field than in the schema', () => {
expect(() =>
decodeCreateAlert({
message: 'hi',
responders: [{ id: 'id', type: 'team', invalidField: 'scott' }],
})
).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`);
});
it('throws an error when additional fields are present in visibleTo with name and type=team', () => {
expect(() =>
decodeCreateAlert({
message: 'hi',
visibleTo: [{ name: 'sam', type: 'team', invalidField: 'scott' }],
})
).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`);
});
it('throws an error when additional fields are present in visibleTo with id and type=team', () => {
expect(() =>
decodeCreateAlert({
message: 'hi',
visibleTo: [{ id: 'id', type: 'team', invalidField: 'scott' }],
})
).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`);
});
it('throws an error when additional fields are present in visibleTo with id and type=user', () => {
expect(() =>
decodeCreateAlert({
message: 'hi',
visibleTo: [{ id: 'id', type: 'user', invalidField: 'scott' }],
})
).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`);
});
it('throws an error when additional fields are present in visibleTo with username and type=user', () => {
expect(() =>
decodeCreateAlert({
message: 'hi',
visibleTo: [{ username: 'sam', type: 'user', invalidField: 'scott' }],
})
).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`);
});
it('throws an error when details is a record of string to number', () => {
expect(() =>
decodeCreateAlert({
message: 'hi',
details: { id: 1 },
})
).toThrowErrorMatchingInlineSnapshot(`"Invalid value \\"1\\" supplied to \\"details.id\\""`);
});
it.each([
['ValidCreateAlertSchema', ValidCreateAlertSchema],
['OpsgenieCreateAlertExample', OpsgenieCreateAlertExample],
])('validates the test object [%s] correctly', (objectName, testObject) => {
expect(() => decodeCreateAlert(testObject)).not.toThrow();
});
});
it('throws an error when the message field is only spaces', () => {
expect(() => decodeCreateAlert({ message: ' ' })).toThrowErrorMatchingInlineSnapshot(
`"[message]: must be populated with a value other than just whitespace"`
);
});
describe('isPartialCreateAlertSchema', () => {
const { message, ...createAlertSchemaWithoutMessage } = ValidCreateAlertSchema;
const { message: ignoreMessage2, ...opsgenieCreateAlertExampleWithoutMessage } =
OpsgenieCreateAlertExample;
it('throws an error when the message field is an empty string', () => {
expect(() => decodeCreateAlert({ message: '' })).toThrowErrorMatchingInlineSnapshot(
`"[message]: must be populated with a value other than just whitespace"`
);
});
it('returns true with an empty object', () => {
expect(isPartialCreateAlertSchema({})).toBeTruthy();
});
it('throws an error when additional fields are present in the data that are not defined in the schema', () => {
expect(() =>
decodeCreateAlert({ invalidField: 'hi', message: 'hi' })
).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`);
});
it('returns false with undefined', () => {
expect(isPartialCreateAlertSchema(undefined)).toBeFalsy();
});
it('throws an error when additional fields are present in responders with name field than in the schema', () => {
expect(() =>
decodeCreateAlert({
message: 'hi',
responders: [{ name: 'sam', type: 'team', invalidField: 'scott' }],
})
).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`);
});
it('returns true with only alias', () => {
expect(isPartialCreateAlertSchema({ alias: 'abc' })).toBeTruthy();
});
it('throws an error when additional fields are present in responders with id field than in the schema', () => {
expect(() =>
decodeCreateAlert({
message: 'hi',
responders: [{ id: 'id', type: 'team', invalidField: 'scott' }],
})
).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`);
});
it.each([
['ValidCreateAlertSchema', ValidCreateAlertSchema],
['OpsgenieCreateAlertExample', OpsgenieCreateAlertExample],
['createAlertSchemaWithoutMessage', createAlertSchemaWithoutMessage],
['opsgenieCreateAlertExampleWithoutMessage', opsgenieCreateAlertExampleWithoutMessage],
])('returns true with the test object [%s]', (objectName, testObject) => {
expect(isPartialCreateAlertSchema(testObject)).toBeTruthy();
});
it('throws an error when additional fields are present in visibleTo with name and type=team', () => {
expect(() =>
decodeCreateAlert({
message: 'hi',
visibleTo: [{ name: 'sam', type: 'team', invalidField: 'scott' }],
})
).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`);
});
it('throws an error when additional fields are present in visibleTo with id and type=team', () => {
expect(() =>
decodeCreateAlert({
message: 'hi',
visibleTo: [{ id: 'id', type: 'team', invalidField: 'scott' }],
})
).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`);
});
it('throws an error when additional fields are present in visibleTo with id and type=user', () => {
expect(() =>
decodeCreateAlert({
message: 'hi',
visibleTo: [{ id: 'id', type: 'user', invalidField: 'scott' }],
})
).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`);
});
it('throws an error when additional fields are present in visibleTo with username and type=user', () => {
expect(() =>
decodeCreateAlert({
message: 'hi',
visibleTo: [{ username: 'sam', type: 'user', invalidField: 'scott' }],
})
).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`);
});
it('throws an error when details is a record of string to number', () => {
expect(() =>
decodeCreateAlert({
message: 'hi',
details: { id: 1 },
})
).toThrowErrorMatchingInlineSnapshot(`"Invalid value \\"1\\" supplied to \\"details.id\\""`);
});
it.each([
['ValidCreateAlertSchema', ValidCreateAlertSchema],
['OpsgenieCreateAlertExample', OpsgenieCreateAlertExample],
])('validates the test object [%s] correctly', (objectName, testObject) => {
expect(() => decodeCreateAlert(testObject)).not.toThrow();
it('returns false with an additional property', () => {
expect(isPartialCreateAlertSchema({ anInvalidField: 'a' })).toBeFalsy();
});
});
});

View file

@ -5,12 +5,10 @@
* 2.0.
*/
import { Either, fold } from 'fp-ts/lib/Either';
import { pipe } from 'fp-ts/lib/pipeable';
import { Either } from 'fp-ts/lib/Either';
import * as rt from 'io-ts';
import { exactCheck } from '@kbn/securitysolution-io-ts-utils';
import { identity } from 'fp-ts/lib/function';
import { isEmpty, isObject } from 'lodash';
import { isEmpty } from 'lodash';
import { decodeSchema } from '../schema_utils';
import * as i18n from './translations';
const MessageNonEmptyString = new rt.Type<string, string, unknown>(
@ -37,6 +35,42 @@ const ResponderTypes = rt.union([
rt.literal('schedule'),
]);
const CreateAlertSchemaOptionalProps = rt.partial(
rt.type({
alias: rt.string,
description: rt.string,
responders: rt.array(
rt.union([
rt.strict({ name: rt.string, type: ResponderTypes }),
rt.strict({ id: rt.string, type: ResponderTypes }),
rt.strict({ username: rt.string, type: rt.literal('user') }),
])
),
visibleTo: rt.array(
rt.union([
rt.strict({ name: rt.string, type: rt.literal('team') }),
rt.strict({ id: rt.string, type: rt.literal('team') }),
rt.strict({ id: rt.string, type: rt.literal('user') }),
rt.strict({ username: rt.string, type: rt.literal('user') }),
])
),
actions: rt.array(rt.string),
tags: rt.array(rt.string),
details: rt.record(rt.string, rt.string),
entity: rt.string,
source: rt.string,
priority: rt.union([
rt.literal('P1'),
rt.literal('P2'),
rt.literal('P3'),
rt.literal('P4'),
rt.literal('P5'),
]),
user: rt.string,
note: rt.string,
}).props
);
/**
* This schema is duplicated from the server. The only difference is that it is using io-ts vs kbn-schema.
* NOTE: This schema must be the same as defined here: x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/schema.ts
@ -51,92 +85,38 @@ const ResponderTypes = rt.union([
*/
const CreateAlertSchema = rt.intersection([
rt.strict({ message: MessageNonEmptyString }),
rt.exact(
rt.partial({
alias: rt.string,
description: rt.string,
responders: rt.array(
rt.union([
rt.strict({ name: rt.string, type: ResponderTypes }),
rt.strict({ id: rt.string, type: ResponderTypes }),
rt.strict({ username: rt.string, type: rt.literal('user') }),
])
),
visibleTo: rt.array(
rt.union([
rt.strict({ name: rt.string, type: rt.literal('team') }),
rt.strict({ id: rt.string, type: rt.literal('team') }),
rt.strict({ id: rt.string, type: rt.literal('user') }),
rt.strict({ username: rt.string, type: rt.literal('user') }),
])
),
actions: rt.array(rt.string),
tags: rt.array(rt.string),
details: rt.record(rt.string, rt.string),
entity: rt.string,
source: rt.string,
priority: rt.union([
rt.literal('P1'),
rt.literal('P2'),
rt.literal('P3'),
rt.literal('P4'),
rt.literal('P5'),
]),
user: rt.string,
note: rt.string,
})
),
rt.exact(CreateAlertSchemaOptionalProps),
]);
export const formatErrors = (errors: rt.Errors): string[] => {
const err = errors.map((error) => {
if (error.message != null) {
return error.message;
} else {
const keyContext = error.context
.filter(
(entry) => entry.key != null && !Number.isInteger(+entry.key) && entry.key.trim() !== ''
)
.map((entry) => entry.key)
.join('.');
const nameContext = error.context.find(
(entry) => entry.type != null && entry.type.name != null && entry.type.name.length > 0
);
const suppliedValue =
keyContext !== '' ? keyContext : nameContext != null ? nameContext.type.name : '';
const value = isObject(error.value) ? JSON.stringify(error.value) : error.value;
return `Invalid value "${value}" supplied to "${suppliedValue}"`;
}
});
return [...new Set(err)];
};
type CreateAlertSchemaType = rt.TypeOf<typeof CreateAlertSchema>;
export const decodeCreateAlert = (data: unknown): CreateAlertSchemaType => {
const onLeft = (errors: rt.Errors) => {
throw new DecodeError(formatErrors(errors));
};
/**
* This schema should match CreateAlertSchema except that all fields are optional and message is only enforced as a string.
* Enforcing message as only a string accommodates the following scenario:
*
* If a user deletes an action in the rule form at index 0, and the
* action at index 1 had the message field specified with all spaces, the message field is technically invalid but
* we want to allow it to pass the partial check so that the form is still populated with the invalid value. Otherwise the
* forum will be reset and the user would lose the information (although it is invalid) they had entered
*/
const PartialCreateAlertSchema = rt.exact(
rt.intersection([
rt.partial(rt.type({ message: rt.string }).props),
CreateAlertSchemaOptionalProps,
])
);
const onRight = (a: CreateAlertSchemaType): CreateAlertSchemaType => identity(a);
type PartialCreateAlertSchemaType = rt.TypeOf<typeof PartialCreateAlertSchema>;
return pipe(
CreateAlertSchema.decode(data),
(decoded) => exactCheck(data, decoded),
fold(onLeft, onRight)
);
export const isPartialCreateAlertSchema = (data: unknown): data is PartialCreateAlertSchemaType => {
try {
decodeSchema(PartialCreateAlertSchema, data);
return true;
} catch (error) {
return false;
}
};
export class DecodeError extends Error {
constructor(public readonly decodeErrors: string[]) {
super(decodeErrors.join());
this.name = this.constructor.name;
}
}
export function isDecodeError(error: unknown): error is DecodeError {
return error instanceof DecodeError;
}
export const decodeCreateAlert = (data: unknown): CreateAlertSchemaType => {
return decodeSchema(CreateAlertSchema, data);
};

View file

@ -37,13 +37,6 @@ export const DESCRIPTION_FIELD_LABEL = i18n.translate(
}
);
export const MESSAGE_FIELD_IS_REQUIRED = i18n.translate(
'xpack.stackConnectors.components.opsgenie.messageFieldRequired',
{
defaultMessage: '"message" field must be populated with a value other than just whitespace',
}
);
export const USE_JSON_EDITOR_LABEL = i18n.translate(
'xpack.stackConnectors.components.opsgenie.useJsonEditorLabel',
{

View file

@ -11,6 +11,7 @@ import {
ActionTypeModel as ConnectorTypeModel,
GenericValidationResult,
} from '@kbn/triggers-actions-ui-plugin/public';
import { isEmpty } from 'lodash';
import { RULE_TAGS_TEMPLATE } from '../../../../common/opsgenie';
import { OpsgenieSubActions } from '../../../../common';
import type {
@ -74,13 +75,13 @@ const validateParams = async (
errors,
};
if (
actionParams.subAction === OpsgenieSubActions.CreateAlert &&
!actionParams?.subActionParams?.message?.length
) {
errors['subActionParams.message'].push(translations.MESSAGE_IS_REQUIRED);
if (actionParams.subAction === OpsgenieSubActions.CreateAlert) {
if (!actionParams?.subActionParams?.message?.length) {
errors['subActionParams.message'].push(translations.MESSAGE_IS_REQUIRED);
} else if (isEmpty(actionParams?.subActionParams?.message?.trim())) {
errors['subActionParams.message'].push(translations.MESSAGE_NON_WHITESPACE);
}
}
if (
actionParams.subAction === OpsgenieSubActions.CloseAlert &&
!actionParams?.subActionParams?.alias?.length

View file

@ -195,7 +195,7 @@ describe('OpsgenieParamFields', () => {
expect(screen.queryByText('Message')).not.toBeInTheDocument();
});
it('preserves the previous alias value when switching between the create and close alert event actions', async () => {
it('does not call edit action when a component rerenders with subActionParams that match the new subAction', async () => {
const { rerender } = render(<OpsgenieParamFields {...defaultCreateAlertProps} />);
expect(screen.getByDisplayValue('hello')).toBeInTheDocument();
@ -220,17 +220,78 @@ describe('OpsgenieParamFields', () => {
expect(screen.queryByDisplayValue('hello')).not.toBeInTheDocument();
expect(editAction).toBeCalledTimes(2);
expect(editAction).toBeCalledTimes(1);
});
expect(editAction.mock.calls[1]).toMatchInlineSnapshot(`
Array [
"subActionParams",
Object {
"alias": "a new alias",
},
0,
]
`);
it('calls editAction with only the alias when the component is rerendered with mismatched closeAlert and params', async () => {
const { rerender } = render(<OpsgenieParamFields {...defaultCreateAlertProps} />);
expect(screen.getByDisplayValue('hello')).toBeInTheDocument();
expect(screen.getByDisplayValue('123')).toBeInTheDocument();
rerender(
<OpsgenieParamFields
{...{
...defaultCloseAlertProps,
actionParams: {
...defaultCloseAlertProps.actionParams,
subActionParams: {
alias: 'a new alias',
message: 'a message',
},
},
}}
/>
);
expect(screen.queryByDisplayValue('hello')).not.toBeInTheDocument();
expect(editAction).toBeCalledTimes(1);
expect(editAction.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"subActionParams",
Object {
"alias": "a new alias",
},
0,
]
`);
});
it('calls editAction with only the alias when the component is rerendered with mismatched createAlert and params', async () => {
const { rerender } = render(<OpsgenieParamFields {...defaultCloseAlertProps} />);
expect(screen.queryByText('Message')).not.toBeInTheDocument();
expect(screen.getByDisplayValue('456')).toBeInTheDocument();
rerender(
<OpsgenieParamFields
{...{
...defaultCreateAlertProps,
actionParams: {
...defaultCreateAlertProps.actionParams,
subActionParams: {
message: 'a message',
alias: 'a new alias',
invalidField: 'a note',
},
},
}}
/>
);
expect(screen.queryByDisplayValue('456')).not.toBeInTheDocument();
expect(editAction).toBeCalledTimes(1);
expect(editAction.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"subActionParams",
Object {
"alias": "a new alias",
},
0,
]
`);
});
it('only preserves the previous alias value when switching between the create and close alert event actions', async () => {
@ -262,14 +323,14 @@ describe('OpsgenieParamFields', () => {
expect(editAction).toBeCalledTimes(2);
expect(editAction.mock.calls[1]).toMatchInlineSnapshot(`
Array [
"subActionParams",
Object {
"alias": "a new alias",
},
0,
]
`);
Array [
"subActionParams",
Object {
"alias": "a new alias",
},
0,
]
`);
});
it('calls editAction when changing the subAction', async () => {

View file

@ -19,8 +19,9 @@ import type {
OpsgenieCreateAlertSubActionParams,
} from '../../../../server/connector_types/stack';
import * as i18n from './translations';
import { CreateAlert } from './create_alert';
import { CreateAlert, isPartialCreateAlertSchema } from './create_alert';
import { CloseAlert } from './close_alert';
import { isPartialCloseAlertSchema } from './close_alert_schema';
const actionOptions = [
{
@ -85,11 +86,20 @@ const OpsgenieParamFields: React.FC<ActionParamsProps<OpsgenieActionParams>> = (
useEffect(() => {
if (subAction != null && currentSubAction.current !== subAction) {
currentSubAction.current = subAction;
const params = subActionParams?.alias ? { alias: subActionParams.alias } : undefined;
editAction('subActionParams', params, index);
// check for a mismatch in the subAction and params, if the subAction does not match the params then we need to
// clear them by calling editAction. We can carry over the alias if it exists
if (
(subAction === OpsgenieSubActions.CreateAlert &&
!isPartialCreateAlertSchema(subActionParams)) ||
(subAction === OpsgenieSubActions.CloseAlert && !isPartialCloseAlertSchema(subActionParams))
) {
const params = subActionParams?.alias ? { alias: subActionParams.alias } : undefined;
editAction('subActionParams', params, index);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subAction, currentSubAction, subActionParams?.alias, index]);
}, [subAction, currentSubAction, index, subActionParams]);
return (
<>

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as rt from 'io-ts';
import { DecodeError, decodeSchema } from './schema_utils';
describe('schema_utils', () => {
describe('decodeSchema', () => {
const testSchema = rt.strict({ stringField: rt.string });
it('throws an error when stringField is not present', () => {
expect(() => decodeSchema(testSchema, { a: 1 })).toThrowErrorMatchingInlineSnapshot(
`"Invalid value \\"undefined\\" supplied to \\"stringField\\""`
);
});
it('throws an error when stringField is present but excess properties are also present', () => {
expect(() =>
decodeSchema(testSchema, { stringField: 'abc', a: 1 })
).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"a\\""`);
});
it('does not throw an error when the data matches the schema', () => {
expect(() => decodeSchema(testSchema, { stringField: 'abc' })).not.toThrow();
});
it('throws a DecodeError instance', () => {
expect(() => decodeSchema(testSchema, { a: 1 })).toThrowError(
new DecodeError([`Invalid value \"undefined\" supplied to \"stringField\"`])
);
});
});
});

View file

@ -0,0 +1,60 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { fold } from 'fp-ts/lib/Either';
import { pipe } from 'fp-ts/lib/pipeable';
import * as rt from 'io-ts';
import { exactCheck } from '@kbn/securitysolution-io-ts-utils';
import { identity } from 'fp-ts/lib/function';
import { isObject } from 'lodash';
const formatErrors = (errors: rt.Errors): string[] => {
const err = errors.map((error) => {
if (error.message != null) {
return error.message;
} else {
const keyContext = error.context
.filter(
(entry) => entry.key != null && !Number.isInteger(+entry.key) && entry.key.trim() !== ''
)
.map((entry) => entry.key)
.join('.');
const nameContext = error.context.find(
(entry) => entry.type != null && entry.type.name != null && entry.type.name.length > 0
);
const suppliedValue =
keyContext !== '' ? keyContext : nameContext != null ? nameContext.type.name : '';
const value = isObject(error.value) ? JSON.stringify(error.value) : error.value;
return `Invalid value "${value}" supplied to "${suppliedValue}"`;
}
});
return [...new Set(err)];
};
export const decodeSchema = <T>(schema: rt.Type<T>, data: unknown): T => {
const onLeft = (errors: rt.Errors) => {
throw new DecodeError(formatErrors(errors));
};
const onRight = (schemaType: T): T => identity(schemaType);
return pipe(schema.decode(data), (decoded) => exactCheck(data, decoded), fold(onLeft, onRight));
};
export class DecodeError extends Error {
constructor(public readonly decodeErrors: string[]) {
super(decodeErrors.join());
this.name = this.constructor.name;
}
}
export function isDecodeError(error: unknown): error is DecodeError {
return error instanceof DecodeError;
}

View file

@ -28,6 +28,11 @@ export const MESSAGE_IS_REQUIRED = i18n.translate(
}
);
export const MESSAGE_NON_WHITESPACE = i18n.translate(
'xpack.stackConnectors.components.opsgenie.messageNotWhitespaceForm',
{ defaultMessage: 'Message must be populated with a value other than just whitespace' }
);
export const ACTION_LABEL = i18n.translate(
'xpack.stackConnectors.components.opsgenie.actionLabel',
{

View file

@ -5,8 +5,12 @@
* 2.0.
*/
import { CreateAlertParamsSchema } from './schema';
import { OpsgenieCreateAlertExample, ValidCreateAlertSchema } from './test_schema';
import { CloseAlertParamsSchema, CreateAlertParamsSchema } from './schema';
import {
OpsgenieCloseAlertExample,
OpsgenieCreateAlertExample,
ValidCreateAlertSchema,
} from './test_schema';
describe('opsgenie schema', () => {
describe('CreateAlertParamsSchema', () => {
@ -17,4 +21,13 @@ describe('opsgenie schema', () => {
expect(() => CreateAlertParamsSchema.validate(testObject)).not.toThrow();
});
});
describe('CloseAlertParamsSchema', () => {
it.each([['OpsgenieCloseAlertExample', OpsgenieCloseAlertExample]])(
'validates the test object [%s] correctly',
(objectName, testObject) => {
expect(() => CloseAlertParamsSchema.validate(testObject)).not.toThrow();
}
);
});
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { CreateAlertParams } from './types';
import { CloseAlertParams, CreateAlertParams } from './types';
export const ValidCreateAlertSchema: CreateAlertParams = {
message: 'a message',
@ -94,3 +94,14 @@ export const OpsgenieCreateAlertExample: CreateAlertParams = {
entity: 'An example entity',
priority: 'P1',
};
/**
* This example is pulled from the sample curl request here: https://docs.opsgenie.com/docs/alert-api#close-alert
* with the addition of the alias field.
*/
export const OpsgenieCloseAlertExample: CloseAlertParams = {
alias: '123',
user: 'Monitoring Script',
source: 'AWS Lambda',
note: 'Action executed via Alert API',
};