mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[Security Solution][Detections] Add Bulk Scheduling for rules (#140166)
Addresses [#2127](https://github.com/elastic/security-team/issues/2172) (internal) ## Summary Adds feature to bulk edit schedule of rules (interval -runs every- and lookback time) https://user-images.githubusercontent.com/5354282/188846852-8bcb128a-db02-4a81-9fc8-3029a97965c2.mov ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [x] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
This commit is contained in:
parent
167587f86c
commit
672bdd25b4
24 changed files with 763 additions and 15 deletions
|
@ -30,5 +30,6 @@ export * from './src/operator';
|
|||
export * from './src/positive_integer_greater_than_zero';
|
||||
export * from './src/positive_integer';
|
||||
export * from './src/string_to_positive_number';
|
||||
export * from './src/time_duration';
|
||||
export * from './src/uuid';
|
||||
export * from './src/version';
|
||||
|
|
|
@ -0,0 +1,105 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { pipe } from 'fp-ts/lib/pipeable';
|
||||
import { left } from 'fp-ts/lib/Either';
|
||||
import { TimeDuration } from '.';
|
||||
import { foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils';
|
||||
|
||||
describe('time_unit', () => {
|
||||
test('it should validate a correctly formed TimeDuration with time unit of seconds', () => {
|
||||
const payload = '1s';
|
||||
const decoded = TimeDuration.decode(payload);
|
||||
const message = pipe(decoded, foldLeftRight);
|
||||
|
||||
expect(getPaths(left(message.errors))).toEqual([]);
|
||||
expect(message.schema).toEqual(payload);
|
||||
});
|
||||
|
||||
test('it should validate a correctly formed TimeDuration with time unit of minutes', () => {
|
||||
const payload = '100m';
|
||||
const decoded = TimeDuration.decode(payload);
|
||||
const message = pipe(decoded, foldLeftRight);
|
||||
|
||||
expect(getPaths(left(message.errors))).toEqual([]);
|
||||
expect(message.schema).toEqual(payload);
|
||||
});
|
||||
|
||||
test('it should validate a correctly formed TimeDuration with time unit of hours', () => {
|
||||
const payload = '10000000h';
|
||||
const decoded = TimeDuration.decode(payload);
|
||||
const message = pipe(decoded, foldLeftRight);
|
||||
|
||||
expect(getPaths(left(message.errors))).toEqual([]);
|
||||
expect(message.schema).toEqual(payload);
|
||||
});
|
||||
|
||||
test('it should NOT validate a negative TimeDuration', () => {
|
||||
const payload = '-10s';
|
||||
const decoded = TimeDuration.decode(payload);
|
||||
const message = pipe(decoded, foldLeftRight);
|
||||
|
||||
expect(getPaths(left(message.errors))).toEqual([
|
||||
'Invalid value "-10s" supplied to "TimeDuration"',
|
||||
]);
|
||||
expect(message.schema).toEqual({});
|
||||
});
|
||||
|
||||
test('it should NOT validate a TimeDuration with some other time unit', () => {
|
||||
const payload = '10000000w';
|
||||
const decoded = TimeDuration.decode(payload);
|
||||
const message = pipe(decoded, foldLeftRight);
|
||||
|
||||
expect(getPaths(left(message.errors))).toEqual([
|
||||
'Invalid value "10000000w" supplied to "TimeDuration"',
|
||||
]);
|
||||
expect(message.schema).toEqual({});
|
||||
});
|
||||
|
||||
test('it should NOT validate a TimeDuration with a time interval with incorrect format', () => {
|
||||
const payload = '100ff0000w';
|
||||
const decoded = TimeDuration.decode(payload);
|
||||
const message = pipe(decoded, foldLeftRight);
|
||||
|
||||
expect(getPaths(left(message.errors))).toEqual([
|
||||
'Invalid value "100ff0000w" supplied to "TimeDuration"',
|
||||
]);
|
||||
expect(message.schema).toEqual({});
|
||||
});
|
||||
|
||||
test('it should NOT validate an empty string', () => {
|
||||
const payload = '';
|
||||
const decoded = TimeDuration.decode(payload);
|
||||
const message = pipe(decoded, foldLeftRight);
|
||||
|
||||
expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to "TimeDuration"']);
|
||||
expect(message.schema).toEqual({});
|
||||
});
|
||||
|
||||
test('it should NOT validate an number', () => {
|
||||
const payload = 100;
|
||||
const decoded = TimeDuration.decode(payload);
|
||||
const message = pipe(decoded, foldLeftRight);
|
||||
|
||||
expect(getPaths(left(message.errors))).toEqual([
|
||||
'Invalid value "100" supplied to "TimeDuration"',
|
||||
]);
|
||||
expect(message.schema).toEqual({});
|
||||
});
|
||||
|
||||
test('it should NOT validate an TimeDuration with a valid time unit but unsafe integer', () => {
|
||||
const payload = `${Math.pow(2, 53)}h`;
|
||||
const decoded = TimeDuration.decode(payload);
|
||||
const message = pipe(decoded, foldLeftRight);
|
||||
|
||||
expect(getPaths(left(message.errors))).toEqual([
|
||||
`Invalid value "${Math.pow(2, 53)}h" supplied to "TimeDuration"`,
|
||||
]);
|
||||
expect(message.schema).toEqual({});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import * as t from 'io-ts';
|
||||
import { Either } from 'fp-ts/lib/Either';
|
||||
|
||||
/**
|
||||
* Types the TimeDuration as:
|
||||
* - A string that is not empty, and composed of a positive integer greater than 0 followed by a unit of time
|
||||
* - in the format {safe_integer}{timeUnit}, e.g. "30s", "1m", "2h"
|
||||
*/
|
||||
export const TimeDuration = new t.Type<string, string, unknown>(
|
||||
'TimeDuration',
|
||||
t.string.is,
|
||||
(input, context): Either<t.Errors, string> => {
|
||||
if (typeof input === 'string' && input.trim() !== '') {
|
||||
try {
|
||||
const inputLength = input.length;
|
||||
const time = parseInt(input.trim().substring(0, inputLength - 1), 10);
|
||||
const unit = input.trim().at(-1);
|
||||
if (
|
||||
time >= 1 &&
|
||||
Number.isSafeInteger(time) &&
|
||||
(unit === 's' || unit === 'm' || unit === 'h')
|
||||
) {
|
||||
return t.success(input);
|
||||
} else {
|
||||
return t.failure(input, context);
|
||||
}
|
||||
} catch (error) {
|
||||
return t.failure(input, context);
|
||||
}
|
||||
} else {
|
||||
return t.failure(input, context);
|
||||
}
|
||||
},
|
||||
t.identity
|
||||
);
|
||||
|
||||
export type TimeDurationC = typeof TimeDuration;
|
|
@ -377,6 +377,125 @@ describe('perform_bulk_action_schema', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('schedules', () => {
|
||||
test('invalid request: wrong schedules payload type', () => {
|
||||
const payload = {
|
||||
query: 'name: test',
|
||||
action: BulkAction.edit,
|
||||
[BulkAction.edit]: [{ type: BulkActionEditType.set_schedule, value: [] }],
|
||||
};
|
||||
|
||||
const message = retrieveValidationMessage(payload);
|
||||
|
||||
expect(getPaths(left(message.errors))).toEqual([
|
||||
'Invalid value "edit" supplied to "action"',
|
||||
'Invalid value "set_schedule" supplied to "edit,type"',
|
||||
'Invalid value "[]" supplied to "edit,value"',
|
||||
]);
|
||||
expect(message.schema).toEqual({});
|
||||
});
|
||||
|
||||
test('invalid request: wrong type of payload data', () => {
|
||||
const payload = {
|
||||
query: 'name: test',
|
||||
action: BulkAction.edit,
|
||||
[BulkAction.edit]: [
|
||||
{
|
||||
type: BulkActionEditType.set_schedule,
|
||||
value: {
|
||||
interval: '-10m',
|
||||
lookback: '1m',
|
||||
},
|
||||
},
|
||||
],
|
||||
} as PerformBulkActionSchema;
|
||||
|
||||
const message = retrieveValidationMessage(payload);
|
||||
|
||||
expect(getPaths(left(message.errors))).toEqual(
|
||||
expect.arrayContaining([
|
||||
'Invalid value "edit" supplied to "action"',
|
||||
'Invalid value "{"interval":"-10m","lookback":"1m"}" supplied to "edit,value"',
|
||||
'Invalid value "-10m" supplied to "edit,value,interval"',
|
||||
])
|
||||
);
|
||||
expect(message.schema).toEqual({});
|
||||
});
|
||||
|
||||
test('invalid request: missing interval', () => {
|
||||
const payload = {
|
||||
query: 'name: test',
|
||||
action: BulkAction.edit,
|
||||
[BulkAction.edit]: [
|
||||
{
|
||||
type: BulkActionEditType.set_schedule,
|
||||
value: {
|
||||
lookback: '1m',
|
||||
},
|
||||
},
|
||||
],
|
||||
} as PerformBulkActionSchema;
|
||||
|
||||
const message = retrieveValidationMessage(payload);
|
||||
|
||||
expect(getPaths(left(message.errors))).toEqual(
|
||||
expect.arrayContaining([
|
||||
'Invalid value "edit" supplied to "action"',
|
||||
'Invalid value "{"lookback":"1m"}" supplied to "edit,value"',
|
||||
'Invalid value "undefined" supplied to "edit,value,interval"',
|
||||
])
|
||||
);
|
||||
expect(message.schema).toEqual({});
|
||||
});
|
||||
|
||||
test('invalid request: missing lookback', () => {
|
||||
const payload = {
|
||||
query: 'name: test',
|
||||
action: BulkAction.edit,
|
||||
[BulkAction.edit]: [
|
||||
{
|
||||
type: BulkActionEditType.set_schedule,
|
||||
value: {
|
||||
interval: '1m',
|
||||
},
|
||||
},
|
||||
],
|
||||
} as PerformBulkActionSchema;
|
||||
|
||||
const message = retrieveValidationMessage(payload);
|
||||
|
||||
expect(getPaths(left(message.errors))).toEqual(
|
||||
expect.arrayContaining([
|
||||
'Invalid value "edit" supplied to "action"',
|
||||
'Invalid value "{"interval":"1m"}" supplied to "edit,value"',
|
||||
'Invalid value "undefined" supplied to "edit,value,lookback"',
|
||||
])
|
||||
);
|
||||
expect(message.schema).toEqual({});
|
||||
});
|
||||
|
||||
test('valid request: set_schedule edit action', () => {
|
||||
const payload: PerformBulkActionSchema = {
|
||||
query: 'name: test',
|
||||
action: BulkAction.edit,
|
||||
[BulkAction.edit]: [
|
||||
{
|
||||
type: BulkActionEditType.set_schedule,
|
||||
value: {
|
||||
interval: '1m',
|
||||
lookback: '1m',
|
||||
},
|
||||
},
|
||||
],
|
||||
} as PerformBulkActionSchema;
|
||||
|
||||
const message = retrieveValidationMessage(payload);
|
||||
|
||||
expect(getPaths(left(message.errors))).toEqual([]);
|
||||
expect(message.schema).toEqual(payload);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rule actions', () => {
|
||||
test('invalid request: invalid rule actions payload', () => {
|
||||
const payload = {
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import * as t from 'io-ts';
|
||||
import { NonEmptyArray, enumeration } from '@kbn/securitysolution-io-ts-types';
|
||||
import { NonEmptyArray, TimeDuration, enumeration } from '@kbn/securitysolution-io-ts-types';
|
||||
|
||||
import {
|
||||
throttle,
|
||||
|
@ -38,6 +38,7 @@ export enum BulkActionEditType {
|
|||
'set_timeline' = 'set_timeline',
|
||||
'add_rule_actions' = 'add_rule_actions',
|
||||
'set_rule_actions' = 'set_rule_actions',
|
||||
'set_schedule' = 'set_schedule',
|
||||
}
|
||||
|
||||
const bulkActionEditPayloadTags = t.type({
|
||||
|
@ -102,11 +103,21 @@ const bulkActionEditPayloadRuleActions = t.type({
|
|||
|
||||
export type BulkActionEditPayloadRuleActions = t.TypeOf<typeof bulkActionEditPayloadRuleActions>;
|
||||
|
||||
const bulkActionEditPayloadSchedule = t.type({
|
||||
type: t.literal(BulkActionEditType.set_schedule),
|
||||
value: t.type({
|
||||
interval: TimeDuration,
|
||||
lookback: TimeDuration,
|
||||
}),
|
||||
});
|
||||
export type BulkActionEditPayloadSchedule = t.TypeOf<typeof bulkActionEditPayloadSchedule>;
|
||||
|
||||
export const bulkActionEditPayload = t.union([
|
||||
bulkActionEditPayloadTags,
|
||||
bulkActionEditPayloadIndexPatterns,
|
||||
bulkActionEditPayloadTimeline,
|
||||
bulkActionEditPayloadRuleActions,
|
||||
bulkActionEditPayloadSchedule,
|
||||
]);
|
||||
|
||||
export type BulkActionEditPayload = t.TypeOf<typeof bulkActionEditPayload>;
|
||||
|
@ -116,14 +127,16 @@ export type BulkActionEditPayload = t.TypeOf<typeof bulkActionEditPayload>;
|
|||
*/
|
||||
export type BulkActionEditForRuleAttributes =
|
||||
| BulkActionEditPayloadTags
|
||||
| BulkActionEditPayloadRuleActions;
|
||||
| BulkActionEditPayloadRuleActions
|
||||
| BulkActionEditPayloadSchedule;
|
||||
|
||||
/**
|
||||
* actions that modify rules params
|
||||
*/
|
||||
export type BulkActionEditForRuleParams =
|
||||
| BulkActionEditPayloadIndexPatterns
|
||||
| BulkActionEditPayloadTimeline;
|
||||
| BulkActionEditPayloadTimeline
|
||||
| BulkActionEditPayloadSchedule;
|
||||
|
||||
export const performBulkActionSchema = t.intersection([
|
||||
t.exact(
|
||||
|
|
|
@ -64,6 +64,13 @@ import {
|
|||
openBulkEditDeleteIndexPatternsForm,
|
||||
selectTimelineTemplate,
|
||||
checkTagsInTagsFilter,
|
||||
clickUpdateScheduleMenuItem,
|
||||
typeScheduleInterval,
|
||||
typeScheduleLookback,
|
||||
setScheduleLookbackTimeUnit,
|
||||
setScheduleIntervalTimeUnit,
|
||||
assertRuleScheduleValues,
|
||||
assertUpdateScheduleWarningExists,
|
||||
} from '../../tasks/rules_bulk_edit';
|
||||
|
||||
import { hasIndexPatterns, getDetails } from '../../tasks/rule_details';
|
||||
|
@ -484,4 +491,51 @@ describe('Detection rules, bulk edit', () => {
|
|||
getDetails(TIMELINE_TEMPLATE_DETAILS).should('have.text', noneTimelineTemplate);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Schedule', () => {
|
||||
it('Updates schedule for custom rules', () => {
|
||||
selectNumberOfRules(expectedNumberOfCustomRulesToBeEdited);
|
||||
clickUpdateScheduleMenuItem();
|
||||
|
||||
assertUpdateScheduleWarningExists(expectedNumberOfCustomRulesToBeEdited);
|
||||
|
||||
typeScheduleInterval('20');
|
||||
setScheduleIntervalTimeUnit('Hours');
|
||||
|
||||
typeScheduleLookback('10');
|
||||
setScheduleLookbackTimeUnit('Minutes');
|
||||
|
||||
submitBulkEditForm();
|
||||
waitForBulkEditActionToFinish({ rulesCount: expectedNumberOfCustomRulesToBeEdited });
|
||||
|
||||
goToTheRuleDetailsOf(RULE_NAME);
|
||||
|
||||
assertRuleScheduleValues({
|
||||
interval: '20h',
|
||||
lookback: '10m',
|
||||
});
|
||||
});
|
||||
|
||||
it('Validates invalid inputs when scheduling for custom rules', () => {
|
||||
selectNumberOfRules(expectedNumberOfCustomRulesToBeEdited);
|
||||
clickUpdateScheduleMenuItem();
|
||||
|
||||
// Validate invalid values are corrected to minimumValue - for 0 and negative values
|
||||
typeScheduleInterval('0');
|
||||
setScheduleIntervalTimeUnit('Hours');
|
||||
|
||||
typeScheduleLookback('-5');
|
||||
setScheduleLookbackTimeUnit('Seconds');
|
||||
|
||||
submitBulkEditForm();
|
||||
waitForBulkEditActionToFinish({ rulesCount: expectedNumberOfCustomRulesToBeEdited });
|
||||
|
||||
goToTheRuleDetailsOf(RULE_NAME);
|
||||
|
||||
assertRuleScheduleValues({
|
||||
interval: '1h',
|
||||
lookback: '1s',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -13,6 +13,8 @@ export const ADD_INDEX_PATTERNS_RULE_BULK_MENU_ITEM =
|
|||
export const DELETE_INDEX_PATTERNS_RULE_BULK_MENU_ITEM =
|
||||
'[data-test-subj="deleteIndexPatternsBulkEditRule"]';
|
||||
|
||||
export const UPDATE_SCHEDULE_MENU_ITEM = '[data-test-subj="setScheduleBulk"]';
|
||||
|
||||
export const APPLY_TIMELINE_RULE_BULK_MENU_ITEM = '[data-test-subj="applyTimelineTemplateBulk"]';
|
||||
|
||||
export const TAGS_RULE_BULK_MENU_ITEM = '[data-test-subj="tagsBulkEditRule"]';
|
||||
|
@ -45,3 +47,13 @@ export const RULES_BULK_EDIT_TIMELINE_TEMPLATES_SELECTOR =
|
|||
|
||||
export const RULES_BULK_EDIT_TIMELINE_TEMPLATES_WARNING =
|
||||
'[data-test-subj="bulkEditRulesTimelineTemplateWarning"]';
|
||||
|
||||
export const RULES_BULK_EDIT_SCHEDULES_WARNING = '[data-test-subj="bulkEditRulesSchedulesWarning"]';
|
||||
|
||||
export const UPDATE_SCHEDULE_INTERVAL_INPUT =
|
||||
'[data-test-subj="bulkEditRulesScheduleIntervalSelector"]';
|
||||
|
||||
export const UPDATE_SCHEDULE_LOOKBACK_INPUT =
|
||||
'[data-test-subj="bulkEditRulesScheduleLookbackSelector"]';
|
||||
|
||||
export const UPDATE_SCHEDULE_TIME_UNIT_SELECT = '[data-test-subj="timeType"]';
|
||||
|
|
|
@ -31,7 +31,13 @@ import {
|
|||
RULES_BULK_EDIT_OVERWRITE_TAGS_CHECKBOX,
|
||||
RULES_BULK_EDIT_OVERWRITE_INDEX_PATTERNS_CHECKBOX,
|
||||
RULES_BULK_EDIT_TIMELINE_TEMPLATES_SELECTOR,
|
||||
UPDATE_SCHEDULE_MENU_ITEM,
|
||||
UPDATE_SCHEDULE_INTERVAL_INPUT,
|
||||
UPDATE_SCHEDULE_TIME_UNIT_SELECT,
|
||||
UPDATE_SCHEDULE_LOOKBACK_INPUT,
|
||||
RULES_BULK_EDIT_SCHEDULES_WARNING,
|
||||
} from '../screens/rules_bulk_edit';
|
||||
import { SCHEDULE_DETAILS } from '../screens/rule_details';
|
||||
|
||||
export const clickApplyTimelineTemplatesMenuItem = () => {
|
||||
cy.get(BULK_ACTIONS_BTN).click();
|
||||
|
@ -53,6 +59,11 @@ export const clickAddTagsMenuItem = () => {
|
|||
cy.get(ADD_TAGS_RULE_BULK_MENU_ITEM).click();
|
||||
};
|
||||
|
||||
export const clickUpdateScheduleMenuItem = () => {
|
||||
cy.get(BULK_ACTIONS_BTN).click();
|
||||
cy.get(UPDATE_SCHEDULE_MENU_ITEM).click().should('not.exist');
|
||||
};
|
||||
|
||||
export const clickAddIndexPatternsMenuItem = () => {
|
||||
clickIndexPatternsMenuItem();
|
||||
cy.get(ADD_INDEX_PATTERNS_RULE_BULK_MENU_ITEM).click();
|
||||
|
@ -166,3 +177,51 @@ export const checkTagsInTagsFilter = (tags: string[]) => {
|
|||
cy.wrap($el).should('have.text', tags[index]);
|
||||
});
|
||||
};
|
||||
|
||||
export const typeScheduleInterval = (interval: string) => {
|
||||
cy.get(UPDATE_SCHEDULE_INTERVAL_INPUT)
|
||||
.find('input')
|
||||
.type('{selectAll}')
|
||||
.type(interval.toString())
|
||||
.blur();
|
||||
};
|
||||
export const typeScheduleLookback = (lookback: string) => {
|
||||
cy.get(UPDATE_SCHEDULE_LOOKBACK_INPUT)
|
||||
.find('input')
|
||||
.type('{selectAll}', { waitForAnimations: true })
|
||||
.type(lookback.toString(), { waitForAnimations: true })
|
||||
.blur();
|
||||
};
|
||||
|
||||
type TimeUnit = 'Seconds' | 'Minutes' | 'Hours';
|
||||
export const setScheduleIntervalTimeUnit = (timeUnit: TimeUnit) => {
|
||||
cy.get(UPDATE_SCHEDULE_INTERVAL_INPUT).within(() => {
|
||||
cy.get(UPDATE_SCHEDULE_TIME_UNIT_SELECT).select(timeUnit);
|
||||
});
|
||||
};
|
||||
|
||||
export const setScheduleLookbackTimeUnit = (timeUnit: TimeUnit) => {
|
||||
cy.get(UPDATE_SCHEDULE_LOOKBACK_INPUT).within(() => {
|
||||
cy.get(UPDATE_SCHEDULE_TIME_UNIT_SELECT).select(timeUnit);
|
||||
});
|
||||
};
|
||||
|
||||
export const assertUpdateScheduleWarningExists = (expectedNumberOfNotMLRules: number) => {
|
||||
cy.get(RULES_BULK_EDIT_SCHEDULES_WARNING).should(
|
||||
'have.text',
|
||||
`You're about to apply changes to ${expectedNumberOfNotMLRules} selected rules. The changes you made will be overwritten to the existing Rule schedules and additional look-back time (if any).`
|
||||
);
|
||||
};
|
||||
|
||||
export const assertRuleScheduleValues = ({
|
||||
interval,
|
||||
lookback,
|
||||
}: {
|
||||
interval: string;
|
||||
lookback: string;
|
||||
}) => {
|
||||
cy.get(SCHEDULE_DETAILS).within(() => {
|
||||
cy.get('dd').eq(0).should('contain.text', interval);
|
||||
cy.get('dd').eq(1).should('contain.text', lookback);
|
||||
});
|
||||
};
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
EuiFormRow,
|
||||
EuiSelect,
|
||||
EuiFormControlLayout,
|
||||
transparentize,
|
||||
} from '@elastic/eui';
|
||||
import { isEmpty } from 'lodash/fp';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
@ -29,6 +30,7 @@ interface ScheduleItemProps {
|
|||
isDisabled: boolean;
|
||||
minimumValue?: number;
|
||||
timeTypes?: string[];
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
const timeTypeOptions = [
|
||||
|
@ -49,12 +51,27 @@ const StyledEuiFormRow = styled(EuiFormRow)`
|
|||
max-width: none;
|
||||
|
||||
.euiFormControlLayout {
|
||||
max-width: 200px !important;
|
||||
max-width: auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.euiFormControlLayout__childrenWrapper > *:first-child {
|
||||
box-shadow: none;
|
||||
height: 38px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.euiFormControlLayout__childrenWrapper > select {
|
||||
background-color: ${({ theme }) => transparentize(theme.eui.euiColorPrimary, 0.1)};
|
||||
color: ${({ theme }) => theme.eui.euiColorPrimary};
|
||||
}
|
||||
|
||||
.euiFormControlLayout--group .euiFormControlLayout {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.euiFormControlLayoutIcons {
|
||||
color: ${({ theme }) => theme.eui.euiColorPrimary};
|
||||
}
|
||||
|
||||
.euiFormControlLayout:not(:first-child) {
|
||||
|
@ -66,12 +83,12 @@ const MyEuiSelect = styled(EuiSelect)`
|
|||
width: auto;
|
||||
`;
|
||||
|
||||
const getNumberFromUserInput = (input: string, defaultValue = 0): number => {
|
||||
const getNumberFromUserInput = (input: string, minimumValue = 0): number => {
|
||||
const number = parseInt(input, 10);
|
||||
if (Number.isNaN(number)) {
|
||||
return defaultValue;
|
||||
return minimumValue;
|
||||
} else {
|
||||
return Math.min(number, Number.MAX_SAFE_INTEGER);
|
||||
return Math.max(minimumValue, Math.min(number, Number.MAX_SAFE_INTEGER));
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -82,6 +99,7 @@ export const ScheduleItem = ({
|
|||
isDisabled,
|
||||
minimumValue = 0,
|
||||
timeTypes = ['s', 'm', 'h'],
|
||||
fullWidth = false,
|
||||
}: ScheduleItemProps) => {
|
||||
const [timeType, setTimeType] = useState(timeTypes[0]);
|
||||
const [timeVal, setTimeVal] = useState<number>(0);
|
||||
|
@ -150,7 +168,7 @@ export const ScheduleItem = ({
|
|||
helpText={field.helpText}
|
||||
error={errorMessage}
|
||||
isInvalid={isInvalid}
|
||||
fullWidth={false}
|
||||
fullWidth={fullWidth}
|
||||
data-test-subj={dataTestSubj}
|
||||
describedByIds={idAria ? [idAria] : undefined}
|
||||
>
|
||||
|
|
|
@ -6,8 +6,8 @@
|
|||
*/
|
||||
|
||||
import type { FC } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import React, { memo, useCallback, useEffect } from 'react';
|
||||
|
||||
import type { RuleStepProps, ScheduleStepRule } from '../../../pages/detection_engine/rules/types';
|
||||
import { RuleStep } from '../../../pages/detection_engine/rules/types';
|
||||
import { StepRuleDescription } from '../description_step';
|
||||
|
@ -17,6 +17,9 @@ import { StepContentWrapper } from '../step_content_wrapper';
|
|||
import { NextStep } from '../next_step';
|
||||
import { schema } from './schema';
|
||||
|
||||
const StyledForm = styled(Form)`
|
||||
max-width: 235px !important;
|
||||
`;
|
||||
interface StepScheduleRuleProps extends RuleStepProps {
|
||||
defaultValues: ScheduleStepRule;
|
||||
onRuleDataChange?: (data: ScheduleStepRule) => void;
|
||||
|
@ -84,7 +87,7 @@ const StepScheduleRuleComponent: FC<StepScheduleRuleProps> = ({
|
|||
) : (
|
||||
<>
|
||||
<StepContentWrapper addPadding={!isUpdateView}>
|
||||
<Form form={form} data-test-subj="stepScheduleRule">
|
||||
<StyledForm form={form} data-test-subj="stepScheduleRule">
|
||||
<UseField
|
||||
path="interval"
|
||||
component={ScheduleItem}
|
||||
|
@ -104,7 +107,7 @@ const StepScheduleRuleComponent: FC<StepScheduleRuleProps> = ({
|
|||
minimumValue: 1,
|
||||
}}
|
||||
/>
|
||||
</Form>
|
||||
</StyledForm>
|
||||
</StepContentWrapper>
|
||||
|
||||
{!isUpdateView && (
|
||||
|
|
|
@ -14,6 +14,7 @@ import { IndexPatternsForm } from './forms/index_patterns_form';
|
|||
import { TagsForm } from './forms/tags_form';
|
||||
import { TimelineTemplateForm } from './forms/timeline_template_form';
|
||||
import { RuleActionsForm } from './forms/rule_actions_form';
|
||||
import { ScheduleForm } from './forms/schedule_form';
|
||||
|
||||
interface BulkEditFlyoutProps {
|
||||
onClose: () => void;
|
||||
|
@ -41,6 +42,8 @@ const BulkEditFlyoutComponent = ({ editAction, tags, ...props }: BulkEditFlyoutP
|
|||
case BulkActionEditType.add_rule_actions:
|
||||
case BulkActionEditType.set_rule_actions:
|
||||
return <RuleActionsForm {...props} />;
|
||||
case BulkActionEditType.set_schedule:
|
||||
return <ScheduleForm {...props} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
|
|
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
* 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 React, { useCallback } from 'react';
|
||||
import { EuiCallOut } from '@elastic/eui';
|
||||
import type { BulkActionEditPayload } from '../../../../../../../../common/detection_engine/schemas/request';
|
||||
import { BulkActionEditType } from '../../../../../../../../common/detection_engine/schemas/request';
|
||||
import { useForm, UseField } from '../../../../../../../shared_imports';
|
||||
import type { FormSchema } from '../../../../../../../shared_imports';
|
||||
import { BulkEditFormWrapper } from './bulk_edit_form_wrapper';
|
||||
import { ScheduleItem } from '../../../../../../components/rules/schedule_item_form';
|
||||
|
||||
import { bulkSetSchedule as i18n } from '../translations';
|
||||
|
||||
export interface ScheduleFormData {
|
||||
interval: string;
|
||||
lookback: string;
|
||||
}
|
||||
|
||||
export const formSchema: FormSchema<ScheduleFormData> = {
|
||||
interval: {
|
||||
label: i18n.INTERVAL_LABEL,
|
||||
helpText: i18n.INTERVAL_HELP_TEXT,
|
||||
},
|
||||
lookback: {
|
||||
label: i18n.LOOKBACK_LABEL,
|
||||
helpText: i18n.LOOKBACK_HELP_TEXT,
|
||||
},
|
||||
};
|
||||
|
||||
const defaultFormData: ScheduleFormData = {
|
||||
interval: '5m',
|
||||
lookback: '1m',
|
||||
};
|
||||
|
||||
interface ScheduleFormComponentProps {
|
||||
rulesCount: number;
|
||||
onClose: () => void;
|
||||
onConfirm: (bulkActionEditPayload: BulkActionEditPayload) => void;
|
||||
}
|
||||
|
||||
export const ScheduleForm = ({ rulesCount, onClose, onConfirm }: ScheduleFormComponentProps) => {
|
||||
const { form } = useForm({
|
||||
schema: formSchema,
|
||||
defaultValue: defaultFormData,
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const { data, isValid } = await form.submit();
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
onConfirm({
|
||||
type: BulkActionEditType.set_schedule,
|
||||
value: {
|
||||
interval: data.interval,
|
||||
lookback: data.lookback,
|
||||
},
|
||||
});
|
||||
}, [form, onConfirm]);
|
||||
|
||||
const warningCallout = (
|
||||
<EuiCallOut color="warning" data-test-subj="bulkEditRulesSchedulesWarning">
|
||||
{i18n.warningCalloutMessage(rulesCount)}
|
||||
</EuiCallOut>
|
||||
);
|
||||
|
||||
return (
|
||||
<BulkEditFormWrapper
|
||||
form={form}
|
||||
title={i18n.FORM_TITLE}
|
||||
banner={warningCallout}
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<UseField
|
||||
path="interval"
|
||||
component={ScheduleItem}
|
||||
componentProps={{
|
||||
idAria: 'bulkEditRulesScheduleIntervalSelector',
|
||||
dataTestSubj: 'bulkEditRulesScheduleIntervalSelector',
|
||||
fullWidth: true,
|
||||
minimumValue: 1,
|
||||
}}
|
||||
/>
|
||||
<UseField
|
||||
path="lookback"
|
||||
component={ScheduleItem}
|
||||
componentProps={{
|
||||
idAria: 'bulkEditRulesScheduleLookbackSelector',
|
||||
dataTestSubj: 'bulkEditRulesScheduleLookbackSelector',
|
||||
fullWidth: true,
|
||||
minimumValue: 1,
|
||||
}}
|
||||
/>
|
||||
</BulkEditFormWrapper>
|
||||
);
|
||||
};
|
|
@ -94,3 +94,43 @@ export const bulkAddRuleActions = {
|
|||
}
|
||||
),
|
||||
};
|
||||
|
||||
export const bulkSetSchedule = {
|
||||
FORM_TITLE: i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.setSchedule.formTitle',
|
||||
{
|
||||
defaultMessage: 'Update rule schedules',
|
||||
}
|
||||
),
|
||||
INTERVAL_LABEL: i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.setSchedule.intervalLabel',
|
||||
{
|
||||
defaultMessage: 'Runs every',
|
||||
}
|
||||
),
|
||||
INTERVAL_HELP_TEXT: i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.setSchedule.intervalHelpText',
|
||||
{
|
||||
defaultMessage: 'Rules run periodically and detect alerts within the specified time frame.',
|
||||
}
|
||||
),
|
||||
LOOKBACK_LABEL: i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.setSchedule.lookbackLabel',
|
||||
{
|
||||
defaultMessage: 'Additional look-back time',
|
||||
}
|
||||
),
|
||||
LOOKBACK_HELP_TEXT: i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.setSchedule.lookbackHelpText',
|
||||
{
|
||||
defaultMessage: 'Adds time to the look-back period to prevent missed alerts.',
|
||||
}
|
||||
),
|
||||
warningCalloutMessage: (rulesCount: number): JSX.Element => (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.setSchedule.warningCalloutMessage"
|
||||
defaultMessage="You're about to apply changes to {rulesCount, plural, one {# selected rule} other {# selected rules}}. The changes you made will be overwritten to the existing Rule schedules and additional look-back time (if any)."
|
||||
values={{ rulesCount }}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
|
|
@ -370,6 +370,16 @@ export const useBulkActions = ({
|
|||
toolTipPosition: 'right',
|
||||
icon: undefined,
|
||||
},
|
||||
{
|
||||
key: i18n.BULK_ACTION_SET_SCHEDULE,
|
||||
name: i18n.BULK_ACTION_SET_SCHEDULE,
|
||||
'data-test-subj': 'setScheduleBulk',
|
||||
disabled: isEditDisabled,
|
||||
onClick: handleBulkEdit(BulkActionEditType.set_schedule),
|
||||
toolTipContent: missingActionPrivileges ? i18n.EDIT_RULE_SETTINGS_TOOLTIP : undefined,
|
||||
toolTipPosition: 'right',
|
||||
icon: undefined,
|
||||
},
|
||||
{
|
||||
key: i18n.BULK_ACTION_APPLY_TIMELINE_TEMPLATE,
|
||||
name: i18n.BULK_ACTION_APPLY_TIMELINE_TEMPLATE,
|
||||
|
|
|
@ -63,6 +63,13 @@ export const computeDryRunPayload = (
|
|||
value: { throttle: '1h', actions: [] },
|
||||
},
|
||||
];
|
||||
case BulkActionEditType.set_schedule:
|
||||
return [
|
||||
{
|
||||
type: editAction,
|
||||
value: { interval: '5m', lookback: '1m' },
|
||||
},
|
||||
];
|
||||
|
||||
default:
|
||||
assertUnreachable(editAction);
|
||||
|
|
|
@ -274,6 +274,7 @@ export const RulesTables = React.memo<RulesTableProps>(
|
|||
|
||||
const shouldShowLinearProgress = isFetched && isRefetching;
|
||||
const shouldShowLoadingOverlay = (!isFetched && isRefetching) || isActionInProgress;
|
||||
|
||||
return (
|
||||
<>
|
||||
{shouldShowLinearProgress && (
|
||||
|
|
|
@ -187,6 +187,13 @@ export const BULK_ACTION_ADD_RULE_ACTIONS = i18n.translate(
|
|||
}
|
||||
);
|
||||
|
||||
export const BULK_ACTION_SET_SCHEDULE = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.setScheduleTitle',
|
||||
{
|
||||
defaultMessage: 'Update rule schedules',
|
||||
}
|
||||
);
|
||||
|
||||
export const BULK_ACTION_MENU_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.contextMenuTitle',
|
||||
{
|
||||
|
|
|
@ -41,4 +41,22 @@ describe('bulkEditActionToRulesClientOperation', () => {
|
|||
value: ['test'],
|
||||
},
|
||||
]);
|
||||
|
||||
test('should transform schedule bulk edit correctly', () => {
|
||||
expect(
|
||||
bulkEditActionToRulesClientOperation({
|
||||
type: BulkActionEditType.set_schedule,
|
||||
value: {
|
||||
interval: '100m',
|
||||
lookback: '10m',
|
||||
},
|
||||
})
|
||||
).toEqual([
|
||||
{
|
||||
field: 'schedule',
|
||||
operation: 'set',
|
||||
value: { interval: '100m' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -87,6 +87,20 @@ export const bulkEditActionToRulesClientOperation = (
|
|||
getNotifyWhenOperation(action.value.throttle),
|
||||
];
|
||||
|
||||
// schedule actions
|
||||
case BulkActionEditType.set_schedule:
|
||||
return [
|
||||
{
|
||||
field: 'schedule',
|
||||
operation: 'set',
|
||||
// We need to pass a pure Interval object
|
||||
// i.e. get rid of the meta property
|
||||
value: {
|
||||
interval: action.value.interval,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
default:
|
||||
return assertUnreachable(action);
|
||||
}
|
||||
|
|
|
@ -351,4 +351,28 @@ describe('ruleParamsModifier', () => {
|
|||
expect(editedRuleParams.timelineTitle).toBe('Test timeline');
|
||||
});
|
||||
});
|
||||
|
||||
describe('schedule', () => {
|
||||
test('should set timeline', () => {
|
||||
const INTERVAL_IN_MINUTES = 5;
|
||||
const LOOKBACK_IN_MINUTES = 1;
|
||||
const FROM_IN_SECONDS = (INTERVAL_IN_MINUTES + LOOKBACK_IN_MINUTES) * 60;
|
||||
const editedRuleParams = ruleParamsModifier(ruleParamsMock, [
|
||||
{
|
||||
type: BulkActionEditType.set_schedule,
|
||||
value: {
|
||||
interval: `${INTERVAL_IN_MINUTES}m`,
|
||||
lookback: `${LOOKBACK_IN_MINUTES}m`,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// @ts-expect-error
|
||||
expect(editedRuleParams.interval).toBeUndefined();
|
||||
expect(editedRuleParams.meta).toStrictEqual({
|
||||
from: '1m',
|
||||
});
|
||||
expect(editedRuleParams.from).toBe(`now-${FROM_IN_SECONDS}s`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,11 +5,13 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { RuleAlertType } from '../types';
|
||||
/* eslint-disable complexity */
|
||||
|
||||
import moment from 'moment';
|
||||
import { parseInterval } from '@kbn/data-plugin/common/search/aggs/utils/date_interval_utils';
|
||||
import type { RuleAlertType } from '../types';
|
||||
import type { BulkActionEditForRuleParams } from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema';
|
||||
import { BulkActionEditType } from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema';
|
||||
|
||||
import { invariant } from '../../../../../common/utils/invariant';
|
||||
|
||||
export const addItemsToArray = <T>(arr: T[], items: T[]): T[] =>
|
||||
|
@ -90,6 +92,23 @@ const applyBulkActionEditToRuleParams = (
|
|||
timelineTitle: action.value.timeline_title || undefined,
|
||||
};
|
||||
break;
|
||||
|
||||
// update look-back period in from and meta.from fields
|
||||
case BulkActionEditType.set_schedule: {
|
||||
const interval = parseInterval(action.value.interval) ?? moment.duration(0);
|
||||
const parsedFrom = parseInterval(action.value.lookback) ?? moment.duration(0);
|
||||
|
||||
const from = parsedFrom.asSeconds() + interval.asSeconds();
|
||||
|
||||
ruleParams = {
|
||||
...ruleParams,
|
||||
meta: {
|
||||
...ruleParams.meta,
|
||||
from: action.value.lookback,
|
||||
},
|
||||
from: `now-${from}s`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return ruleParams;
|
||||
|
|
|
@ -29,6 +29,10 @@ export const splitBulkEditActions = (actions: BulkActionEditPayload[]) => {
|
|||
|
||||
return actions.reduce((acc, action) => {
|
||||
switch (action.type) {
|
||||
case BulkActionEditType.set_schedule:
|
||||
acc.attributesActions.push(action);
|
||||
acc.paramsActions.push(action);
|
||||
break;
|
||||
case BulkActionEditType.add_tags:
|
||||
case BulkActionEditType.set_tags:
|
||||
case BulkActionEditType.delete_tags:
|
||||
|
|
|
@ -49,10 +49,10 @@ export const bulkEditRules = async ({
|
|||
mlAuthz,
|
||||
}: BulkEditRulesArguments) => {
|
||||
const { attributesActions, paramsActions } = splitBulkEditActions(actions);
|
||||
|
||||
const operations = attributesActions.map(bulkEditActionToRulesClientOperation).flat();
|
||||
const result = await rulesClient.bulkEdit({
|
||||
...(ids ? { ids } : { filter: enrichFilterWithRuleTypeMapping(filter) }),
|
||||
operations: attributesActions.map(bulkEditActionToRulesClientOperation).flat(),
|
||||
operations,
|
||||
paramsModifier: async (ruleParams: RuleAlertType['params']) => {
|
||||
await validateBulkEditRule({
|
||||
mlAuthz,
|
||||
|
|
|
@ -1050,6 +1050,10 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
type: BulkActionEditType.set_timeline,
|
||||
value: { timeline_id: 'mock-id', timeline_title: 'mock-title' },
|
||||
},
|
||||
{
|
||||
type: BulkActionEditType.set_schedule,
|
||||
value: { interval: '1m', lookback: '1m' },
|
||||
},
|
||||
];
|
||||
cases.forEach(({ type, value }) => {
|
||||
it(`should return error when trying to apply "${type}" edit action to prebuilt rule`, async () => {
|
||||
|
@ -1655,6 +1659,71 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('schedule actions', () => {
|
||||
it('should return bad request error if payload is invalid', async () => {
|
||||
const ruleId = 'ruleId';
|
||||
const intervalMinutes = 0;
|
||||
const interval = `${intervalMinutes}m`;
|
||||
const lookbackMinutes = -1;
|
||||
const lookback = `${lookbackMinutes}m`;
|
||||
await createRule(supertest, log, getSimpleRule(ruleId));
|
||||
|
||||
const { body } = await postBulkAction()
|
||||
.send({
|
||||
query: '',
|
||||
action: BulkAction.edit,
|
||||
[BulkAction.edit]: [
|
||||
{
|
||||
type: BulkActionEditType.set_schedule,
|
||||
value: {
|
||||
interval,
|
||||
lookback,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
expect(body.statusCode).to.eql(400);
|
||||
expect(body.error).to.eql('Bad Request');
|
||||
expect(body.message).to.contain('Invalid value "0m" supplied to "edit,value,interval"');
|
||||
expect(body.message).to.contain('Invalid value "-1m" supplied to "edit,value,lookback"');
|
||||
});
|
||||
|
||||
it('should update schedule values in rules with a valid payload', async () => {
|
||||
const ruleId = 'ruleId';
|
||||
const intervalMinutes = 15;
|
||||
const interval = `${intervalMinutes}m`;
|
||||
const lookbackMinutes = 10;
|
||||
const lookback = `${lookbackMinutes}m`;
|
||||
await createRule(supertest, log, getSimpleRule(ruleId));
|
||||
|
||||
const { body } = await postBulkAction()
|
||||
.send({
|
||||
query: '',
|
||||
action: BulkAction.edit,
|
||||
[BulkAction.edit]: [
|
||||
{
|
||||
type: BulkActionEditType.set_schedule,
|
||||
value: {
|
||||
interval,
|
||||
lookback,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(body.attributes.summary).to.eql({ failed: 0, succeeded: 1, total: 1 });
|
||||
|
||||
expect(body.attributes.results.updated[0].interval).to.eql(interval);
|
||||
expect(body.attributes.results.updated[0].meta).to.eql({ from: `${lookbackMinutes}m` });
|
||||
expect(body.attributes.results.updated[0].from).to.eql(
|
||||
`now-${(intervalMinutes + lookbackMinutes) * 60}s`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('overwrite_data_views', () => {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue