[ResponseOps] adds mustache lambdas and array.asJSON (#150572)

partially resolves some issues in https://github.com/elastic/kibana/issues/84217

Adds Mustache lambdas for alerting actions to format dates with `{{#FormatDate}}`, evaluate math expressions with `{{#EvalMath}}`, and provide easier JSON formatting with `{{#ParseHjson}}` and a new `asJSON` property added to arrays.
This commit is contained in:
Patrick Mueller 2023-04-24 15:14:30 -04:00 committed by GitHub
parent 953437f05d
commit 4382e1cf32
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 545 additions and 243 deletions

View file

@ -0,0 +1,201 @@
/*
* 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 dedent from 'dedent';
import { renderMustacheString } from './mustache_renderer';
describe('mustache lambdas', () => {
describe('FormatDate', () => {
it('date with defaults is successful', () => {
const timeStamp = '2022-11-29T15:52:44Z';
const template = dedent`
{{#FormatDate}} {{timeStamp}} {{/FormatDate}}
`.trim();
expect(renderMustacheString(template, { timeStamp }, 'none')).toEqual('2022-11-29 03:52pm');
});
it('date with a time zone is successful', () => {
const timeStamp = '2022-11-29T15:52:44Z';
const template = dedent`
{{#FormatDate}} {{timeStamp}} ; America/New_York {{/FormatDate}}
`.trim();
expect(renderMustacheString(template, { timeStamp }, 'none')).toEqual('2022-11-29 10:52am');
});
it('date with a format is successful', () => {
const timeStamp = '2022-11-29T15:52:44Z';
const template = dedent`
{{#FormatDate}} {{timeStamp}} ;; dddd MMM Do YYYY HH:mm:ss.SSS {{/FormatDate}}
`.trim();
expect(renderMustacheString(template, { timeStamp }, 'none')).toEqual(
'Tuesday Nov 29th 2022 15:52:44.000'
);
});
it('date with a format and timezone is successful', () => {
const timeStamp = '2022-11-29T15:52:44Z';
const template = dedent`
{{#FormatDate}} {{timeStamp}};America/New_York;dddd MMM Do YYYY HH:mm:ss.SSS {{/FormatDate}}
`.trim();
expect(renderMustacheString(template, { timeStamp }, 'none').trim()).toEqual(
'Tuesday Nov 29th 2022 10:52:44.000'
);
});
it('empty date produces error', () => {
const timeStamp = '';
const template = dedent`
{{#FormatDate}} {{/FormatDate}}
`.trim();
expect(renderMustacheString(template, { timeStamp }, 'none').trim()).toEqual(
'error rendering mustache template "{{#FormatDate}} {{/FormatDate}}": date is empty'
);
});
it('invalid date produces error', () => {
const timeStamp = 'this is not a d4t3';
const template = dedent`
{{#FormatDate}}{{timeStamp}}{{/FormatDate}}
`.trim();
expect(renderMustacheString(template, { timeStamp }, 'none').trim()).toEqual(
'error rendering mustache template "{{#FormatDate}}{{timeStamp}}{{/FormatDate}}": invalid date "this is not a d4t3"'
);
});
it('invalid timezone produces error', () => {
const timeStamp = '2023-04-10T23:52:39';
const template = dedent`
{{#FormatDate}}{{timeStamp}};NotATime Zone!{{/FormatDate}}
`.trim();
expect(renderMustacheString(template, { timeStamp }, 'none').trim()).toEqual(
'error rendering mustache template "{{#FormatDate}}{{timeStamp}};NotATime Zone!{{/FormatDate}}": unknown timeZone value "NotATime Zone!"'
);
});
it('invalid format produces error', () => {
const timeStamp = '2023-04-10T23:52:39';
const template = dedent`
{{#FormatDate}}{{timeStamp}};;garbage{{/FormatDate}}
`.trim();
// not clear how to force an error, it pretty much does something with
// ANY string
expect(renderMustacheString(template, { timeStamp }, 'none').trim()).toEqual(
'gamrbamg2' // a => am/pm (so am here); e => day of week
);
});
});
describe('EvalMath', () => {
it('math is successful', () => {
const vars = {
context: {
a: { b: 1 },
c: { d: 2 },
},
};
const template = dedent`
{{#EvalMath}} 1 + 0 {{/EvalMath}}
{{#EvalMath}} 1 + context.a.b {{/EvalMath}}
{{#context}}
{{#EvalMath}} 1 + c.d {{/EvalMath}}
{{/context}}
`.trim();
const result = renderMustacheString(template, vars, 'none');
expect(result).toEqual(`1\n2\n3\n`);
});
it('invalid expression produces error', () => {
const vars = {
context: {
a: { b: 1 },
c: { d: 2 },
},
};
const template = dedent`
{{#EvalMath}} ) 1 ++++ 0 ( {{/EvalMath}}
`.trim();
const result = renderMustacheString(template, vars, 'none');
expect(result).toEqual(
`error rendering mustache template "{{#EvalMath}} ) 1 ++++ 0 ( {{/EvalMath}}": error evaluating tinymath expression ") 1 ++++ 0 (": Failed to parse expression. Expected "(", function, literal, or whitespace but ")" found.`
);
});
});
describe('ParseHJson', () => {
it('valid Hjson is successful', () => {
const vars = {
context: {
a: { b: 1 },
c: { d: 2 },
},
};
const hjson = `
{
# specify rate in requests/second (because comments are helpful!)
rate: 1000
a: {{context.a}}
a_b: {{context.a.b}}
c: {{context.c}}
c_d: {{context.c.d}}
# list items can be separated by lines, or commas, and trailing
# commas permitted
list: [
1 2
3
4,5,6,
]
}`;
const template = dedent`
{{#ParseHjson}} ${hjson} {{/ParseHjson}}
`.trim();
const result = renderMustacheString(template, vars, 'none');
expect(JSON.parse(result)).toMatchInlineSnapshot(`
Object {
"a": Object {
"b": 1,
},
"a_b": 1,
"c": Object {
"d": 2,
},
"c_d": 2,
"list": Array [
"1 2",
3,
4,
5,
6,
],
"rate": 1000,
}
`);
});
it('renders an error message on parse errors', () => {
const template = dedent`
{{#ParseHjson}} [1,2,3,,] {{/ParseHjson}}
`.trim();
const result = renderMustacheString(template, {}, 'none');
expect(result).toMatch(/^error rendering mustache template .*/);
});
});
});

View file

@ -0,0 +1,114 @@
/*
* 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 tinymath from '@kbn/tinymath';
import { parse as hjsonParse } from 'hjson';
import moment from 'moment-timezone';
type Variables = Record<string, unknown>;
const DefaultDateTimeZone = 'UTC';
const DefaultDateFormat = 'YYYY-MM-DD hh:mma';
export function getMustacheLambdas(): Variables {
return getLambdas();
}
const TimeZoneSet = new Set(moment.tz.names());
type RenderFn = (text: string) => string;
function getLambdas() {
return {
EvalMath: () =>
// mustache invokes lamdas with `this` set to the current "view" (variables)
function (this: Variables, text: string, render: RenderFn) {
return evalMath(this, render(text.trim()));
},
ParseHjson: () =>
function (text: string, render: RenderFn) {
return parseHjson(render(text.trim()));
},
FormatDate: () =>
function (text: string, render: RenderFn) {
const dateString = render(text.trim()).trim();
return formatDate(dateString);
},
};
}
function evalMath(vars: Variables, o: unknown): string {
const expr = `${o}`;
try {
const result = tinymath.evaluate(expr, vars);
return `${result}`;
} catch (err) {
throw new Error(`error evaluating tinymath expression "${expr}": ${err.message}`);
}
}
function parseHjson(o: unknown): string {
const hjsonObject = `${o}`;
let object: unknown;
try {
object = hjsonParse(hjsonObject);
} catch (err) {
throw new Error(`error parsing Hjson "${hjsonObject}": ${err.message}`);
}
return JSON.stringify(object);
}
function formatDate(dateString: unknown): string {
const { date, timeZone, format } = splitDateString(`${dateString}`);
if (date === '') {
throw new Error(`date is empty`);
}
if (isNaN(new Date(date).valueOf())) {
throw new Error(`invalid date "${date}"`);
}
let mDate: moment.Moment;
try {
mDate = moment(date);
if (!mDate.isValid()) {
throw new Error(`date is invalid`);
}
} catch (err) {
throw new Error(`error evaluating moment date "${date}": ${err.message}`);
}
if (!TimeZoneSet.has(timeZone)) {
throw new Error(`unknown timeZone value "${timeZone}"`);
}
try {
mDate.tz(timeZone);
} catch (err) {
throw new Error(`error evaluating moment timeZone "${timeZone}": ${err.message}`);
}
try {
return mDate.format(format);
} catch (err) {
throw new Error(`error evaluating moment format "${format}": ${err.message}`);
}
}
function splitDateString(dateString: string) {
const parts = dateString.split(';', 3).map((s) => s.trim());
const [date = '', timeZone = '', format = ''] = parts;
return {
date,
timeZone: timeZone || DefaultDateTimeZone,
format: format || DefaultDateFormat,
};
}

View file

@ -58,6 +58,12 @@ describe('mustache_renderer', () => {
expect(renderMustacheString('{{f.g}}', variables, escape)).toBe('3');
expect(renderMustacheString('{{f.h}}', variables, escape)).toBe('');
expect(renderMustacheString('{{i}}', variables, escape)).toBe('42,43,44');
if (escape === 'markdown') {
expect(renderMustacheString('{{i.asJSON}}', variables, escape)).toBe('\\[42,43,44\\]');
} else {
expect(renderMustacheString('{{i.asJSON}}', variables, escape)).toBe('[42,43,44]');
}
});
}
@ -339,6 +345,11 @@ describe('mustache_renderer', () => {
const expected = '1 - {"c":2,"d":[3,4]} -- 5,{"f":6,"g":7}';
expect(renderMustacheString('{{a}} - {{b}} -- {{e}}', deepVariables, 'none')).toEqual(expected);
expect(renderMustacheString('{{e}}', deepVariables, 'none')).toEqual('5,{"f":6,"g":7}');
expect(renderMustacheString('{{e.asJSON}}', deepVariables, 'none')).toEqual(
'[5,{"f":6,"g":7}]'
);
});
describe('converting dot variables', () => {

View file

@ -7,8 +7,10 @@
import Mustache from 'mustache';
import { isString, isPlainObject, cloneDeepWith, merge } from 'lodash';
import { getMustacheLambdas } from './mustache_lambdas';
export type Escape = 'markdown' | 'slack' | 'json' | 'none';
type Variables = Record<string, unknown>;
// return a rendered mustache template with no escape given the specified variables and escape
@ -25,11 +27,13 @@ export function renderMustacheStringNoEscape(string: string, variables: Variable
// return a rendered mustache template given the specified variables and escape
export function renderMustacheString(string: string, variables: Variables, escape: Escape): string {
const augmentedVariables = augmentObjectVariables(variables);
const lambdas = getMustacheLambdas();
const previousMustacheEscape = Mustache.escape;
Mustache.escape = getEscape(escape);
try {
return Mustache.render(`${string}`, augmentedVariables);
return Mustache.render(`${string}`, { ...lambdas, ...augmentedVariables });
} catch (err) {
// log error; the mustache code does not currently leak variables
return `error rendering mustache template "${string}": ${err.message}`;
@ -98,6 +102,9 @@ function addToStringDeep(object: unknown): void {
// walk arrays, but don't add a toString() as mustache already does something
if (Array.isArray(object)) {
// instead, add an asJSON()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(object as any).asJSON = () => JSON.stringify(object);
object.forEach((element) => addToStringDeep(element));
return;
}

View file

@ -33,7 +33,8 @@
"@kbn/logging-mocks",
"@kbn/core-elasticsearch-client-server-mocks",
"@kbn/safer-lodash-set",
"@kbn/core-http-server-mocks"
"@kbn/core-http-server-mocks",
"@kbn/tinymath",
],
"exclude": [
"target/**/*",

View file

@ -35,7 +35,7 @@ export async function initPlugin() {
}
// store a message that was posted to be remembered
const match = text.match(/^message (.*)$/);
const match = text.match(/^message ([\S\s]*)$/);
if (match) {
messages.push(match[1]);
response.statusCode = 200;

View file

@ -79,7 +79,7 @@ function createServerCallback() {
}
// store a payload that was posted to be remembered
const match = data.match(/^payload (.*)$/);
const match = data.match(/^payload ([\S\s]*)$/);
if (match) {
payloads.push(match[1]);
response.statusCode = 200;
@ -89,6 +89,8 @@ function createServerCallback() {
response.statusCode = 400;
response.end(`unexpected body ${data}`);
// eslint-disable-next-line no-console
console.log(`webhook simulator received unexpected body: ${data}`);
return;
});
} else {

View file

@ -47,6 +47,7 @@ export const DeepContextVariables = {
arrayI: [44, 45],
nullJ: null,
undefinedK: undefined,
dateL: '2023-04-20T04:13:17.858Z',
};
function getAlwaysFiringAlertType() {

View file

@ -14,13 +14,16 @@
import http from 'http';
import getPort from 'get-port';
import { URL, format as formatUrl } from 'url';
import axios from 'axios';
import expect from '@kbn/expect';
import { getWebhookServer, getSlackServer } from '@kbn/actions-simulators-plugin/server/plugin';
import { Spaces } from '../../../scenarios';
import { getUrlPrefix, getTestRuleData, ObjectRemover } from '../../../../common/lib';
import {
getUrlPrefix,
getTestRuleData as getCoreTestRuleData,
ObjectRemover,
} from '../../../../common/lib';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
@ -32,8 +35,10 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon
const objectRemover = new ObjectRemover(supertest);
let webhookSimulatorURL: string = '';
let webhookServer: http.Server;
let webhookConnector: any;
let slackSimulatorURL: string = '';
let slackServer: http.Server;
let slackConnector: any;
before(async () => {
let availablePort: number;
@ -42,6 +47,7 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon
availablePort = await getPort({ port: 9000 });
webhookServer.listen(availablePort);
webhookSimulatorURL = `http://localhost:${availablePort}`;
webhookConnector = await createWebhookConnector(webhookSimulatorURL);
slackServer = await getSlackServer();
availablePort = await getPort({ port: getPort.makeRange(9000, 9100) });
@ -49,6 +55,7 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon
slackServer.listen(availablePort);
}
slackSimulatorURL = `http://localhost:${availablePort}`;
slackConnector = await createSlackConnector(slackSimulatorURL);
});
after(async () => {
@ -57,13 +64,177 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon
slackServer.close();
});
it('should handle escapes in webhook', async () => {
const url = formatUrl(new URL(webhookSimulatorURL), { auth: false });
const actionResponse = await supertest
describe('escaping', () => {
it('should handle escapes in webhook', async () => {
// from x-pack/test/alerting_api_integration/common/plugins/alerts/server/alert_types.ts,
// const EscapableStrings
const template = '{{context.escapableDoubleQuote}} -- {{context.escapableLineFeed}}';
const rule = await createRule({
id: webhookConnector.id,
group: 'default',
params: {
body: `payload {{alertId}} - ${template}`,
},
});
const body = await retry.try(async () => waitForActionBody(webhookSimulatorURL, rule.id));
expect(body).to.be(`\\"double quote\\" -- line\\nfeed`);
});
it('should handle escapes in slack', async () => {
// from x-pack/test/alerting_api_integration/common/plugins/alerts/server/alert_types.ts,
// const EscapableStrings
const template =
'{{context.escapableBacktic}} -- {{context.escapableBold}} -- {{context.escapableBackticBold}} -- {{context.escapableHtml}}';
const rule = await createRule({
id: slackConnector.id,
group: 'default',
params: {
message: `message {{alertId}} - ${template}`,
},
});
const body = await retry.try(async () => waitForActionBody(slackSimulatorURL, rule.id));
expect(body).to.be("back'tic -- `*bold*` -- `'*bold*'` -- &lt;&amp;&gt;");
});
it('should handle context variable object expansion', async () => {
// from x-pack/test/alerting_api_integration/common/plugins/alerts/server/alert_types.ts,
// const DeepContextVariables
const template = '{{context.deep}}';
const rule = await createRule({
id: slackConnector.id,
group: 'default',
params: {
message: `message {{alertId}} - ${template}`,
},
});
const body = await retry.try(async () => waitForActionBody(slackSimulatorURL, rule.id));
expect(body).to.be(
'{"objectA":{"stringB":"B","arrayC":[{"stringD":"D1","numberE":42},{"stringD":"D2","numberE":43}],"objectF":{"stringG":"G","nullG":null}},"stringH":"H","arrayI":[44,45],"nullJ":null,"dateL":"2023-04-20T04:13:17.858Z"}'
);
});
it('should render kibanaBaseUrl as empty string since not configured', async () => {
const template = 'kibanaBaseUrl: "{{kibanaBaseUrl}}"';
const rule = await createRule({
id: slackConnector.id,
group: 'default',
params: {
message: `message {{alertId}} - ${template}`,
},
});
const body = await retry.try(async () => waitForActionBody(slackSimulatorURL, rule.id));
expect(body).to.be('kibanaBaseUrl: ""');
});
it('should render action variables in rule action', async () => {
const rule = await createRule({
id: webhookConnector.id,
group: 'default',
params: {
body: `payload {{rule.id}} - old id variable: {{alertId}}, new id variable: {{rule.id}}, old name variable: {{alertName}}, new name variable: {{rule.name}}`,
},
});
const body = await retry.try(async () => waitForActionBody(webhookSimulatorURL, rule.id));
expect(body).to.be(
`old id variable: ${rule.id}, new id variable: ${rule.id}, old name variable: ${rule.name}, new name variable: ${rule.name}`
);
});
});
describe('lambdas', () => {
it('should handle ParseHjson', async () => {
const template = `{{#ParseHjson}} {
ruleId: {{rule.id}}
ruleName: {{rule.name}}
} {{/ParseHjson}}`;
const rule = await createRule({
id: webhookConnector.id,
group: 'default',
params: {
body: `payload {{alertId}} - ${template}`,
},
});
const body = await retry.try(async () => waitForActionBody(webhookSimulatorURL, rule.id));
expect(body).to.be(`{"ruleId":"${rule.id}","ruleName":"testing mustache templates"}`);
});
it('should handle asJSON', async () => {
// from x-pack/test/alerting_api_integration/common/plugins/alerts/server/alert_types.ts,
// const DeepContextVariables
const template = `{{#context.deep.objectA}}
{{{arrayC}}} {{{arrayC.asJSON}}}
{{/context.deep.objectA}}
`;
const rule = await createRule({
id: webhookConnector.id,
group: 'default',
params: {
body: `payload {{alertId}} - ${template}`,
},
});
const body = await retry.try(async () => waitForActionBody(webhookSimulatorURL, rule.id));
const expected1 = `{"stringD":"D1","numberE":42},{"stringD":"D2","numberE":43}`;
const expected2 = `[{"stringD":"D1","numberE":42},{"stringD":"D2","numberE":43}]`;
expect(body.trim()).to.be(`${expected1} ${expected2}`);
});
it('should handle EvalMath', async () => {
// from x-pack/test/alerting_api_integration/common/plugins/alerts/server/alert_types.ts,
// const DeepContextVariables
const template = `{{#context.deep}}avg({{arrayI.0}}, {{arrayI.1}})/100 => {{#EvalMath}}
round((arrayI[0] + arrayI[1]) / 2 / 100, 2)
{{/EvalMath}}{{/context.deep}}`;
const rule = await createRule({
id: slackConnector.id,
group: 'default',
params: {
message: `message {{alertId}} - ${template}`,
},
});
const body = await retry.try(async () => waitForActionBody(slackSimulatorURL, rule.id));
expect(body).to.be(`avg(44, 45)/100 => 0.45`);
});
it('should handle FormatDate', async () => {
// from x-pack/test/alerting_api_integration/common/plugins/alerts/server/alert_types.ts,
// const DeepContextVariables
const template = `{{#context.deep}}{{#FormatDate}}
{{{dateL}}} ; America/New_York; dddd MMM Do YYYY HH:mm:ss
{{/FormatDate}}{{/context.deep}}`;
const rule = await createRule({
id: slackConnector.id,
group: 'default',
params: {
message: `message {{alertId}} - ${template}`,
},
});
const body = await retry.try(async () => waitForActionBody(slackSimulatorURL, rule.id));
expect(body.trim()).to.be(`Thursday Apr 20th 2023 00:13:17`);
});
});
async function createRule(action: any) {
const ruleResponse = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(getTestRuleData({ actions: [action] }));
expect(ruleResponse.status).to.eql(200);
const rule = ruleResponse.body;
objectRemover.add(Spaces.space1.id, rule.id, 'rule', 'alerting');
return rule;
}
async function createWebhookConnector(url: string) {
const createResponse = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connector`)
.set('kbn-xsrf', 'test')
.send({
name: 'testing mustache escapes for webhook',
name: 'testing mustache for webhook',
connector_type_id: '.webhook',
secrets: {},
config: {
@ -73,248 +244,30 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon
url,
},
});
expect(actionResponse.status).to.eql(200);
const createdAction = actionResponse.body;
objectRemover.add(Spaces.space1.id, createdAction.id, 'connector', 'actions');
expect(createResponse.status).to.eql(200);
const connector = createResponse.body;
objectRemover.add(Spaces.space1.id, connector.id, 'connector', 'actions');
// from x-pack/test/alerting_api_integration/common/plugins/alerts/server/alert_types.ts,
// const EscapableStrings
const varsTemplate = '{{context.escapableDoubleQuote}} -- {{context.escapableLineFeed}}';
return connector;
}
const alertResponse = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
name: 'testing variable escapes for webhook',
rule_type_id: 'test.patternFiring',
params: {
pattern: { instance: [true] },
},
actions: [
{
id: createdAction.id,
group: 'default',
params: {
body: `payload {{alertId}} - ${varsTemplate}`,
},
},
],
})
);
expect(alertResponse.status).to.eql(200);
const createdAlert = alertResponse.body;
objectRemover.add(Spaces.space1.id, createdAlert.id, 'rule', 'alerting');
const body = await retry.try(async () =>
waitForActionBody(webhookSimulatorURL, createdAlert.id)
);
expect(body).to.be(`\\"double quote\\" -- line\\nfeed`);
});
it('should handle escapes in slack', async () => {
const actionResponse = await supertest
async function createSlackConnector(url: string) {
const createResponse = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connector`)
.set('kbn-xsrf', 'test')
.send({
name: "testing backtic'd mustache escapes for slack",
name: 'testing mustache for slack',
connector_type_id: '.slack',
secrets: {
webhookUrl: slackSimulatorURL,
webhookUrl: url,
},
});
expect(actionResponse.status).to.eql(200);
const createdAction = actionResponse.body;
objectRemover.add(Spaces.space1.id, createdAction.id, 'connector', 'actions');
expect(createResponse.status).to.eql(200);
const connector = createResponse.body;
objectRemover.add(Spaces.space1.id, connector.id, 'connector', 'actions');
// from x-pack/test/alerting_api_integration/common/plugins/alerts/server/alert_types.ts,
// const EscapableStrings
const varsTemplate =
'{{context.escapableBacktic}} -- {{context.escapableBold}} -- {{context.escapableBackticBold}} -- {{context.escapableHtml}}';
const alertResponse = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
name: 'testing variable escapes for slack',
rule_type_id: 'test.patternFiring',
params: {
pattern: { instance: [true] },
},
actions: [
{
id: createdAction.id,
group: 'default',
params: {
message: `message {{alertId}} - ${varsTemplate}`,
},
},
],
})
);
expect(alertResponse.status).to.eql(200);
const createdAlert = alertResponse.body;
objectRemover.add(Spaces.space1.id, createdAlert.id, 'rule', 'alerting');
const body = await retry.try(async () =>
waitForActionBody(slackSimulatorURL, createdAlert.id)
);
expect(body).to.be("back'tic -- `*bold*` -- `'*bold*'` -- &lt;&amp;&gt;");
});
it('should handle context variable object expansion', async () => {
const actionResponse = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connector`)
.set('kbn-xsrf', 'test')
.send({
name: 'testing context variable expansion',
connector_type_id: '.slack',
secrets: {
webhookUrl: slackSimulatorURL,
},
});
expect(actionResponse.status).to.eql(200);
const createdAction = actionResponse.body;
objectRemover.add(Spaces.space1.id, createdAction.id, 'connector', 'actions');
// from x-pack/test/alerting_api_integration/common/plugins/alerts/server/alert_types.ts,
// const DeepContextVariables
const varsTemplate = '{{context.deep}}';
const alertResponse = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
name: 'testing context variable expansion',
rule_type_id: 'test.patternFiring',
params: {
pattern: { instance: [true, true] },
},
actions: [
{
id: createdAction.id,
group: 'default',
params: {
message: `message {{alertId}} - ${varsTemplate}`,
},
},
],
})
);
expect(alertResponse.status).to.eql(200);
const createdAlert = alertResponse.body;
objectRemover.add(Spaces.space1.id, createdAlert.id, 'rule', 'alerting');
const body = await retry.try(async () =>
waitForActionBody(slackSimulatorURL, createdAlert.id)
);
expect(body).to.be(
'{"objectA":{"stringB":"B","arrayC":[{"stringD":"D1","numberE":42},{"stringD":"D2","numberE":43}],"objectF":{"stringG":"G","nullG":null}},"stringH":"H","arrayI":[44,45],"nullJ":null}'
);
});
it('should render kibanaBaseUrl as empty string since not configured', async () => {
const actionResponse = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connector`)
.set('kbn-xsrf', 'test')
.send({
name: 'testing context variable expansion',
connector_type_id: '.slack',
secrets: {
webhookUrl: slackSimulatorURL,
},
});
expect(actionResponse.status).to.eql(200);
const createdAction = actionResponse.body;
objectRemover.add(Spaces.space1.id, createdAction.id, 'connector', 'actions');
const varsTemplate = 'kibanaBaseUrl: "{{kibanaBaseUrl}}"';
const alertResponse = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
name: 'testing context variable kibanaBaseUrl',
rule_type_id: 'test.patternFiring',
params: {
pattern: { instance: [true, true] },
},
actions: [
{
id: createdAction.id,
group: 'default',
params: {
message: `message {{alertId}} - ${varsTemplate}`,
},
},
],
})
);
expect(alertResponse.status).to.eql(200);
const createdAlert = alertResponse.body;
objectRemover.add(Spaces.space1.id, createdAlert.id, 'rule', 'alerting');
const body = await retry.try(async () =>
waitForActionBody(slackSimulatorURL, createdAlert.id)
);
expect(body).to.be('kibanaBaseUrl: ""');
});
it('should render action variables in rule action', async () => {
const url = formatUrl(new URL(webhookSimulatorURL), { auth: false });
const actionResponse = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connector`)
.set('kbn-xsrf', 'test')
.send({
name: 'testing action variable rendering',
connector_type_id: '.webhook',
secrets: {},
config: {
headers: {
'Content-Type': 'text/plain',
},
url,
},
});
expect(actionResponse.status).to.eql(200);
const createdAction = actionResponse.body;
objectRemover.add(Spaces.space1.id, createdAction.id, 'connector', 'actions');
const alertResponse = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
name: 'testing variable escapes for webhook',
rule_type_id: 'test.patternFiring',
params: {
pattern: { instance: [true] },
},
actions: [
{
id: createdAction.id,
group: 'default',
params: {
body: `payload {{rule.id}} - old id variable: {{alertId}}, new id variable: {{rule.id}}, old name variable: {{alertName}}, new name variable: {{rule.name}}`,
},
},
],
})
);
expect(alertResponse.status).to.eql(200);
const createdAlert = alertResponse.body;
objectRemover.add(Spaces.space1.id, createdAlert.id, 'rule', 'alerting');
const body = await retry.try(async () =>
waitForActionBody(webhookSimulatorURL, createdAlert.id)
);
expect(body).to.be(
`old id variable: ${createdAlert.id}, new id variable: ${createdAlert.id}, old name variable: ${createdAlert.name}, new name variable: ${createdAlert.name}`
);
});
return connector;
}
});
async function waitForActionBody(url: string, id: string): Promise<string> {
@ -322,7 +275,7 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon
expect(response.status).to.eql(200);
for (const datum of response.data) {
const match = datum.match(/^(.*) - (.*)$/);
const match = datum.match(/^(.*) - ([\S\s]*)$/);
if (match == null) continue;
if (match[1] === id) return match[2];
@ -331,3 +284,15 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon
throw new Error(`no action body posted yet for id ${id}`);
}
}
function getTestRuleData(overrides: any) {
const defaults = {
name: 'testing mustache templates',
rule_type_id: 'test.patternFiring',
params: {
pattern: { instance: [true] },
},
};
return getCoreTestRuleData({ ...overrides, ...defaults });
}