diff --git a/docs/accessibility.asciidoc b/docs/accessibility.asciidoc new file mode 100644 index 00000000000000..4869d35dab1568 --- /dev/null +++ b/docs/accessibility.asciidoc @@ -0,0 +1,65 @@ +[chapter] +[[accessibility]] += Accessibility Statement for Kibana +++++ +Accessibility +++++ + +Elastic is committed to ensuring digital accessibility for people with disabilities. We are continually improving the user experience, and strive toward ensuring our tools are usable by everyone. + +[float] +[[accessibility-measures]] +== Measures to support accessibility +Elastic takes the following measures to ensure accessibility of Kibana: + +* Maintains and incorporates an https://elastic.github.io/eui/[accessible component library]. +* Provides continual accessibility training for our staff. +* Employs a third-party audit. + +[float] +[[accessibility-conformance-status]] +== Conformance status +Kibana aims to meet https://www.w3.org/WAI/WCAG21/quickref/?currentsidebar=%23col_customize&levels=aaa&technologies=server%2Csmil%2Cflash%2Csl[WCAG 2.1 level AA] compliance. Currently, we can only claim to partially conform, meaning we do not fully meet all of the success criteria. However, we do try to take a broader view of accessibility, and go above and beyond the legal and regulatory standards to provide a good experience for all of our users. + +[float] +[[accessibility-feedback]] +== Feedback +We welcome your feedback on the accessibility of Kibana. Please let us know if you encounter accessibility barriers on Kibana by either emailing us at accessibility@elastic.co or opening https://github.com/elastic/kibana/issues/new?labels=Project%3AAccessibility&template=Accessibility.md&title=%28Accessibility%29[an issue on GitHub]. + +[float] +[[accessibility-specs]] +== Technical specifications +Accessibility of Kibana relies on the following technologies to work with your web browser and any assistive technologies or plugins installed on your computer: + +* HTML +* CSS +* JavaScript +* WAI-ARIA + +[float] +[[accessibility-limitations-and-alternatives]] +== Limitations and alternatives +Despite our best efforts to ensure accessibility of Kibana, there are some limitations. Please https://github.com/elastic/kibana/issues/new?labels=Project%3AAccessibility&template=Accessibility.md&title=%28Accessibility%29[open an issue on GitHub] if you observe an issue not in this list. + +Known limitations are in the following areas: + +* *Charts*: We have a clear plan for the first steps of making charts accessible. We’ve opened this https://github.com/elastic/elastic-charts/issues/300[Charts accessibility ticket on GitHub] for tracking our progress. +* *Maps*: Maps might pose difficulties to users with vision disabilities. We welcome your input on making our maps accessible. Go to the https://github.com/elastic/kibana/issues/57271[Maps accessibility ticket on GitHub] to join the discussion and view our plans. +* *Tables*: Although generally accessible and marked-up as standard HTML tables with column headers, tables rarely make use of row headers and have poor captions. You will see incremental improvements as various applications adopt a new accessible component. +* *Color contrast*: Modern Kibana interfaces generally do not have color contrast issues. However, older code might fall below the recommended contrast levels. As we continue to update our code, this issue will phase out naturally. + +To see individual tickets, view our https://github.com/elastic/kibana/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3AProject%3AAccessibility[GitHub issues with label "`Project:Accessibility`"]. + +[float] +[[accessibility-approach]] +== Assessment approach +Elastic assesses the accessibility of Kibana with the following approaches: + +* *Self-evaluation*: Our employees are familiar with accessibility standards and review new designs and implemented features to confirm that they are accessible. +* *External evaluation*: We engage external contractors to help us conduct an independent assessment and generate a formal VPAT. Please email accessibility@elastic.co if you’d like a copy. +* *Automated evaluation*: We are starting to run https://www.deque.com/axe/[axe] on every page. See our current progress in the https://github.com/elastic/kibana/issues/51456[automated testing GitHub issue]. + +Manual testing largely focuses on screen reader support and is done on: + +* VoiceOver on MacOS with Safari, Chrome and Edge +* NVDA on Windows with Chrome and Firefox diff --git a/docs/index.asciidoc b/docs/index.asciidoc index 491a9629e983e6..5474772ab7da81 100644 --- a/docs/index.asciidoc +++ b/docs/index.asciidoc @@ -22,6 +22,8 @@ include::{asciidoc-dir}/../../shared/attributes.asciidoc[] include::user/index.asciidoc[] +include::accessibility.asciidoc[] + include::limitations.asciidoc[] include::release-notes/highlights.asciidoc[] diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index f3e401bedcef33..eec75033e8beb0 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -88442,7 +88442,7 @@ module.exports = function kindOf(val) { }; function ctorName(val) { - return val.constructor ? val.constructor.name : null; + return typeof val.constructor === 'function' ? val.constructor.name : null; } function isArray(val) { @@ -88653,7 +88653,7 @@ module.exports = function kindOf(val) { }; function ctorName(val) { - return val.constructor ? val.constructor.name : null; + return typeof val.constructor === 'function' ? val.constructor.name : null; } function isArray(val) { @@ -88844,7 +88844,7 @@ module.exports = function kindOf(val) { }; function ctorName(val) { - return val.constructor ? val.constructor.name : null; + return typeof val.constructor === 'function' ? val.constructor.name : null; } function isArray(val) { @@ -101921,7 +101921,7 @@ module.exports = function kindOf(val) { }; function ctorName(val) { - return val.constructor ? val.constructor.name : null; + return typeof val.constructor === 'function' ? val.constructor.name : null; } function isArray(val) { @@ -104780,7 +104780,7 @@ module.exports = function kindOf(val) { }; function ctorName(val) { - return val.constructor ? val.constructor.name : null; + return typeof val.constructor === 'function' ? val.constructor.name : null; } function isArray(val) { diff --git a/x-pack/legacy/plugins/alerting/common/types.ts b/x-pack/legacy/plugins/alerting/common/alert.ts similarity index 94% rename from x-pack/legacy/plugins/alerting/common/types.ts rename to x-pack/legacy/plugins/alerting/common/alert.ts index 54bf04d0765d63..8f28c8fbaed7f7 100644 --- a/x-pack/legacy/plugins/alerting/common/types.ts +++ b/x-pack/legacy/plugins/alerting/common/alert.ts @@ -5,12 +5,13 @@ */ import { SavedObjectAttributes } from 'kibana/server'; -import { AlertActionParams } from '../server/types'; export interface IntervalSchedule extends SavedObjectAttributes { interval: string; } +export type AlertActionParams = SavedObjectAttributes; + export interface AlertAction { group: string; id: string; diff --git a/x-pack/legacy/plugins/alerting/common/alert_instance.ts b/x-pack/legacy/plugins/alerting/common/alert_instance.ts new file mode 100644 index 00000000000000..a6852f06efd347 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/common/alert_instance.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as t from 'io-ts'; +import { DateFromString } from './date_from_string'; + +const metaSchema = t.partial({ + lastScheduledActions: t.type({ + group: t.string, + date: DateFromString, + }), +}); +export type AlertInstanceMeta = t.TypeOf; + +const stateSchema = t.record(t.string, t.unknown); +export type AlertInstanceState = t.TypeOf; + +export const rawAlertInstance = t.partial({ + state: stateSchema, + meta: metaSchema, +}); +export type RawAlertInstance = t.TypeOf; diff --git a/x-pack/legacy/plugins/alerting/common/alert_task_instance.ts b/x-pack/legacy/plugins/alerting/common/alert_task_instance.ts new file mode 100644 index 00000000000000..50722a471f3d78 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/common/alert_task_instance.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as t from 'io-ts'; +import { rawAlertInstance } from './alert_instance'; +import { DateFromString } from './date_from_string'; + +export const alertStateSchema = t.partial({ + alertTypeState: t.record(t.string, t.unknown), + alertInstances: t.record(t.string, rawAlertInstance), + previousStartedAt: t.union([t.null, DateFromString]), +}); + +export type AlertTaskState = t.TypeOf; + +export const alertParamsSchema = t.intersection([ + t.type({ + alertId: t.string, + }), + t.partial({ + spaceId: t.string, + }), +]); +export type AlertTaskParams = t.TypeOf; diff --git a/x-pack/legacy/plugins/alerting/common/date_from_string.test.ts b/x-pack/legacy/plugins/alerting/common/date_from_string.test.ts new file mode 100644 index 00000000000000..ecf7bdb3245785 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/common/date_from_string.test.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DateFromString } from './date_from_string'; +import { right, isLeft } from 'fp-ts/lib/Either'; + +describe('DateFromString', () => { + test('validated and parses a string into a Date', () => { + const date = new Date(1973, 10, 30); + expect(DateFromString.decode(date.toISOString())).toEqual(right(date)); + }); + + test('validated and returns a failure for an actual Date', () => { + const date = new Date(1973, 10, 30); + expect(isLeft(DateFromString.decode(date))).toEqual(true); + }); + + test('validated and returns a failure for an invalid Date string', () => { + expect(isLeft(DateFromString.decode('1234-23-45'))).toEqual(true); + }); + + test('validated and returns a failure for a null value', () => { + expect(isLeft(DateFromString.decode(null))).toEqual(true); + }); +}); diff --git a/x-pack/legacy/plugins/alerting/common/date_from_string.ts b/x-pack/legacy/plugins/alerting/common/date_from_string.ts new file mode 100644 index 00000000000000..831891fc12d92b --- /dev/null +++ b/x-pack/legacy/plugins/alerting/common/date_from_string.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { either } from 'fp-ts/lib/Either'; + +// represents a Date from an ISO string +export const DateFromString = new t.Type( + 'DateFromString', + // detect the type + (value): value is Date => value instanceof Date, + (valueToDecode, context) => + either.chain( + // validate this is a string + t.string.validate(valueToDecode, context), + // decode + value => { + const decoded = new Date(value); + return isNaN(decoded.getTime()) ? t.failure(valueToDecode, context) : t.success(decoded); + } + ), + valueToEncode => valueToEncode.toISOString() +); diff --git a/x-pack/legacy/plugins/alerting/common/index.ts b/x-pack/legacy/plugins/alerting/common/index.ts index 9f4141dbcae7df..03b3487f10f1d9 100644 --- a/x-pack/legacy/plugins/alerting/common/index.ts +++ b/x-pack/legacy/plugins/alerting/common/index.ts @@ -4,4 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './types'; +export * from './alert'; +export * from './alert_instance'; +export * from './alert_task_instance'; diff --git a/x-pack/legacy/plugins/alerting/server/alert_instance/alert_instance.ts b/x-pack/legacy/plugins/alerting/server/alert_instance/alert_instance.ts index df67f7d2a1d9ee..4d106178f86fb4 100644 --- a/x-pack/legacy/plugins/alerting/server/alert_instance/alert_instance.ts +++ b/x-pack/legacy/plugins/alerting/server/alert_instance/alert_instance.ts @@ -3,10 +3,14 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import * as t from 'io-ts'; +import { + AlertInstanceMeta, + AlertInstanceState, + RawAlertInstance, + rawAlertInstance, +} from '../../common'; import { State, Context } from '../types'; -import { DateFromString } from '../lib/types'; import { parseDuration } from '../lib'; interface ScheduledExecutionOptions { @@ -14,24 +18,7 @@ interface ScheduledExecutionOptions { context: Context; state: State; } - -const metaSchema = t.partial({ - lastScheduledActions: t.type({ - group: t.string, - date: DateFromString, - }), -}); -type AlertInstanceMeta = t.TypeOf; - -const stateSchema = t.record(t.string, t.unknown); -type AlertInstanceState = t.TypeOf; - -export const rawAlertInstance = t.partial({ - state: stateSchema, - meta: metaSchema, -}); -export type RawAlertInstance = t.TypeOf; - +export type AlertInstances = Record; export class AlertInstance { private scheduledExecutionOptions?: ScheduledExecutionOptions; private meta: AlertInstanceMeta; diff --git a/x-pack/legacy/plugins/alerting/server/alert_instance/index.ts b/x-pack/legacy/plugins/alerting/server/alert_instance/index.ts index fc828096adf284..40ee0874e805cd 100644 --- a/x-pack/legacy/plugins/alerting/server/alert_instance/index.ts +++ b/x-pack/legacy/plugins/alerting/server/alert_instance/index.ts @@ -4,5 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { AlertInstance, RawAlertInstance, rawAlertInstance } from './alert_instance'; +export { AlertInstance } from './alert_instance'; export { createAlertInstanceFactory } from './create_alert_instance_factory'; diff --git a/x-pack/legacy/plugins/alerting/server/alerts_client.ts b/x-pack/legacy/plugins/alerting/server/alerts_client.ts index 709c6d3faefba0..1875ac04ddaa79 100644 --- a/x-pack/legacy/plugins/alerting/server/alerts_client.ts +++ b/x-pack/legacy/plugins/alerting/server/alerts_client.ts @@ -22,6 +22,7 @@ import { AlertType, IntervalSchedule, SanitizedAlert, + AlertTaskState, } from './types'; import { validateAlertTypeParams } from './lib'; import { @@ -31,7 +32,7 @@ import { } from '../../../../plugins/security/server'; import { EncryptedSavedObjectsPluginStart } from '../../../../plugins/encrypted_saved_objects/server'; import { TaskManagerStartContract } from '../../../../plugins/task_manager/server'; -import { AlertTaskState, taskInstanceToAlertTaskInstance } from './task_runner/alert_task_instance'; +import { taskInstanceToAlertTaskInstance } from './task_runner/alert_task_instance'; type NormalizedAlertAction = Omit; export type CreateAPIKeyResult = diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/alert_task_instance.ts b/x-pack/legacy/plugins/alerting/server/task_runner/alert_task_instance.ts index 33b416fe8e2da2..6bc318070377d3 100644 --- a/x-pack/legacy/plugins/alerting/server/task_runner/alert_task_instance.ts +++ b/x-pack/legacy/plugins/alerting/server/task_runner/alert_task_instance.ts @@ -7,32 +7,12 @@ import * as t from 'io-ts'; import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { ConcreteTaskInstance } from '../../../../../plugins/task_manager/server'; -import { SanitizedAlert } from '../types'; -import { DateFromString } from '../lib/types'; -import { AlertInstance, rawAlertInstance } from '../alert_instance'; +import { SanitizedAlert, AlertTaskState, alertParamsSchema, alertStateSchema } from '../../common'; export interface AlertTaskInstance extends ConcreteTaskInstance { state: AlertTaskState; } -export const alertStateSchema = t.partial({ - alertTypeState: t.record(t.string, t.unknown), - alertInstances: t.record(t.string, rawAlertInstance), - previousStartedAt: t.union([t.null, DateFromString]), -}); -export type AlertInstances = Record; -export type AlertTaskState = t.TypeOf; - -const alertParamsSchema = t.intersection([ - t.type({ - alertId: t.string, - }), - t.partial({ - spaceId: t.string, - }), -]); -export type AlertTaskParams = t.TypeOf; - const enumerateErrorFields = (e: t.Errors) => `${e.map(({ context }) => context.map(({ key }) => key).join('.'))}`; diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts index 1466d3ccd274b5..2632decb125f06 100644 --- a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts @@ -10,16 +10,21 @@ import { SavedObject } from '../../../../../../src/core/server'; import { TaskRunnerContext } from './task_runner_factory'; import { ConcreteTaskInstance } from '../../../../../plugins/task_manager/server'; import { createExecutionHandler } from './create_execution_handler'; -import { AlertInstance, createAlertInstanceFactory, RawAlertInstance } from '../alert_instance'; +import { AlertInstance, createAlertInstanceFactory } from '../alert_instance'; import { getNextRunAt } from './get_next_run_at'; import { validateAlertTypeParams } from '../lib'; -import { AlertType, RawAlert, IntervalSchedule, Services, AlertInfoParams } from '../types'; -import { promiseResult, map, Resultable, asOk, asErr, resolveErr } from '../lib/result_type'; import { + AlertType, + RawAlert, + IntervalSchedule, + Services, + AlertInfoParams, + RawAlertInstance, AlertTaskState, - AlertInstances, - taskInstanceToAlertTaskInstance, -} from './alert_task_instance'; +} from '../types'; +import { promiseResult, map, Resultable, asOk, asErr, resolveErr } from '../lib/result_type'; +import { taskInstanceToAlertTaskInstance } from './alert_task_instance'; +import { AlertInstances } from '../alert_instance/alert_instance'; const FALLBACK_RETRY_INTERVAL: IntervalSchedule = { interval: '5m' }; diff --git a/x-pack/legacy/plugins/alerting/server/types.ts b/x-pack/legacy/plugins/alerting/server/types.ts index fd87292ef9147b..44381dc279dd25 100644 --- a/x-pack/legacy/plugins/alerting/server/types.ts +++ b/x-pack/legacy/plugins/alerting/server/types.ts @@ -8,8 +8,7 @@ import { AlertInstance } from './alert_instance'; import { AlertTypeRegistry as OrigAlertTypeRegistry } from './alert_type_registry'; import { PluginSetupContract, PluginStartContract } from './plugin'; import { SavedObjectAttributes, SavedObjectsClientContract } from '../../../../../src/core/server'; -import { Alert } from '../common'; - +import { Alert, AlertActionParams } from '../common'; export * from '../common'; export type State = Record; @@ -57,8 +56,6 @@ export interface AlertType { executor: ({ services, params, state }: AlertExecutorOptions) => Promise; } -export type AlertActionParams = SavedObjectAttributes; - export interface RawAlertAction extends SavedObjectAttributes { group: string; actionRef: string; diff --git a/x-pack/legacy/plugins/canvas/server/plugin.ts b/x-pack/legacy/plugins/canvas/server/plugin.ts index 713747551ff478..014ff244e6e0c6 100644 --- a/x-pack/legacy/plugins/canvas/server/plugin.ts +++ b/x-pack/legacy/plugins/canvas/server/plugin.ts @@ -6,40 +6,9 @@ import { CoreSetup, PluginsSetup } from './shim'; import { functions } from '../canvas_plugin_src/functions/server'; -import { loadSampleData } from './sample_data'; export class Plugin { public setup(core: CoreSetup, plugins: PluginsSetup) { plugins.interpreter.register({ serverFunctions: functions }); - - plugins.features.registerFeature({ - id: 'canvas', - name: 'Canvas', - icon: 'canvasApp', - navLinkId: 'canvas', - app: ['canvas', 'kibana'], - catalogue: ['canvas'], - privileges: { - all: { - savedObject: { - all: ['canvas-workpad', 'canvas-element'], - read: ['index-pattern'], - }, - ui: ['save', 'show'], - }, - read: { - savedObject: { - all: [], - read: ['index-pattern', 'canvas-workpad', 'canvas-element'], - }, - ui: ['show'], - }, - }, - }); - - loadSampleData( - plugins.home.sampleData.addSavedObjectsToSampleDataset, - plugins.home.sampleData.addAppLinksToSampleDataset - ); } } diff --git a/x-pack/legacy/plugins/canvas/server/shim.ts b/x-pack/legacy/plugins/canvas/server/shim.ts index c9d70e6a721ee7..c36ee3a291dae3 100644 --- a/x-pack/legacy/plugins/canvas/server/shim.ts +++ b/x-pack/legacy/plugins/canvas/server/shim.ts @@ -29,10 +29,6 @@ export interface PluginsSetup { kibana: { injectedUiAppVars: ReturnType; }; - sampleData: { - addSavedObjectsToSampleDataset: any; - addAppLinksToSampleDataset: any; - }; usageCollection: UsageCollectionSetup; } diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts index da98944d5f0c97..dfd812251e3d66 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts @@ -139,7 +139,7 @@ export const enableRules = async ({ ids, enabled }: EnableRulesProps): Promise( `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, { - method: 'PUT', + method: 'PATCH', body: JSON.stringify(ids.map(id => ({ id, enabled }))), asResponse: true, } @@ -160,7 +160,7 @@ export const deleteRules = async ({ ids }: DeleteRulesProps): Promise( `${DETECTION_ENGINE_RULES_URL}/_bulk_delete`, { - method: 'PUT', + method: 'DELETE', body: JSON.stringify(ids.map(id => ({ id }))), asResponse: true, } diff --git a/x-pack/legacy/plugins/siem/public/hooks/api/api.tsx b/x-pack/legacy/plugins/siem/public/hooks/api/api.tsx index e29e2ed193f946..69848c08fa3f88 100644 --- a/x-pack/legacy/plugins/siem/public/hooks/api/api.tsx +++ b/x-pack/legacy/plugins/siem/public/hooks/api/api.tsx @@ -36,6 +36,8 @@ export const throwIfNotOk = async (response?: Response): Promise => { if (body != null && body.message) { if (body.statusCode != null) { throw new ToasterErrors([body.message, `${i18n.STATUS_CODE} ${body.statusCode}`]); + } else if (body.status_code != null) { + throw new ToasterErrors([body.message, `${i18n.STATUS_CODE} ${body.status_code}`]); } else { throw new ToasterErrors([body.message]); } diff --git a/x-pack/legacy/plugins/siem/public/utils/api/index.ts b/x-pack/legacy/plugins/siem/public/utils/api/index.ts index 1dc14413b04d21..3c70083136505d 100644 --- a/x-pack/legacy/plugins/siem/public/utils/api/index.ts +++ b/x-pack/legacy/plugins/siem/public/utils/api/index.ts @@ -8,6 +8,7 @@ export interface MessageBody { error?: string; message?: string; statusCode?: number; + status_code?: number; } export const parseJsonFromBody = async (response: Response): Promise => { diff --git a/x-pack/legacy/plugins/siem/server/kibana.index.ts b/x-pack/legacy/plugins/siem/server/kibana.index.ts index a488db3f0c3d73..bab7936005c04a 100644 --- a/x-pack/legacy/plugins/siem/server/kibana.index.ts +++ b/x-pack/legacy/plugins/siem/server/kibana.index.ts @@ -13,7 +13,7 @@ import { readIndexRoute } from './lib/detection_engine/routes/index/read_index_r import { readRulesRoute } from './lib/detection_engine/routes/rules/read_rules_route'; import { findRulesRoute } from './lib/detection_engine/routes/rules/find_rules_route'; import { deleteRulesRoute } from './lib/detection_engine/routes/rules/delete_rules_route'; -import { updateRulesRoute } from './lib/detection_engine/routes/rules/update_rules_route'; +import { patchRulesRoute } from './lib/detection_engine/routes/rules/patch_rules_route'; import { setSignalsStatusRoute } from './lib/detection_engine/routes/signals/open_close_signals_route'; import { querySignalsRoute } from './lib/detection_engine/routes/signals/query_signals_route'; import { ServerFacade } from './types'; @@ -23,12 +23,14 @@ import { readTagsRoute } from './lib/detection_engine/routes/tags/read_tags_rout import { readPrivilegesRoute } from './lib/detection_engine/routes/privileges/read_privileges_route'; import { addPrepackedRulesRoute } from './lib/detection_engine/routes/rules/add_prepackaged_rules_route'; import { createRulesBulkRoute } from './lib/detection_engine/routes/rules/create_rules_bulk_route'; -import { updateRulesBulkRoute } from './lib/detection_engine/routes/rules/update_rules_bulk_route'; +import { patchRulesBulkRoute } from './lib/detection_engine/routes/rules/patch_rules_bulk_route'; import { deleteRulesBulkRoute } from './lib/detection_engine/routes/rules/delete_rules_bulk_route'; import { importRulesRoute } from './lib/detection_engine/routes/rules/import_rules_route'; import { exportRulesRoute } from './lib/detection_engine/routes/rules/export_rules_route'; import { findRulesStatusesRoute } from './lib/detection_engine/routes/rules/find_rules_status_route'; import { getPrepackagedRulesStatusRoute } from './lib/detection_engine/routes/rules/get_prepackaged_rules_status_route'; +import { updateRulesRoute } from './lib/detection_engine/routes/rules/update_rules_route'; +import { updateRulesBulkRoute } from './lib/detection_engine/routes/rules/update_rules_bulk_route'; const APP_ID = 'siem'; @@ -50,12 +52,14 @@ export const initServerWithKibana = (context: PluginInitializerContext, __legacy updateRulesRoute(__legacy); deleteRulesRoute(__legacy); findRulesRoute(__legacy); + patchRulesRoute(__legacy); addPrepackedRulesRoute(__legacy); getPrepackagedRulesStatusRoute(__legacy); createRulesBulkRoute(__legacy); updateRulesBulkRoute(__legacy); deleteRulesBulkRoute(__legacy); + patchRulesBulkRoute(__legacy); importRulesRoute(__legacy); exportRulesRoute(__legacy); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 19c4279e06b032..b008ead8df9480 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -108,6 +108,14 @@ export const getUpdateRequest = (): ServerInjectOptions => ({ }, }); +export const getPatchRequest = (): ServerInjectOptions => ({ + method: 'PATCH', + url: DETECTION_ENGINE_RULES_URL, + payload: { + ...typicalPayload(), + }, +}); + export const getReadRequest = (): ServerInjectOptions => ({ method: 'GET', url: `${DETECTION_ENGINE_RULES_URL}?rule_id=rule-1`, @@ -130,6 +138,12 @@ export const getUpdateBulkRequest = (): ServerInjectOptions => ({ payload: [typicalPayload()], }); +export const getPatchBulkRequest = (): ServerInjectOptions => ({ + method: 'PATCH', + url: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, + payload: [typicalPayload()], +}); + export const getDeleteBulkRequest = (): ServerInjectOptions => ({ method: 'DELETE', url: `${DETECTION_ENGINE_RULES_URL}/_bulk_delete`, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/index/create_index_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/index/create_index_route.ts index 0eb090179b1925..e0d48836013ec0 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/index/create_index_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/index/create_index_route.ts @@ -5,7 +5,6 @@ */ import Hapi from 'hapi'; -import Boom from 'boom'; import { DETECTION_ENGINE_INDEX_URL } from '../../../../../common/constants'; import signalsPolicy from './signals_policy.json'; @@ -31,13 +30,18 @@ export const createCreateIndexRoute = (server: ServerFacade): Hapi.ServerRoute = }, }, }, - async handler(request: RequestFacade) { + async handler(request: RequestFacade, headers) { try { const index = getIndex(request, server); const callWithRequest = callWithRequestFactory(request, server); const indexExists = await getIndexExists(callWithRequest, index); if (indexExists) { - return new Boom(`index: "${index}" already exists`, { statusCode: 409 }); + return headers + .response({ + message: `index: "${index}" already exists`, + status_code: 409, + }) + .code(409); } else { const policyExists = await getPolicyExists(callWithRequest, index); if (!policyExists) { @@ -52,7 +56,13 @@ export const createCreateIndexRoute = (server: ServerFacade): Hapi.ServerRoute = return { acknowledged: true }; } } catch (err) { - return transformError(err); + const error = transformError(err); + return headers + .response({ + message: error.message, + status_code: error.statusCode, + }) + .code(error.statusCode); } }, }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/index/delete_index_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/index/delete_index_route.ts index 82fe0f55215fbd..c1edc824b81eb6 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/index/delete_index_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/index/delete_index_route.ts @@ -5,7 +5,6 @@ */ import Hapi from 'hapi'; -import Boom from 'boom'; import { DETECTION_ENGINE_INDEX_URL } from '../../../../../common/constants'; import { ServerFacade, RequestFacade } from '../../../../types'; @@ -39,13 +38,18 @@ export const createDeleteIndexRoute = (server: ServerFacade): Hapi.ServerRoute = }, }, }, - async handler(request: RequestFacade) { + async handler(request: RequestFacade, headers) { try { const index = getIndex(request, server); const callWithRequest = callWithRequestFactory(request, server); const indexExists = await getIndexExists(callWithRequest, index); if (!indexExists) { - return new Boom(`index: "${index}" does not exist`, { statusCode: 404 }); + return headers + .response({ + message: `index: "${index}" does not exist`, + status_code: 404, + }) + .code(404); } else { await deleteAllIndex(callWithRequest, `${index}-*`); const policyExists = await getPolicyExists(callWithRequest, index); @@ -59,7 +63,13 @@ export const createDeleteIndexRoute = (server: ServerFacade): Hapi.ServerRoute = return { acknowledged: true }; } } catch (err) { - return transformError(err); + const error = transformError(err); + return headers + .response({ + message: error.message, + status_code: error.statusCode, + }) + .code(error.statusCode); } }, }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/index/read_index_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/index/read_index_route.ts index a8c4b7407c4487..1a5018d446747e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/index/read_index_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/index/read_index_route.ts @@ -5,7 +5,6 @@ */ import Hapi from 'hapi'; -import Boom from 'boom'; import { DETECTION_ENGINE_INDEX_URL } from '../../../../../common/constants'; import { ServerFacade, RequestFacade } from '../../../../types'; @@ -42,11 +41,22 @@ export const createReadIndexRoute = (server: ServerFacade): Hapi.ServerRoute => if (request.method.toLowerCase() === 'head') { return headers.response().code(404); } else { - return new Boom('index for this space does not exist', { statusCode: 404 }); + return headers + .response({ + message: 'index for this space does not exist', + status_code: 404, + }) + .code(404); } } } catch (err) { - return transformError(err); + const error = transformError(err); + return headers + .response({ + message: error.message, + status_code: error.statusCode, + }) + .code(error.statusCode); } }, }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/privileges/read_privileges_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/privileges/read_privileges_route.ts index 5ea4dc7595b2b1..45ecb7dc972884 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/privileges/read_privileges_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/privileges/read_privileges_route.ts @@ -24,7 +24,7 @@ export const createReadPrivilegesRulesRoute = (server: ServerFacade): Hapi.Serve }, }, }, - async handler(request: RulesRequest) { + async handler(request: RulesRequest, headers) { try { const callWithRequest = callWithRequestFactory(request, server); const index = getIndex(request, server); @@ -35,7 +35,13 @@ export const createReadPrivilegesRulesRoute = (server: ServerFacade): Hapi.Serve has_encryption_key: !usingEphemeralEncryptionKey, }); } catch (err) { - return transformError(err); + const error = transformError(err); + return headers + .response({ + message: error.message, + status_code: error.statusCode, + }) + .code(error.statusCode); } }, }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts index e4f612a14832b1..ec86de84ff3c79 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts @@ -85,10 +85,9 @@ describe('add_prepackaged_rules_route', () => { alertsClient.create.mockResolvedValue(getResult()); const { payload } = await server.inject(addPrepackagedRulesRequest()); expect(JSON.parse(payload)).toEqual({ - error: 'Bad Request', message: 'Pre-packaged rules cannot be installed until the space index is created: .siem-signals-default', - statusCode: 400, + status_code: 400, }); }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts index 28af530272bc77..e796f21d9c03a2 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts @@ -6,7 +6,6 @@ import Hapi from 'hapi'; import { isFunction } from 'lodash/fp'; -import Boom from 'boom'; import { DETECTION_ENGINE_PREPACKAGED_URL } from '../../../../../common/constants'; import { ServerFacade, RequestFacade } from '../../../../types'; @@ -56,9 +55,12 @@ export const createAddPrepackedRulesRoute = (server: ServerFacade): Hapi.ServerR if (rulesToInstall.length !== 0 || rulesToUpdate.length !== 0) { const spaceIndexExists = await getIndexExists(callWithRequest, spaceIndex); if (!spaceIndexExists) { - return Boom.badRequest( - `Pre-packaged rules cannot be installed until the space index is created: ${spaceIndex}` - ); + return headers + .response({ + message: `Pre-packaged rules cannot be installed until the space index is created: ${spaceIndex}`, + status_code: 400, + }) + .code(400); } } await Promise.all( @@ -76,7 +78,13 @@ export const createAddPrepackedRulesRoute = (server: ServerFacade): Hapi.ServerR rules_updated: rulesToUpdate.length, }; } catch (err) { - return transformError(err); + const error = transformError(err); + return headers + .response({ + message: error.message, + status_code: error.statusCode, + }) + .code(error.statusCode); } }, }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts index 27575fb264f7b4..e51634c0d2c072 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts @@ -73,9 +73,8 @@ describe('create_rules', () => { alertsClient.create.mockResolvedValue(getResult()); const { payload } = await server.inject(getCreateRequest()); expect(JSON.parse(payload)).toEqual({ - error: 'Bad Request', message: 'To create a rule, the index must exist first. Index .siem-signals does not exist', - statusCode: 400, + status_code: 400, }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts index ec1df238f94838..de874f66d0444b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts @@ -6,7 +6,6 @@ import Hapi from 'hapi'; import { isFunction } from 'lodash/fp'; -import Boom from 'boom'; import uuid from 'uuid'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { createRules } from '../../rules/create_rules'; @@ -15,7 +14,7 @@ import { createRulesSchema } from '../schemas/create_rules_schema'; import { ServerFacade } from '../../../../types'; import { readRules } from '../../rules/read_rules'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; -import { transformOrError } from './utils'; +import { transform } from './utils'; import { getIndexExists } from '../../index/get_index_exists'; import { callWithRequestFactory, getIndex, transformError } from '../utils'; import { KibanaRequest } from '../../../../../../../../../src/core/server'; @@ -76,14 +75,22 @@ export const createCreateRulesRoute = (server: ServerFacade): Hapi.ServerRoute = const callWithRequest = callWithRequestFactory(request, server); const indexExists = await getIndexExists(callWithRequest, finalIndex); if (!indexExists) { - return Boom.badRequest( - `To create a rule, the index must exist first. Index ${finalIndex} does not exist` - ); + return headers + .response({ + message: `To create a rule, the index must exist first. Index ${finalIndex} does not exist`, + status_code: 400, + }) + .code(400); } if (ruleId != null) { const rule = await readRules({ alertsClient, ruleId }); if (rule != null) { - return Boom.conflict(`rule_id: "${ruleId}" already exists`); + return headers + .response({ + message: `rule_id: "${ruleId}" already exists`, + status_code: 409, + }) + .code(409); } } const createdRule = await createRules({ @@ -126,9 +133,25 @@ export const createCreateRulesRoute = (server: ServerFacade): Hapi.ServerRoute = search: `${createdRule.id}`, searchFields: ['alertId'], }); - return transformOrError(createdRule, ruleStatuses.saved_objects[0]); + const transformed = transform(createdRule, ruleStatuses.saved_objects[0]); + if (transformed == null) { + return headers + .response({ + message: 'Internal error transforming rules', + status_code: 500, + }) + .code(500); + } else { + return transformed; + } } catch (err) { - return transformError(err); + const error = transformError(err); + return headers + .response({ + message: error.message, + status_code: error.statusCode, + }) + .code(error.statusCode); } }, }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts index c2b5576c091835..b3f8eafa24115f 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts @@ -41,7 +41,7 @@ export const createDeleteRulesBulkRoute = (server: ServerFacade): Hapi.ServerRou if (!alertsClient || !savedObjectsClient) { return headers.response().code(404); } - const rules = Promise.all( + const rules = await Promise.all( request.payload.map(async payloadRule => { const { id, rule_id: ruleId } = payloadRule; const idOrRuleIdOrUnknown = id ?? ruleId ?? '(unknown id)'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts index 33f181cfbb5a5c..e4d3787c900721 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts @@ -11,7 +11,7 @@ import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { deleteRules } from '../../rules/delete_rules'; import { ServerFacade } from '../../../../types'; import { queryRulesSchema } from '../schemas/query_rules_schema'; -import { getIdError, transformOrError } from './utils'; +import { getIdError, transform } from './utils'; import { transformError } from '../utils'; import { QueryRequest, IRuleSavedAttributesSavedObjectAttributes } from '../../rules/types'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; @@ -62,12 +62,34 @@ export const createDeleteRulesRoute = (server: ServerFacade): Hapi.ServerRoute = ruleStatuses.saved_objects.forEach(async obj => savedObjectsClient.delete(ruleStatusSavedObjectType, obj.id) ); - return transformOrError(rule, ruleStatuses.saved_objects[0]); + const transformed = transform(rule, ruleStatuses.saved_objects[0]); + if (transformed == null) { + return headers + .response({ + message: 'Internal error transforming rules', + status_code: 500, + }) + .code(500); + } else { + return transformed; + } } else { - return getIdError({ id, ruleId }); + const error = getIdError({ id, ruleId }); + return headers + .response({ + message: error.message, + status_code: error.statusCode, + }) + .code(error.statusCode); } } catch (err) { - return transformError(err); + const error = transformError(err); + return headers + .response({ + message: error.message, + status_code: error.statusCode, + }) + .code(error.statusCode); } }, }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/export_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/export_rules_route.ts index ce624693428837..5da5ffcd58bf19 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/export_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/export_rules_route.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; import Hapi from 'hapi'; import { isFunction } from 'lodash/fp'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; @@ -14,6 +13,7 @@ import { getNonPackagedRulesCount } from '../../rules/get_existing_prepackaged_r import { exportRulesSchema, exportRulesQuerySchema } from '../schemas/export_rules_schema'; import { getExportByObjectIds } from '../../rules/get_export_by_object_ids'; import { getExportAll } from '../../rules/get_export_all'; +import { transformError } from '../utils'; export const createExportRulesRoute = (server: ServerFacade): Hapi.ServerRoute => { return { @@ -39,11 +39,21 @@ export const createExportRulesRoute = (server: ServerFacade): Hapi.ServerRoute = try { const exportSizeLimit = server.config().get('savedObjects.maxImportExportSize'); if (request.payload?.objects != null && request.payload.objects.length > exportSizeLimit) { - return Boom.badRequest(`Can't export more than ${exportSizeLimit} rules`); + return headers + .response({ + message: `Can't export more than ${exportSizeLimit} rules`, + status_code: 400, + }) + .code(400); } else { const nonPackagedRulesCount = await getNonPackagedRulesCount({ alertsClient }); if (nonPackagedRulesCount > exportSizeLimit) { - return Boom.badRequest(`Can't export more than ${exportSizeLimit} rules`); + return headers + .response({ + message: `Can't export more than ${exportSizeLimit} rules`, + status_code: 400, + }) + .code(400); } } @@ -59,8 +69,14 @@ export const createExportRulesRoute = (server: ServerFacade): Hapi.ServerRoute = return response .header('Content-Disposition', `attachment; filename="${request.query.file_name}"`) .header('Content-Type', 'application/ndjson'); - } catch { - return Boom.badRequest(`Sorry, something went wrong to export rules`); + } catch (err) { + const error = transformError(err); + return headers + .response({ + message: error.message, + status_code: error.statusCode, + }) + .code(error.statusCode); } }, }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_route.ts index 5b12703590407c..b15c1db7222cf4 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_route.ts @@ -11,7 +11,7 @@ import { findRules } from '../../rules/find_rules'; import { FindRulesRequest, IRuleSavedAttributesSavedObjectAttributes } from '../../rules/types'; import { findRulesSchema } from '../schemas/find_rules_schema'; import { ServerFacade } from '../../../../types'; -import { transformFindAlertsOrError } from './utils'; +import { transformFindAlerts } from './utils'; import { transformError } from '../utils'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; @@ -62,9 +62,25 @@ export const createFindRulesRoute = (): Hapi.ServerRoute => { return results; }) ); - return transformFindAlertsOrError(rules, ruleStatuses); + const transformed = transformFindAlerts(rules, ruleStatuses); + if (transformed == null) { + return headers + .response({ + message: 'unknown data type, error transforming alert', + status_code: 500, + }) + .code(500); + } else { + return transformed; + } } catch (err) { - return transformError(err); + const error = transformError(err); + return headers + .response({ + message: error.message, + status_code: error.statusCode, + }) + .code(error.statusCode); } }, }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/get_prepackaged_rule_status_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.test.ts similarity index 100% rename from x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/get_prepackaged_rule_status_route.test.ts rename to x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.test.ts diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.ts index ab6ee8e97a70f7..c999292ba7674d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.ts @@ -55,7 +55,13 @@ export const createGetPrepackagedRulesStatusRoute = (): Hapi.ServerRoute => { rules_not_updated: rulesToUpdate.length, }; } catch (err) { - return transformError(err); + const error = transformError(err); + return headers + .response({ + message: error.message, + status_code: error.statusCode, + }) + .code(error.statusCode); } }, }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts index 0d57f5739fc15a..5e87c99d815efc 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; import Hapi from 'hapi'; import { chunk, isEmpty, isFunction } from 'lodash/fp'; import { extname } from 'path'; @@ -24,18 +23,12 @@ import { } from '../utils'; import { createRulesStreamFromNdJson } from '../../rules/create_rules_stream_from_ndjson'; import { ImportRuleAlertRest } from '../../types'; -import { updateRules } from '../../rules/update_rules'; +import { patchRules } from '../../rules/patch_rules'; import { importRulesQuerySchema, importRulesPayloadSchema } from '../schemas/import_rules_schema'; import { KibanaRequest } from '../../../../../../../../../src/core/server'; type PromiseFromStreams = ImportRuleAlertRest | Error; -/* - * We were getting some error like that possible EventEmitter memory leak detected - * So we decide to batch the update by 10 to avoid any complication in the node side - * https://nodejs.org/docs/latest/api/events.html#events_emitter_setmaxlisteners_n - * - */ const CHUNK_PARSED_OBJECT_SIZE = 10; export const createImportRulesRoute = (server: ServerFacade): Hapi.ServerRoute => { @@ -71,13 +64,17 @@ export const createImportRulesRoute = (server: ServerFacade): Hapi.ServerRoute = const { filename } = request.payload.file.hapi; const fileExtension = extname(filename).toLowerCase(); if (fileExtension !== '.ndjson') { - return Boom.badRequest(`Invalid file extension ${fileExtension}`); + return headers + .response({ + message: `Invalid file extension ${fileExtension}`, + status_code: 400, + }) + .code(400); } const objectLimit = server.config().get('savedObjects.maxImportExportSize'); const readStream = createRulesStreamFromNdJson(request.payload.file, objectLimit); const parsedObjects = await createPromiseFromStreams([readStream]); - const uniqueParsedObjects = Array.from( parsedObjects .reduce( @@ -122,6 +119,7 @@ export const createImportRulesRoute = (server: ServerFacade): Hapi.ServerRoute = } const { description, + enabled, false_positives: falsePositives, from, immutable, @@ -166,7 +164,7 @@ export const createImportRulesRoute = (server: ServerFacade): Hapi.ServerRoute = alertsClient, actionsClient, description, - enabled: false, + enabled, falsePositives, from, immutable, @@ -194,12 +192,12 @@ export const createImportRulesRoute = (server: ServerFacade): Hapi.ServerRoute = }); resolve({ rule_id: ruleId, status_code: 200 }); } else if (rule != null && request.query.overwrite) { - await updateRules({ + await patchRules({ alertsClient, actionsClient, savedObjectsClient, description, - enabled: false, + enabled, falsePositives, from, immutable, @@ -232,7 +230,7 @@ export const createImportRulesRoute = (server: ServerFacade): Hapi.ServerRoute = createBulkErrorObject({ ruleId, statusCode: 409, - message: `This Rule "${rule.name}" already exists`, + message: `rule_id: "${ruleId}" already exists`, }) ); } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk.test.ts new file mode 100644 index 00000000000000..aa0dd04786a2ee --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk.test.ts @@ -0,0 +1,160 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockServer, + createMockServerWithoutAlertClientDecoration, +} from '../__mocks__/_mock_server'; + +import { patchRulesRoute } from './patch_rules_route'; +import { ServerInjectOptions } from 'hapi'; + +import { + getFindResult, + getResult, + updateActionResult, + typicalPayload, + getFindResultWithSingleHit, + getPatchBulkRequest, +} from '../__mocks__/request_responses'; +import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { patchRulesBulkRoute } from './patch_rules_bulk_route'; +import { BulkError } from '../utils'; + +describe('patch_rules_bulk', () => { + let { server, alertsClient, actionsClient } = createMockServer(); + + beforeEach(() => { + jest.resetAllMocks(); + ({ server, alertsClient, actionsClient } = createMockServer()); + patchRulesBulkRoute(server); + }); + + describe('status codes with actionClient and alertClient', () => { + test('returns 200 when updating a single rule with a valid actionClient and alertClient', async () => { + alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); + alertsClient.get.mockResolvedValue(getResult()); + actionsClient.update.mockResolvedValue(updateActionResult()); + alertsClient.update.mockResolvedValue(getResult()); + const { statusCode } = await server.inject(getPatchBulkRequest()); + expect(statusCode).toBe(200); + }); + + test('returns 200 as a response when updating a single rule that does not exist', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + alertsClient.get.mockResolvedValue(getResult()); + actionsClient.update.mockResolvedValue(updateActionResult()); + alertsClient.update.mockResolvedValue(getResult()); + const { statusCode } = await server.inject(getPatchBulkRequest()); + expect(statusCode).toBe(200); + }); + + test('returns 404 within the payload when updating a single rule that does not exist', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + alertsClient.get.mockResolvedValue(getResult()); + actionsClient.update.mockResolvedValue(updateActionResult()); + alertsClient.update.mockResolvedValue(getResult()); + const { payload } = await server.inject(getPatchBulkRequest()); + const parsed: BulkError[] = JSON.parse(payload); + const expected: BulkError[] = [ + { + error: { message: 'rule_id: "rule-1" not found', status_code: 404 }, + rule_id: 'rule-1', + }, + ]; + expect(parsed).toEqual(expected); + }); + + test('returns 404 if alertClient is not available on the route', async () => { + const { serverWithoutAlertClient } = createMockServerWithoutAlertClientDecoration(); + patchRulesRoute(serverWithoutAlertClient); + const { statusCode } = await serverWithoutAlertClient.inject(getPatchBulkRequest()); + expect(statusCode).toBe(404); + }); + }); + + describe('validation', () => { + test('returns 400 if id is not given in either the body or the url', async () => { + alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); + alertsClient.get.mockResolvedValue(getResult()); + const { rule_id, ...noId } = typicalPayload(); + const request: ServerInjectOptions = { + method: 'PATCH', + url: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, + payload: [noId], + }; + const { statusCode } = await server.inject(request); + expect(statusCode).toBe(400); + }); + + test('returns errors as 200 to just indicate ok something happened', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + actionsClient.update.mockResolvedValue(updateActionResult()); + alertsClient.update.mockResolvedValue(getResult()); + const request: ServerInjectOptions = { + method: 'PATCH', + url: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, + payload: [typicalPayload()], + }; + const { statusCode } = await server.inject(request); + expect(statusCode).toEqual(200); + }); + + test('returns 404 in the payload if the record does not exist yet', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + actionsClient.update.mockResolvedValue(updateActionResult()); + alertsClient.update.mockResolvedValue(getResult()); + const request: ServerInjectOptions = { + method: 'PATCH', + url: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, + payload: [typicalPayload()], + }; + const { payload } = await server.inject(request); + const parsed: BulkError[] = JSON.parse(payload); + const expected: BulkError[] = [ + { + error: { message: 'rule_id: "rule-1" not found', status_code: 404 }, + rule_id: 'rule-1', + }, + ]; + expect(parsed).toEqual(expected); + }); + + test('returns 200 if type is query', async () => { + alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); + alertsClient.get.mockResolvedValue(getResult()); + actionsClient.update.mockResolvedValue(updateActionResult()); + alertsClient.update.mockResolvedValue(getResult()); + const request: ServerInjectOptions = { + method: 'PATCH', + url: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, + payload: [typicalPayload()], + }; + const { statusCode } = await server.inject(request); + expect(statusCode).toBe(200); + }); + + test('returns 400 if type is not filter or kql', async () => { + alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); + alertsClient.get.mockResolvedValue(getResult()); + actionsClient.update.mockResolvedValue(updateActionResult()); + alertsClient.update.mockResolvedValue(getResult()); + const { type, ...noType } = typicalPayload(); + const request: ServerInjectOptions = { + method: 'PATCH', + url: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, + payload: [ + { + ...noType, + type: 'something-made-up', + }, + ], + }; + const { statusCode } = await server.inject(request); + expect(statusCode).toBe(400); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts new file mode 100644 index 00000000000000..00184b6c16b7e3 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Hapi from 'hapi'; +import { isFunction } from 'lodash/fp'; +import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { + BulkPatchRulesRequest, + IRuleSavedAttributesSavedObjectAttributes, +} from '../../rules/types'; +import { ServerFacade } from '../../../../types'; +import { transformOrBulkError, getIdBulkError } from './utils'; +import { transformBulkError } from '../utils'; +import { patchRulesBulkSchema } from '../schemas/patch_rules_bulk_schema'; +import { patchRules } from '../../rules/patch_rules'; +import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; +import { KibanaRequest } from '../../../../../../../../../src/core/server'; + +export const createPatchRulesBulkRoute = (server: ServerFacade): Hapi.ServerRoute => { + return { + method: 'PATCH', + path: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, + options: { + tags: ['access:siem'], + validate: { + options: { + abortEarly: false, + }, + payload: patchRulesBulkSchema, + }, + }, + async handler(request: BulkPatchRulesRequest, headers) { + const alertsClient = isFunction(request.getAlertsClient) ? request.getAlertsClient() : null; + const actionsClient = await server.plugins.actions.getActionsClientWithRequest( + KibanaRequest.from((request as unknown) as Hapi.Request) + ); + const savedObjectsClient = isFunction(request.getSavedObjectsClient) + ? request.getSavedObjectsClient() + : null; + if (!alertsClient || !savedObjectsClient) { + return headers.response().code(404); + } + + const rules = await Promise.all( + request.payload.map(async payloadRule => { + const { + description, + enabled, + false_positives: falsePositives, + from, + query, + language, + output_index: outputIndex, + saved_id: savedId, + timeline_id: timelineId, + timeline_title: timelineTitle, + meta, + filters, + rule_id: ruleId, + id, + index, + interval, + max_signals: maxSignals, + risk_score: riskScore, + name, + severity, + tags, + to, + type, + threat, + references, + version, + } = payloadRule; + const idOrRuleIdOrUnknown = id ?? ruleId ?? '(unknown id)'; + try { + const rule = await patchRules({ + alertsClient, + actionsClient, + description, + enabled, + falsePositives, + from, + query, + language, + outputIndex, + savedId, + savedObjectsClient, + timelineId, + timelineTitle, + meta, + filters, + id, + ruleId, + index, + interval, + maxSignals, + riskScore, + name, + severity, + tags, + to, + type, + threat, + references, + version, + }); + if (rule != null) { + const ruleStatuses = await savedObjectsClient.find< + IRuleSavedAttributesSavedObjectAttributes + >({ + type: ruleStatusSavedObjectType, + perPage: 1, + sortField: 'statusDate', + sortOrder: 'desc', + search: rule.id, + searchFields: ['alertId'], + }); + return transformOrBulkError(rule.id, rule, ruleStatuses.saved_objects[0]); + } else { + return getIdBulkError({ id, ruleId }); + } + } catch (err) { + return transformBulkError(idOrRuleIdOrUnknown, err); + } + }) + ); + return rules; + }, + }; +}; + +export const patchRulesBulkRoute = (server: ServerFacade): void => { + server.route(createPatchRulesBulkRoute(server)); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts new file mode 100644 index 00000000000000..d315d45046e2dd --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockServer, + createMockServerWithoutAlertClientDecoration, +} from '../__mocks__/_mock_server'; + +import { patchRulesRoute } from './patch_rules_route'; +import { ServerInjectOptions } from 'hapi'; + +import { + getFindResult, + getFindResultStatus, + getResult, + updateActionResult, + getPatchRequest, + typicalPayload, + getFindResultWithSingleHit, +} from '../__mocks__/request_responses'; +import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; + +describe('patch_rules', () => { + let { server, alertsClient, actionsClient, savedObjectsClient } = createMockServer(); + + beforeEach(() => { + jest.resetAllMocks(); + ({ server, alertsClient, actionsClient, savedObjectsClient } = createMockServer()); + patchRulesRoute(server); + }); + + describe('status codes with actionClient and alertClient', () => { + test('returns 200 when updating a single rule with a valid actionClient and alertClient', async () => { + alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); + alertsClient.get.mockResolvedValue(getResult()); + actionsClient.update.mockResolvedValue(updateActionResult()); + alertsClient.update.mockResolvedValue(getResult()); + savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); + const { statusCode } = await server.inject(getPatchRequest()); + expect(statusCode).toBe(200); + }); + + test('returns 404 when updating a single rule that does not exist', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + alertsClient.get.mockResolvedValue(getResult()); + actionsClient.update.mockResolvedValue(updateActionResult()); + alertsClient.update.mockResolvedValue(getResult()); + savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); + const { statusCode } = await server.inject(getPatchRequest()); + expect(statusCode).toBe(404); + }); + + test('returns 404 if alertClient is not available on the route', async () => { + const { serverWithoutAlertClient } = createMockServerWithoutAlertClientDecoration(); + patchRulesRoute(serverWithoutAlertClient); + const { statusCode } = await serverWithoutAlertClient.inject(getPatchRequest()); + expect(statusCode).toBe(404); + }); + }); + + describe('validation', () => { + test('returns 400 if id is not given in either the body or the url', async () => { + alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); + alertsClient.get.mockResolvedValue(getResult()); + savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); + const { rule_id, ...noId } = typicalPayload(); + const request: ServerInjectOptions = { + method: 'PATCH', + url: DETECTION_ENGINE_RULES_URL, + payload: { + payload: noId, + }, + }; + const { statusCode } = await server.inject(request); + expect(statusCode).toBe(400); + }); + + test('returns 404 if the record does not exist yet', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + actionsClient.update.mockResolvedValue(updateActionResult()); + alertsClient.update.mockResolvedValue(getResult()); + savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); + const request: ServerInjectOptions = { + method: 'PATCH', + url: DETECTION_ENGINE_RULES_URL, + payload: typicalPayload(), + }; + const { statusCode } = await server.inject(request); + expect(statusCode).toBe(404); + }); + + test('returns 200 if type is query', async () => { + alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); + alertsClient.get.mockResolvedValue(getResult()); + actionsClient.update.mockResolvedValue(updateActionResult()); + alertsClient.update.mockResolvedValue(getResult()); + savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); + const request: ServerInjectOptions = { + method: 'PATCH', + url: DETECTION_ENGINE_RULES_URL, + payload: typicalPayload(), + }; + const { statusCode } = await server.inject(request); + expect(statusCode).toBe(200); + }); + + test('returns 400 if type is not filter or kql', async () => { + alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); + alertsClient.get.mockResolvedValue(getResult()); + actionsClient.update.mockResolvedValue(updateActionResult()); + alertsClient.update.mockResolvedValue(getResult()); + savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); + const { type, ...noType } = typicalPayload(); + const request: ServerInjectOptions = { + method: 'PATCH', + url: DETECTION_ENGINE_RULES_URL, + payload: { + ...noType, + type: 'something-made-up', + }, + }; + const { statusCode } = await server.inject(request); + expect(statusCode).toBe(400); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts new file mode 100644 index 00000000000000..e27ae81362f27e --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Hapi from 'hapi'; +import { isFunction } from 'lodash/fp'; +import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { patchRules } from '../../rules/patch_rules'; +import { PatchRulesRequest, IRuleSavedAttributesSavedObjectAttributes } from '../../rules/types'; +import { patchRulesSchema } from '../schemas/patch_rules_schema'; +import { ServerFacade } from '../../../../types'; +import { getIdError, transform } from './utils'; +import { transformError } from '../utils'; +import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; +import { KibanaRequest } from '../../../../../../../../../src/core/server'; + +export const createPatchRulesRoute = (server: ServerFacade): Hapi.ServerRoute => { + return { + method: 'PATCH', + path: DETECTION_ENGINE_RULES_URL, + options: { + tags: ['access:siem'], + validate: { + options: { + abortEarly: false, + }, + payload: patchRulesSchema, + }, + }, + async handler(request: PatchRulesRequest, headers) { + const { + description, + enabled, + false_positives: falsePositives, + from, + query, + language, + output_index: outputIndex, + saved_id: savedId, + timeline_id: timelineId, + timeline_title: timelineTitle, + meta, + filters, + rule_id: ruleId, + id, + index, + interval, + max_signals: maxSignals, + risk_score: riskScore, + name, + severity, + tags, + to, + type, + threat, + references, + version, + } = request.payload; + + const alertsClient = isFunction(request.getAlertsClient) ? request.getAlertsClient() : null; + const actionsClient = await server.plugins.actions.getActionsClientWithRequest( + KibanaRequest.from((request as unknown) as Hapi.Request) + ); + const savedObjectsClient = isFunction(request.getSavedObjectsClient) + ? request.getSavedObjectsClient() + : null; + if (!alertsClient || !savedObjectsClient) { + return headers.response().code(404); + } + + try { + const rule = await patchRules({ + alertsClient, + actionsClient, + description, + enabled, + falsePositives, + from, + query, + language, + outputIndex, + savedId, + savedObjectsClient, + timelineId, + timelineTitle, + meta, + filters, + id, + ruleId, + index, + interval, + maxSignals, + riskScore, + name, + severity, + tags, + to, + type, + threat, + references, + version, + }); + if (rule != null) { + const ruleStatuses = await savedObjectsClient.find< + IRuleSavedAttributesSavedObjectAttributes + >({ + type: ruleStatusSavedObjectType, + perPage: 1, + sortField: 'statusDate', + sortOrder: 'desc', + search: rule.id, + searchFields: ['alertId'], + }); + const transformed = transform(rule, ruleStatuses.saved_objects[0]); + if (transformed == null) { + return headers + .response({ + message: 'Internal error transforming rules', + status_code: 500, + }) + .code(500); + } else { + return transformed; + } + } else { + const error = getIdError({ id, ruleId }); + return headers + .response({ + message: error.message, + status_code: error.statusCode, + }) + .code(error.statusCode); + } + } catch (err) { + const error = transformError(err); + return headers + .response({ + message: error.message, + status_code: error.statusCode, + }) + .code(error.statusCode); + } + }, + }; +}; + +export const patchRulesRoute = (server: ServerFacade) => { + server.route(createPatchRulesRoute(server)); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/read_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/read_rules_route.ts index 55fecdc14f7558..e82ad92704695c 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/read_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/read_rules_route.ts @@ -7,7 +7,7 @@ import Hapi from 'hapi'; import { isFunction } from 'lodash/fp'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; -import { getIdError, transformOrError } from './utils'; +import { getIdError, transform } from './utils'; import { transformError } from '../utils'; import { readRules } from '../../rules/read_rules'; @@ -54,12 +54,34 @@ export const createReadRulesRoute: Hapi.ServerRoute = { search: rule.id, searchFields: ['alertId'], }); - return transformOrError(rule, ruleStatuses.saved_objects[0]); + const transformedOrError = transform(rule, ruleStatuses.saved_objects[0]); + if (transformedOrError == null) { + return headers + .response({ + message: 'Internal error transforming rules', + status_code: 500, + }) + .code(500); + } else { + return transformedOrError; + } } else { - return getIdError({ id, ruleId }); + const error = getIdError({ id, ruleId }); + return headers + .response({ + message: error.message, + status_code: error.statusCode, + }) + .code(error.statusCode); } } catch (err) { - return transformError(err); + const error = transformError(err); + return headers + .response({ + message: error.message, + status_code: error.statusCode, + }) + .code(error.statusCode); } }, }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts index 8c7558d6d4fb57..671497f9f65db9 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts @@ -13,11 +13,11 @@ import { } from '../../rules/types'; import { ServerFacade } from '../../../../types'; import { transformOrBulkError, getIdBulkError } from './utils'; -import { transformBulkError } from '../utils'; +import { transformBulkError, getIndex } from '../utils'; import { updateRulesBulkSchema } from '../schemas/update_rules_bulk_schema'; -import { updateRules } from '../../rules/update_rules'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; import { KibanaRequest } from '../../../../../../../../../src/core/server'; +import { updateRules } from '../../rules/update_rules'; export const createUpdateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRoute => { return { @@ -44,7 +44,7 @@ export const createUpdateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRou return headers.response().code(404); } - const rules = Promise.all( + const rules = await Promise.all( request.payload.map(async payloadRule => { const { description, @@ -74,6 +74,7 @@ export const createUpdateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRou references, version, } = payloadRule; + const finalIndex = outputIndex != null ? outputIndex : getIndex(request, server); const idOrRuleIdOrUnknown = id ?? ruleId ?? '(unknown id)'; try { const rule = await updateRules({ @@ -81,11 +82,12 @@ export const createUpdateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRou actionsClient, description, enabled, + immutable: false, falsePositives, from, query, language, - outputIndex, + outputIndex: finalIndex, savedId, savedObjectsClient, timelineId, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts index 590307e06a26a9..a01627d2094b79 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts @@ -7,14 +7,14 @@ import Hapi from 'hapi'; import { isFunction } from 'lodash/fp'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; -import { updateRules } from '../../rules/update_rules'; import { UpdateRulesRequest, IRuleSavedAttributesSavedObjectAttributes } from '../../rules/types'; import { updateRulesSchema } from '../schemas/update_rules_schema'; import { ServerFacade } from '../../../../types'; -import { getIdError, transformOrError } from './utils'; -import { transformError } from '../utils'; +import { getIdError, transform } from './utils'; +import { transformError, getIndex } from '../utils'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; import { KibanaRequest } from '../../../../../../../../../src/core/server'; +import { updateRules } from '../../rules/update_rules'; export const createUpdateRulesRoute = (server: ServerFacade): Hapi.ServerRoute => { return { @@ -39,8 +39,8 @@ export const createUpdateRulesRoute = (server: ServerFacade): Hapi.ServerRoute = language, output_index: outputIndex, saved_id: savedId, - timeline_id: timelineId = null, - timeline_title: timelineTitle = null, + timeline_id: timelineId, + timeline_title: timelineTitle, meta, filters, rule_id: ruleId, @@ -71,6 +71,7 @@ export const createUpdateRulesRoute = (server: ServerFacade): Hapi.ServerRoute = } try { + const finalIndex = outputIndex != null ? outputIndex : getIndex(request, server); const rule = await updateRules({ alertsClient, actionsClient, @@ -78,9 +79,10 @@ export const createUpdateRulesRoute = (server: ServerFacade): Hapi.ServerRoute = enabled, falsePositives, from, + immutable: false, query, language, - outputIndex, + outputIndex: finalIndex, savedId, savedObjectsClient, timelineId, @@ -113,12 +115,34 @@ export const createUpdateRulesRoute = (server: ServerFacade): Hapi.ServerRoute = search: rule.id, searchFields: ['alertId'], }); - return transformOrError(rule, ruleStatuses.saved_objects[0]); + const transformed = transform(rule, ruleStatuses.saved_objects[0]); + if (transformed == null) { + return headers + .response({ + message: 'Internal error transforming rules', + status_code: 500, + }) + .code(500); + } else { + return transformed; + } } else { - return getIdError({ id, ruleId }); + const error = getIdError({ id, ruleId }); + return headers + .response({ + message: error.message, + status_code: error.statusCode, + }) + .code(error.statusCode); } } catch (err) { - return transformError(err); + const error = transformError(err); + return headers + .response({ + message: error.message, + status_code: error.statusCode, + }) + .code(error.statusCode); } }, }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts index ec11a8fb2da39d..7e7d67333e78d9 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts @@ -4,13 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; - import { transformAlertToRule, getIdError, - transformFindAlertsOrError, - transformOrError, + transformFindAlerts, + transform, transformTags, getIdBulkError, transformOrBulkError, @@ -547,55 +545,87 @@ describe('utils', () => { }); describe('getIdError', () => { + test('it should have a status code', () => { + const error = getIdError({ id: '123', ruleId: undefined }); + expect(error).toEqual({ + message: 'id: "123" not found', + statusCode: 404, + }); + }); + test('outputs message about id not being found if only id is defined and ruleId is undefined', () => { - const boom = getIdError({ id: '123', ruleId: undefined }); - expect(boom.message).toEqual('id: "123" not found'); + const error = getIdError({ id: '123', ruleId: undefined }); + expect(error).toEqual({ + message: 'id: "123" not found', + statusCode: 404, + }); }); test('outputs message about id not being found if only id is defined and ruleId is null', () => { - const boom = getIdError({ id: '123', ruleId: null }); - expect(boom.message).toEqual('id: "123" not found'); + const error = getIdError({ id: '123', ruleId: null }); + expect(error).toEqual({ + message: 'id: "123" not found', + statusCode: 404, + }); }); test('outputs message about ruleId not being found if only ruleId is defined and id is undefined', () => { - const boom = getIdError({ id: undefined, ruleId: 'rule-id-123' }); - expect(boom.message).toEqual('rule_id: "rule-id-123" not found'); + const error = getIdError({ id: undefined, ruleId: 'rule-id-123' }); + expect(error).toEqual({ + message: 'rule_id: "rule-id-123" not found', + statusCode: 404, + }); }); test('outputs message about ruleId not being found if only ruleId is defined and id is null', () => { - const boom = getIdError({ id: null, ruleId: 'rule-id-123' }); - expect(boom.message).toEqual('rule_id: "rule-id-123" not found'); + const error = getIdError({ id: null, ruleId: 'rule-id-123' }); + expect(error).toEqual({ + message: 'rule_id: "rule-id-123" not found', + statusCode: 404, + }); }); test('outputs message about both being not defined when both are undefined', () => { - const boom = getIdError({ id: undefined, ruleId: undefined }); - expect(boom.message).toEqual('id or rule_id should have been defined'); + const error = getIdError({ id: undefined, ruleId: undefined }); + expect(error).toEqual({ + message: 'id or rule_id should have been defined', + statusCode: 404, + }); }); test('outputs message about both being not defined when both are null', () => { - const boom = getIdError({ id: null, ruleId: null }); - expect(boom.message).toEqual('id or rule_id should have been defined'); + const error = getIdError({ id: null, ruleId: null }); + expect(error).toEqual({ + message: 'id or rule_id should have been defined', + statusCode: 404, + }); }); test('outputs message about both being not defined when id is null and ruleId is undefined', () => { - const boom = getIdError({ id: null, ruleId: undefined }); - expect(boom.message).toEqual('id or rule_id should have been defined'); + const error = getIdError({ id: null, ruleId: undefined }); + expect(error).toEqual({ + message: 'id or rule_id should have been defined', + statusCode: 404, + }); }); test('outputs message about both being not defined when id is undefined and ruleId is null', () => { - const boom = getIdError({ id: undefined, ruleId: null }); - expect(boom.message).toEqual('id or rule_id should have been defined'); + const error = getIdError({ id: undefined, ruleId: null }); + expect(error).toEqual({ + message: 'id or rule_id should have been defined', + statusCode: 404, + }); }); }); - describe('transformFindAlertsOrError', () => { + describe('transformFindAlerts', () => { test('outputs empty data set when data set is empty correct', () => { - const output = transformFindAlertsOrError({ data: [] }); + const output = transformFindAlerts({ data: [] }); expect(output).toEqual({ data: [] }); }); test('outputs 200 if the data is of type siem alert', () => { - const output = transformFindAlertsOrError({ + const output = transformFindAlerts({ data: [getResult()], }); const expected: OutputRuleAlertRest = { @@ -663,14 +693,14 @@ describe('utils', () => { }); test('returns 500 if the data is not of type siem alert', () => { - const output = transformFindAlertsOrError({ data: [{ random: 1 }] }); - expect((output as Boom).message).toEqual('Internal error transforming'); + const output = transformFindAlerts({ data: [{ random: 1 }] }); + expect(output).toBeNull(); }); }); describe('transformOrError', () => { test('outputs 200 if the data is of type siem alert', () => { - const output = transformOrError(getResult()); + const output = transform(getResult()); const expected: OutputRuleAlertRest = { created_by: 'elastic', created_at: '2019-12-13T16:40:33.400Z', @@ -734,8 +764,8 @@ describe('utils', () => { }); test('returns 500 if the data is not of type siem alert', () => { - const output = transformOrError({ data: [{ random: 1 }] }); - expect((output as Boom).message).toEqual('Internal error transforming'); + const output = transform({ data: [{ random: 1 }] }); + expect(output).toBeNull(); }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts index b45db53c13d883..abb94c10209dca 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; import { pickBy } from 'lodash/fp'; import { SavedObject } from 'kibana/server'; import { INTERNAL_IDENTIFIER } from '../../../../../common/constants'; @@ -24,6 +23,7 @@ import { createSuccessObject, ImportSuccessError, createImportErrorObject, + OutputError, } from '../utils'; export const getIdError = ({ @@ -32,13 +32,22 @@ export const getIdError = ({ }: { id: string | undefined | null; ruleId: string | undefined | null; -}) => { +}): OutputError => { if (id != null) { - return Boom.notFound(`id: "${id}" not found`); + return { + message: `id: "${id}" not found`, + statusCode: 404, + }; } else if (ruleId != null) { - return Boom.notFound(`rule_id: "${ruleId}" not found`); + return { + message: `rule_id: "${ruleId}" not found`, + statusCode: 404, + }; } else { - return Boom.notFound('id or rule_id should have been defined'); + return { + message: 'id or rule_id should have been defined', + statusCode: 404, + }; } }; @@ -136,10 +145,10 @@ export const transformAlertsToRules = ( return alerts.map(alert => transformAlertToRule(alert)); }; -export const transformFindAlertsOrError = ( +export const transformFindAlerts = ( findResults: { data: unknown[] }, ruleStatuses?: unknown[] -): unknown | Boom => { +): unknown | null => { if (!ruleStatuses && isAlertTypes(findResults.data)) { findResults.data = findResults.data.map(alert => transformAlertToRule(alert)); return findResults; @@ -150,14 +159,14 @@ export const transformFindAlertsOrError = ( ); return findResults; } else { - return new Boom('Internal error transforming', { statusCode: 500 }); + return null; } }; -export const transformOrError = ( +export const transform = ( alert: unknown, ruleStatus?: unknown -): Partial | Boom => { +): Partial | null => { if (!ruleStatus && isAlertType(alert)) { return transformAlertToRule(alert); } @@ -166,7 +175,7 @@ export const transformOrError = ( } else if (isAlertType(alert) && isRuleStatusSavedObjectType(ruleStatus)) { return transformAlertToRule(alert, ruleStatus); } else { - return new Boom('Internal error transforming', { statusCode: 500 }); + return null; } }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_bulk_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_bulk_schema.test.ts index 1eab50848b822a..2a64478962ced4 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_bulk_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_bulk_schema.test.ts @@ -5,7 +5,7 @@ */ import { createRulesBulkSchema } from './create_rules_bulk_schema'; -import { UpdateRuleAlertParamsRest } from '../../rules/types'; +import { PatchRuleAlertParamsRest } from '../../rules/types'; // only the basics of testing are here. // see: create_rules_schema.test.ts for the bulk of the validation tests @@ -13,7 +13,7 @@ import { UpdateRuleAlertParamsRest } from '../../rules/types'; describe('create_rules_bulk_schema', () => { test('can take an empty array and validate it', () => { expect( - createRulesBulkSchema.validate>>([]).error + createRulesBulkSchema.validate>>([]).error ).toBeFalsy(); }); @@ -29,7 +29,7 @@ describe('create_rules_bulk_schema', () => { test('single array of [id] does validate', () => { expect( - createRulesBulkSchema.validate>>([ + createRulesBulkSchema.validate>>([ { rule_id: 'rule-1', risk_score: 50, @@ -49,7 +49,7 @@ describe('create_rules_bulk_schema', () => { test('two values of [id] does validate', () => { expect( - createRulesBulkSchema.validate>>([ + createRulesBulkSchema.validate>>([ { rule_id: 'rule-1', risk_score: 50, @@ -82,7 +82,7 @@ describe('create_rules_bulk_schema', () => { test('The default for "from" will be "now-6m"', () => { expect( - createRulesBulkSchema.validate>([ + createRulesBulkSchema.validate>([ { rule_id: 'rule-1', risk_score: 50, @@ -102,7 +102,7 @@ describe('create_rules_bulk_schema', () => { test('The default for "to" will be "now"', () => { expect( - createRulesBulkSchema.validate>([ + createRulesBulkSchema.validate>([ { rule_id: 'rule-1', risk_score: 50, @@ -122,7 +122,7 @@ describe('create_rules_bulk_schema', () => { test('You cannot set the severity to a value other than low, medium, high, or critical', () => { expect( - createRulesBulkSchema.validate>([ + createRulesBulkSchema.validate>([ { rule_id: 'rule-1', risk_score: 50, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts index d9605a265d28b8..052a149f3d4dc1 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts @@ -5,12 +5,12 @@ */ import { createRulesSchema } from './create_rules_schema'; -import { UpdateRuleAlertParamsRest } from '../../rules/types'; +import { PatchRuleAlertParamsRest } from '../../rules/types'; import { ThreatParams, RuleAlertParamsRest } from '../../types'; describe('create rules schema', () => { test('empty objects do not validate', () => { - expect(createRulesSchema.validate>({}).error).toBeTruthy(); + expect(createRulesSchema.validate>({}).error).toBeTruthy(); }); test('made up values do not validate', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_bulk_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_bulk_schema.test.ts new file mode 100644 index 00000000000000..cbcb9eba75bc15 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_bulk_schema.test.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { patchRulesBulkSchema } from './patch_rules_bulk_schema'; +import { PatchRuleAlertParamsRest } from '../../rules/types'; + +// only the basics of testing are here. +// see: patch_rules_schema.test.ts for the bulk of the validation tests +// this just wraps patchRulesSchema in an array +describe('patch_rules_bulk_schema', () => { + test('can take an empty array and validate it', () => { + expect( + patchRulesBulkSchema.validate>>([]).error + ).toBeFalsy(); + }); + + test('made up values do not validate', () => { + expect( + patchRulesBulkSchema.validate<[{ madeUp: string }]>([ + { + madeUp: 'hi', + }, + ]).error + ).toBeTruthy(); + }); + + test('single array of [id] does validate', () => { + expect( + patchRulesBulkSchema.validate>>([ + { + id: 'rule-1', + }, + ]).error + ).toBeFalsy(); + }); + + test('two values of [id] does validate', () => { + expect( + patchRulesBulkSchema.validate>>([ + { + id: 'rule-1', + }, + { + id: 'rule-2', + }, + ]).error + ).toBeFalsy(); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_bulk_schema.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_bulk_schema.ts new file mode 100644 index 00000000000000..ff813bce84add4 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_bulk_schema.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Joi from 'joi'; + +import { patchRulesSchema } from './patch_rules_schema'; + +export const patchRulesBulkSchema = Joi.array().items(patchRulesSchema); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts new file mode 100644 index 00000000000000..11bed22e1c047c --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts @@ -0,0 +1,1015 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { patchRulesSchema } from './patch_rules_schema'; +import { PatchRuleAlertParamsRest } from '../../rules/types'; +import { ThreatParams } from '../../types'; + +describe('patch rules schema', () => { + test('empty objects do not validate as they require at least id or rule_id', () => { + expect(patchRulesSchema.validate>({}).error).toBeTruthy(); + }); + + test('made up values do not validate', () => { + expect( + patchRulesSchema.validate>({ + madeUp: 'hi', + }).error + ).toBeTruthy(); + }); + + test('[id] does validate', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + }).error + ).toBeFalsy(); + }); + + test('[rule_id] does validate', () => { + expect( + patchRulesSchema.validate>({ + rule_id: 'rule-1', + }).error + ).toBeFalsy(); + }); + + test('[id] and [rule_id] does not validate', () => { + expect( + patchRulesSchema.validate>({ + id: 'id-1', + rule_id: 'rule-1', + }).error.message + ).toEqual('"value" contains a conflict between exclusive peers [id, rule_id]'); + }); + + test('[rule_id, description] does validate', () => { + expect( + patchRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + }).error + ).toBeFalsy(); + }); + + test('[id, description] does validate', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + }).error + ).toBeFalsy(); + }); + + test('[id, risk_score] does validate', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + risk_score: 10, + }).error + ).toBeFalsy(); + }); + + test('[rule_id, description, from] does validate', () => { + expect( + patchRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + }).error + ).toBeFalsy(); + }); + + test('[id, description, from] does validate', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + }).error + ).toBeFalsy(); + }); + + test('[rule_id, description, from, to] does validate', () => { + expect( + patchRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + }).error + ).toBeFalsy(); + }); + + test('[id, description, from, to] does validate', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + }).error + ).toBeFalsy(); + }); + + test('[rule_id, description, from, to, name] does validate', () => { + expect( + patchRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + name: 'some-name', + }).error + ).toBeFalsy(); + }); + + test('[id, description, from, to, name] does validate', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + name: 'some-name', + }).error + ).toBeFalsy(); + }); + + test('[rule_id, description, from, to, name, severity] does validate', () => { + expect( + patchRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + name: 'some-name', + severity: 'low', + }).error + ).toBeFalsy(); + }); + + test('[id, description, from, to, name, severity] does validate', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + name: 'some-name', + severity: 'low', + }).error + ).toBeFalsy(); + }); + + test('[rule_id, description, from, to, name, severity, type] does validate', () => { + expect( + patchRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + name: 'some-name', + severity: 'low', + type: 'query', + }).error + ).toBeFalsy(); + }); + + test('[id, description, from, to, name, severity, type] does validate', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + name: 'some-name', + severity: 'low', + type: 'query', + }).error + ).toBeFalsy(); + }); + + test('[rule_id, description, from, to, name, severity, type, interval] does validate', () => { + expect( + patchRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + }).error + ).toBeFalsy(); + }); + + test('[id, description, from, to, name, severity, type, interval] does validate', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + }).error + ).toBeFalsy(); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type] does validate', () => { + expect( + patchRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + }).error + ).toBeFalsy(); + }); + + test('[id, description, from, to, index, name, severity, interval, type] does validate', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + }).error + ).toBeFalsy(); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, query] does validate', () => { + expect( + patchRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + query: 'some query', + }).error + ).toBeFalsy(); + }); + + test('[id, description, from, to, index, name, severity, interval, type, query] does validate', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + query: 'some query', + }).error + ).toBeFalsy(); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, query, language] does validate', () => { + expect( + patchRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + query: 'some query', + language: 'kuery', + }).error + ).toBeFalsy(); + }); + + test('[id, description, from, to, index, name, severity, interval, type, query, language] does validate', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + query: 'some query', + language: 'kuery', + }).error + ).toBeFalsy(); + }); + + test('[rule_id, description, from, to, index, name, severity, type, filter] does validate', () => { + expect( + patchRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + }).error + ).toBeFalsy(); + }); + + test('[id, description, from, to, index, name, severity, type, filter] does validate', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + }).error + ).toBeFalsy(); + }); + + test('allows references to be sent as a valid value to patch with', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + }).error + ).toBeFalsy(); + }); + + test('does not default references to an array', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + query: 'some-query', + language: 'kuery', + }).value.references + ).toEqual(undefined); + }); + + test('does not default interval', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + type: 'query', + }).value.interval + ).toEqual(undefined); + }); + + test('does not default max signal', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + }).value.max_signals + ).toEqual(undefined); + }); + + test('references cannot be numbers', () => { + expect( + patchRulesSchema.validate< + Partial> & { references: number[] } + >({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + query: 'some-query', + language: 'kuery', + references: [5], + }).error.message + ).toEqual( + 'child "references" fails because ["references" at position 0 fails because ["0" must be a string]]' + ); + }); + + test('indexes cannot be numbers', () => { + expect( + patchRulesSchema.validate< + Partial> & { index: number[] } + >({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: [5], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + query: 'some-query', + language: 'kuery', + }).error.message + ).toEqual( + 'child "index" fails because ["index" at position 0 fails because ["0" must be a string]]' + ); + }); + + test('saved_id is not required when type is saved_query and will validate without it', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'saved_query', + }).error + ).toBeFalsy(); + }); + + test('saved_id validates with saved_query', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + }).error + ).toBeFalsy(); + }); + + test('saved_query type can have filters with it', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + filters: [], + }).error + ).toBeFalsy(); + }); + + test('language validates with kuery', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + }).error + ).toBeFalsy(); + }); + + test('language validates with lucene', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'lucene', + }).error + ).toBeFalsy(); + }); + + test('language does not validate with something made up', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'something-made-up', + }).error.message + ).toEqual('child "language" fails because ["language" must be one of [kuery, lucene]]'); + }); + + test('max_signals cannot be negative', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: -1, + }).error.message + ).toEqual('child "max_signals" fails because ["max_signals" must be greater than 0]'); + }); + + test('max_signals cannot be zero', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 0, + }).error.message + ).toEqual('child "max_signals" fails because ["max_signals" must be greater than 0]'); + }); + + test('max_signals can be 1', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + }).error + ).toBeFalsy(); + }); + + test('meta can be patched', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + meta: { whateverYouWant: 'anything_at_all' }, + }).error + ).toBeFalsy(); + }); + + test('You cannot patch meta as a string', () => { + expect( + patchRulesSchema.validate & { meta: string }>>( + { + id: 'rule-1', + meta: 'should not work', + } + ).error.message + ).toEqual('child "meta" fails because ["meta" must be an object]'); + }); + + test('filters cannot be a string', () => { + expect( + patchRulesSchema.validate< + Partial & { filters: string }> + >({ + rule_id: 'rule-1', + type: 'query', + filters: 'some string', + }).error.message + ).toEqual('child "filters" fails because ["filters" must be an array]'); + }); + + test('threat is not defaulted to empty array on patch', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + }).value.threat + ).toBe(undefined); + }); + + test('threat is not defaulted to undefined on patch with empty array', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + threat: [], + }).value.threat + ).toMatchObject([]); + }); + + test('threat is valid when updated with all sub-objects', () => { + const expected: ThreatParams[] = [ + { + framework: 'fake', + tactic: { + id: 'fakeId', + name: 'fakeName', + reference: 'fakeRef', + }, + technique: [ + { + id: 'techniqueId', + name: 'techniqueName', + reference: 'techniqueRef', + }, + ], + }, + ]; + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + threat: [ + { + framework: 'fake', + tactic: { + id: 'fakeId', + name: 'fakeName', + reference: 'fakeRef', + }, + technique: [ + { + id: 'techniqueId', + name: 'techniqueName', + reference: 'techniqueRef', + }, + ], + }, + ], + }).value.threat + ).toMatchObject(expected); + }); + + test('threat is invalid when updated with missing property framework', () => { + expect( + patchRulesSchema.validate< + Partial> & { + threat: Array>>; + } + >({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + threat: [ + { + tactic: { + id: 'fakeId', + name: 'fakeName', + reference: 'fakeRef', + }, + technique: [ + { + id: 'techniqueId', + name: 'techniqueName', + reference: 'techniqueRef', + }, + ], + }, + ], + }).error.message + ).toEqual( + 'child "threat" fails because ["threat" at position 0 fails because [child "framework" fails because ["framework" is required]]]' + ); + }); + + test('threat is invalid when updated with missing tactic sub-object', () => { + expect( + patchRulesSchema.validate< + Partial> & { + threat: Array>>; + } + >({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + threat: [ + { + framework: 'fake', + technique: [ + { + id: 'techniqueId', + name: 'techniqueName', + reference: 'techniqueRef', + }, + ], + }, + ], + }).error.message + ).toEqual( + 'child "threat" fails because ["threat" at position 0 fails because [child "tactic" fails because ["tactic" is required]]]' + ); + }); + + test('threat is invalid when updated with missing technique', () => { + expect( + patchRulesSchema.validate< + Partial> & { + threat: Array>>; + } + >({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + threat: [ + { + framework: 'fake', + tactic: { + id: 'techniqueId', + name: 'techniqueName', + reference: 'techniqueRef', + }, + }, + ], + }).error.message + ).toEqual( + 'child "threat" fails because ["threat" at position 0 fails because [child "technique" fails because ["technique" is required]]]' + ); + }); + + test('validates with timeline_id and timeline_title', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + timeline_id: 'some-id', + timeline_title: 'some-title', + }).error + ).toBeFalsy(); + }); + + test('You cannot omit timeline_title when timeline_id is present', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + timeline_id: 'some-id', + }).error.message + ).toEqual('child "timeline_title" fails because ["timeline_title" is required]'); + }); + + test('You cannot have a null value for timeline_title when timeline_id is present', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + timeline_id: 'timeline-id', + timeline_title: null, + }).error.message + ).toEqual('child "timeline_title" fails because ["timeline_title" must be a string]'); + }); + + test('You cannot have empty string for timeline_title when timeline_id is present', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + timeline_id: 'some-id', + timeline_title: '', + }).error.message + ).toEqual('child "timeline_title" fails because ["timeline_title" is not allowed to be empty]'); + }); + + test('You cannot have timeline_title with an empty timeline_id', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + timeline_id: '', + timeline_title: 'some-title', + }).error.message + ).toEqual('child "timeline_id" fails because ["timeline_id" is not allowed to be empty]'); + }); + + test('You cannot have timeline_title without timeline_id', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + timeline_title: 'some-title', + }).error.message + ).toEqual('child "timeline_title" fails because ["timeline_title" is not allowed]'); + }); + + test('You cannot set the severity to a value other than low, medium, high, or critical', () => { + expect( + patchRulesSchema.validate>({ + id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'junk', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "severity" fails because ["severity" must be one of [low, medium, high, critical]]' + ); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.ts new file mode 100644 index 00000000000000..d0ed1af01833b3 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Joi from 'joi'; + +/* eslint-disable @typescript-eslint/camelcase */ +import { + enabled, + description, + false_positives, + filters, + from, + index, + rule_id, + interval, + query, + language, + output_index, + saved_id, + timeline_id, + timeline_title, + meta, + risk_score, + max_signals, + name, + severity, + tags, + to, + type, + threat, + references, + id, + version, +} from './schemas'; +/* eslint-enable @typescript-eslint/camelcase */ + +export const patchRulesSchema = Joi.object({ + description, + enabled, + false_positives, + filters, + from, + rule_id, + id, + index, + interval, + query: query.allow(''), + language, + output_index, + saved_id, + timeline_id, + timeline_title, + meta, + risk_score, + max_signals, + name, + severity, + tags, + to, + type, + threat, + references, + version, +}).xor('id', 'rule_id'); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/query_rules_bulk_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/query_rules_bulk_schema.test.ts index ab1ffaab491651..7ea7fcbd1d86b3 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/query_rules_bulk_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/query_rules_bulk_schema.test.ts @@ -5,7 +5,7 @@ */ import { queryRulesBulkSchema } from './query_rules_bulk_schema'; -import { UpdateRuleAlertParamsRest } from '../../rules/types'; +import { PatchRuleAlertParamsRest } from '../../rules/types'; // only the basics of testing are here. // see: query_rules_bulk_schema.test.ts for the bulk of the validation tests @@ -13,13 +13,13 @@ import { UpdateRuleAlertParamsRest } from '../../rules/types'; describe('query_rules_bulk_schema', () => { test('can take an empty array and validate it', () => { expect( - queryRulesBulkSchema.validate>>([]).error + queryRulesBulkSchema.validate>>([]).error ).toBeFalsy(); }); test('both rule_id and id being supplied do not validate', () => { expect( - queryRulesBulkSchema.validate>>([ + queryRulesBulkSchema.validate>>([ { rule_id: '1', id: '1', @@ -32,7 +32,7 @@ describe('query_rules_bulk_schema', () => { test('both rule_id and id being supplied do not validate if one array element works but the second does not', () => { expect( - queryRulesBulkSchema.validate>>([ + queryRulesBulkSchema.validate>>([ { id: '1', }, @@ -48,13 +48,13 @@ describe('query_rules_bulk_schema', () => { test('only id validates', () => { expect( - queryRulesBulkSchema.validate>>([{ id: '1' }]).error + queryRulesBulkSchema.validate>>([{ id: '1' }]).error ).toBeFalsy(); }); test('only id validates with two elements', () => { expect( - queryRulesBulkSchema.validate>>([ + queryRulesBulkSchema.validate>>([ { id: '1' }, { id: '2' }, ]).error @@ -63,14 +63,14 @@ describe('query_rules_bulk_schema', () => { test('only rule_id validates', () => { expect( - queryRulesBulkSchema.validate>>([{ rule_id: '1' }]) + queryRulesBulkSchema.validate>>([{ rule_id: '1' }]) .error ).toBeFalsy(); }); test('only rule_id validates with two elements', () => { expect( - queryRulesBulkSchema.validate>>([ + queryRulesBulkSchema.validate>>([ { rule_id: '1' }, { rule_id: '2' }, ]).error @@ -79,7 +79,7 @@ describe('query_rules_bulk_schema', () => { test('both id and rule_id validates with two separate elements', () => { expect( - queryRulesBulkSchema.validate>>([ + queryRulesBulkSchema.validate>>([ { id: '1' }, { rule_id: '2' }, ]).error diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/query_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/query_rules_schema.test.ts index c89d60e773a779..0f392e399f36c5 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/query_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/query_rules_schema.test.ts @@ -5,29 +5,29 @@ */ import { queryRulesSchema } from './query_rules_schema'; -import { UpdateRuleAlertParamsRest } from '../../rules/types'; +import { PatchRuleAlertParamsRest } from '../../rules/types'; describe('queryRulesSchema', () => { test('empty objects do not validate', () => { - expect(queryRulesSchema.validate>({}).error).toBeTruthy(); + expect(queryRulesSchema.validate>({}).error).toBeTruthy(); }); test('both rule_id and id being supplied do not validate', () => { expect( - queryRulesSchema.validate>({ rule_id: '1', id: '1' }).error + queryRulesSchema.validate>({ rule_id: '1', id: '1' }).error .message ).toEqual('"value" contains a conflict between exclusive peers [id, rule_id]'); }); test('only id validates', () => { expect( - queryRulesSchema.validate>({ id: '1' }).error + queryRulesSchema.validate>({ id: '1' }).error ).toBeFalsy(); }); test('only rule_id validates', () => { expect( - queryRulesSchema.validate>({ rule_id: '1' }).error + queryRulesSchema.validate>({ rule_id: '1' }).error ).toBeFalsy(); }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_bulk_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_bulk_schema.test.ts index 2b1bad39eb6861..e866260662ad7b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_bulk_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_bulk_schema.test.ts @@ -31,7 +31,17 @@ describe('update_rules_bulk_schema', () => { expect( updateRulesBulkSchema.validate>>([ { - id: 'rule-1', + id: 'id-1', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + name: 'some-name', + severity: 'low', + type: 'query', + query: 'some query', + index: ['index-1'], + interval: '5m', }, ]).error ).toBeFalsy(); @@ -41,10 +51,30 @@ describe('update_rules_bulk_schema', () => { expect( updateRulesBulkSchema.validate>>([ { - id: 'rule-1', + id: 'id-1', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + name: 'some-name', + severity: 'low', + type: 'query', + query: 'some query', + index: ['index-1'], + interval: '5m', }, { - id: 'rule-2', + id: 'id-2', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + name: 'some-name', + severity: 'low', + type: 'query', + query: 'some query', + index: ['index-1'], + interval: '5m', }, ]).error ).toBeFalsy(); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts index 0dc9f3df3da1ce..c7899f3afa7b8f 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts @@ -5,169 +5,107 @@ */ import { updateRulesSchema } from './update_rules_schema'; -import { UpdateRuleAlertParamsRest } from '../../rules/types'; -import { ThreatParams } from '../../types'; +import { PatchRuleAlertParamsRest } from '../../rules/types'; +import { ThreatParams, RuleAlertParamsRest } from '../../types'; -describe('update rules schema', () => { +describe('create rules schema', () => { test('empty objects do not validate as they require at least id or rule_id', () => { - expect(updateRulesSchema.validate>({}).error).toBeTruthy(); + expect(updateRulesSchema.validate>({}).error).toBeTruthy(); }); test('made up values do not validate', () => { expect( - updateRulesSchema.validate>({ + updateRulesSchema.validate>({ madeUp: 'hi', }).error ).toBeTruthy(); }); - test('[id] does validate', () => { + test('[rule_id] does not validate', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', - }).error - ).toBeFalsy(); - }); - - test('[rule_id] does validate', () => { - expect( - updateRulesSchema.validate>({ - rule_id: 'rule-1', - }).error - ).toBeFalsy(); - }); - - test('[id and rule_id] does not validate', () => { - expect( - updateRulesSchema.validate>({ - id: 'id-1', + updateRulesSchema.validate>({ rule_id: 'rule-1', }).error ).toBeTruthy(); }); - test('[rule_id, description] does validate', () => { - expect( - updateRulesSchema.validate>({ - rule_id: 'rule-1', - description: 'some description', - }).error - ).toBeFalsy(); - }); - - test('[id, description] does validate', () => { - expect( - updateRulesSchema.validate>({ - id: 'rule-1', - description: 'some description', - }).error - ).toBeFalsy(); - }); - - test('[id, risk_score] does validate', () => { - expect( - updateRulesSchema.validate>({ - id: 'rule-1', - risk_score: 10, - }).error - ).toBeFalsy(); - }); - - test('[rule_id, description, from] does validate', () => { - expect( - updateRulesSchema.validate>({ - rule_id: 'rule-1', - description: 'some description', - from: 'now-5m', - }).error - ).toBeFalsy(); - }); - - test('[id, description, from] does validate', () => { - expect( - updateRulesSchema.validate>({ - id: 'rule-1', - description: 'some description', - from: 'now-5m', - }).error - ).toBeFalsy(); - }); - - test('[rule_id, description, from, to] does validate', () => { + test('[id] and [rule_id] does not validate', () => { expect( - updateRulesSchema.validate>({ + updateRulesSchema.validate>({ + id: 'id-1', rule_id: 'rule-1', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', - }).error - ).toBeFalsy(); + name: 'some-name', + severity: 'low', + type: 'query', + query: 'some query', + index: ['index-1'], + interval: '5m', + }).error.message + ).toEqual('"value" contains a conflict between exclusive peers [id, rule_id]'); }); - test('[id, description, from, to] does validate', () => { + test('[rule_id, description] does not validate', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate>({ + rule_id: 'rule-1', description: 'some description', - from: 'now-5m', - to: 'now', }).error - ).toBeFalsy(); + ).toBeTruthy(); }); - test('[rule_id, description, from, to, name] does validate', () => { + test('[rule_id, description, from] does not validate', () => { expect( - updateRulesSchema.validate>({ + updateRulesSchema.validate>({ rule_id: 'rule-1', description: 'some description', from: 'now-5m', - to: 'now', - name: 'some-name', }).error - ).toBeFalsy(); + ).toBeTruthy(); }); - test('[id, description, from, to, name] does validate', () => { + test('[rule_id, description, from, to] does not validate', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate>({ + rule_id: 'rule-1', description: 'some description', from: 'now-5m', to: 'now', - name: 'some-name', }).error - ).toBeFalsy(); + ).toBeTruthy(); }); - test('[rule_id, description, from, to, name, severity] does validate', () => { + test('[rule_id, description, from, to, name] does not validate', () => { expect( - updateRulesSchema.validate>({ + updateRulesSchema.validate>({ rule_id: 'rule-1', description: 'some description', from: 'now-5m', to: 'now', name: 'some-name', - severity: 'low', }).error - ).toBeFalsy(); + ).toBeTruthy(); }); - test('[id, description, from, to, name, severity] does validate', () => { + test('[rule_id, description, from, to, name, severity] does not validate', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate>({ + rule_id: 'rule-1', description: 'some description', from: 'now-5m', to: 'now', name: 'some-name', severity: 'low', }).error - ).toBeFalsy(); + ).toBeTruthy(); }); - test('[rule_id, description, from, to, name, severity, type] does validate', () => { + test('[rule_id, description, from, to, name, severity, type] does not validate', () => { expect( - updateRulesSchema.validate>({ + updateRulesSchema.validate>({ rule_id: 'rule-1', description: 'some description', from: 'now-5m', @@ -176,56 +114,61 @@ describe('update rules schema', () => { severity: 'low', type: 'query', }).error - ).toBeFalsy(); + ).toBeTruthy(); }); - test('[id, description, from, to, name, severity, type] does validate', () => { + test('[rule_id, description, from, to, name, severity, type, interval] does not validate', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate>({ + rule_id: 'rule-1', description: 'some description', from: 'now-5m', to: 'now', name: 'some-name', severity: 'low', + interval: '5m', type: 'query', }).error - ).toBeFalsy(); + ).toBeTruthy(); }); - test('[rule_id, description, from, to, name, severity, type, interval] does validate', () => { + test('[rule_id, description, from, to, name, severity, type, interval, index] does not validate', () => { expect( - updateRulesSchema.validate>({ + updateRulesSchema.validate>({ rule_id: 'rule-1', description: 'some description', from: 'now-5m', to: 'now', name: 'some-name', severity: 'low', - interval: '5m', type: 'query', + interval: '5m', + index: ['index-1'], }).error - ).toBeFalsy(); + ).toBeTruthy(); }); - test('[id, description, from, to, name, severity, type, interval] does validate', () => { + test('[rule_id, description, from, to, name, severity, type, query, index, interval] does validate', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', name: 'some-name', severity: 'low', - interval: '5m', type: 'query', + query: 'some query', + index: ['index-1'], + interval: '5m', }).error ).toBeFalsy(); }); - test('[rule_id, description, from, to, index, name, severity, interval, type] does validate', () => { + test('[rule_id, description, from, to, index, name, severity, interval, type, query, language] does not validate', () => { expect( - updateRulesSchema.validate>({ + updateRulesSchema.validate>({ rule_id: 'rule-1', description: 'some description', from: 'now-5m', @@ -235,14 +178,17 @@ describe('update rules schema', () => { severity: 'low', interval: '5m', type: 'query', + query: 'some query', + language: 'kuery', }).error - ).toBeFalsy(); + ).toBeTruthy(); }); - test('[id, description, from, to, index, name, severity, interval, type] does validate', () => { + test('[rule_id, description, from, to, index, name, severity, interval, type, query, language, risk_score] does validate', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', @@ -251,14 +197,18 @@ describe('update rules schema', () => { severity: 'low', interval: '5m', type: 'query', + query: 'some query', + language: 'kuery', }).error ).toBeFalsy(); }); - test('[rule_id, description, from, to, index, name, severity, interval, type, query] does validate', () => { + test('[rule_id, description, from, to, index, name, severity, interval, type, query, language, risk_score, output_index] does validate', () => { expect( - updateRulesSchema.validate>({ + updateRulesSchema.validate>({ rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', @@ -268,14 +218,15 @@ describe('update rules schema', () => { interval: '5m', type: 'query', query: 'some query', + language: 'kuery', }).error ).toBeFalsy(); }); - test('[id, description, from, to, index, name, severity, interval, type, query] does validate', () => { + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score] does validate', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate>({ + rule_id: 'rule-1', description: 'some description', from: 'now-5m', to: 'now', @@ -284,15 +235,17 @@ describe('update rules schema', () => { severity: 'low', interval: '5m', type: 'query', - query: 'some query', + risk_score: 50, }).error ).toBeFalsy(); }); - test('[rule_id, description, from, to, index, name, severity, interval, type, query, language] does validate', () => { + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, output_index] does validate', () => { expect( - updateRulesSchema.validate>({ + updateRulesSchema.validate>({ rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', @@ -301,16 +254,16 @@ describe('update rules schema', () => { severity: 'low', interval: '5m', type: 'query', - query: 'some query', - language: 'kuery', }).error ).toBeFalsy(); }); - test('[id, description, from, to, index, name, severity, interval, type, query, language] does validate', () => { + test('You can send in an empty array to threat', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', @@ -319,16 +272,21 @@ describe('update rules schema', () => { severity: 'low', interval: '5m', type: 'query', + references: ['index-1'], query: 'some query', language: 'kuery', + max_signals: 1, + threat: [], }).error ).toBeFalsy(); }); - test('[rule_id, description, from, to, index, name, severity, type, filter] does validate', () => { + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, output_index, threat] does validate', () => { expect( - updateRulesSchema.validate>({ + updateRulesSchema.validate>({ rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', @@ -337,14 +295,33 @@ describe('update rules schema', () => { severity: 'low', interval: '5m', type: 'query', + threat: [ + { + framework: 'someFramework', + tactic: { + id: 'fakeId', + name: 'fakeName', + reference: 'fakeRef', + }, + technique: [ + { + id: 'techniqueId', + name: 'techniqueName', + reference: 'techniqueRef', + }, + ], + }, + ], }).error ).toBeFalsy(); }); - test('[id, description, from, to, index, name, severity, type, filter] does validate', () => { + test('allows references to be sent as valid', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', @@ -353,14 +330,19 @@ describe('update rules schema', () => { severity: 'low', interval: '5m', type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', }).error ).toBeFalsy(); }); - test('allows references to be sent as a valid value to update with', () => { + test('defaults references to an array', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', @@ -369,17 +351,20 @@ describe('update rules schema', () => { severity: 'low', interval: '5m', type: 'query', - references: ['index-1'], - query: 'some query', + query: 'some-query', language: 'kuery', - }).error - ).toBeFalsy(); + }).value.references + ).toEqual([]); }); - test('does not default references to an array', () => { + test('references cannot be numbers', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate< + Partial> & { references: number[] } + >({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', @@ -390,47 +375,60 @@ describe('update rules schema', () => { type: 'query', query: 'some-query', language: 'kuery', - }).value.references - ).toEqual(undefined); + references: [5], + }).error.message + ).toEqual( + 'child "references" fails because ["references" at position 0 fails because ["0" must be a string]]' + ); }); - test('does not default interval', () => { + test('indexes cannot be numbers', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', - description: 'some description', - from: 'now-5m', - to: 'now', - index: ['index-1'], - name: 'some-name', - severity: 'low', - type: 'query', - }).value.interval - ).toEqual(undefined); + updateRulesSchema.validate> & { index: number[] }>( + { + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: [5], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + query: 'some-query', + language: 'kuery', + } + ).error.message + ).toEqual( + 'child "index" fails because ["index" at position 0 fails because ["0" must be a string]]' + ); }); - test('does not default max signal', () => { + test('defaults interval to 5 min', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', index: ['index-1'], name: 'some-name', severity: 'low', - interval: '5m', type: 'query', - }).value.max_signals - ).toEqual(undefined); + }).value.interval + ).toEqual('5m'); }); - test('references cannot be numbers', () => { + test('defaults max signals to 100', () => { expect( - updateRulesSchema.validate< - Partial> & { references: number[] } - >({ - id: 'rule-1', + updateRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', @@ -439,41 +437,34 @@ describe('update rules schema', () => { severity: 'low', interval: '5m', type: 'query', - query: 'some-query', - language: 'kuery', - references: [5], - }).error.message - ).toEqual( - 'child "references" fails because ["references" at position 0 fails because ["0" must be a string]]' - ); + }).value.max_signals + ).toEqual(100); }); - test('indexes cannot be numbers', () => { + test('saved_id is required when type is saved_query and will not validate without out', () => { expect( - updateRulesSchema.validate< - Partial> & { index: number[] } - >({ - id: 'rule-1', + updateRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', - index: [5], + index: ['index-1'], name: 'some-name', severity: 'low', interval: '5m', - type: 'query', - query: 'some-query', - language: 'kuery', + type: 'saved_query', }).error.message - ).toEqual( - 'child "index" fails because ["index" at position 0 fails because ["0" must be a string]]' - ); + ).toEqual('child "saved_id" fails because ["saved_id" is required]'); }); - test('saved_id is not required when type is saved_query and will validate without it', () => { + test('saved_id is required when type is saved_query and validates with it', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + output_index: '.siem-signals', description: 'some description', from: 'now-5m', to: 'now', @@ -482,14 +473,17 @@ describe('update rules schema', () => { severity: 'low', interval: '5m', type: 'saved_query', + saved_id: 'some id', }).error ).toBeFalsy(); }); - test('saved_id validates with saved_query', () => { + test('saved_query type can have filters with it', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', @@ -499,14 +493,19 @@ describe('update rules schema', () => { interval: '5m', type: 'saved_query', saved_id: 'some id', + filters: [], }).error ).toBeFalsy(); }); - test('saved_query type can have filters with it', () => { + test('filters cannot be a string', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate< + Partial & { filters: string }> + >({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', @@ -516,15 +515,17 @@ describe('update rules schema', () => { interval: '5m', type: 'saved_query', saved_id: 'some id', - filters: [], - }).error - ).toBeFalsy(); + filters: 'some string', + }).error.message + ).toEqual('child "filters" fails because ["filters" must be an array]'); }); test('language validates with kuery', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', @@ -542,8 +543,10 @@ describe('update rules schema', () => { test('language validates with lucene', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + output_index: '.siem-signals', description: 'some description', from: 'now-5m', to: 'now', @@ -561,8 +564,10 @@ describe('update rules schema', () => { test('language does not validate with something made up', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', @@ -580,8 +585,10 @@ describe('update rules schema', () => { test('max_signals cannot be negative', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', @@ -600,8 +607,10 @@ describe('update rules schema', () => { test('max_signals cannot be zero', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', @@ -620,8 +629,10 @@ describe('update rules schema', () => { test('max_signals can be 1', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', @@ -638,42 +649,12 @@ describe('update rules schema', () => { ).toBeFalsy(); }); - test('meta can be updated', () => { - expect( - updateRulesSchema.validate>({ - id: 'rule-1', - meta: { whateverYouWant: 'anything_at_all' }, - }).error - ).toBeFalsy(); - }); - - test('You cannot update meta as a string', () => { - expect( - updateRulesSchema.validate< - Partial & { meta: string }> - >({ - id: 'rule-1', - meta: 'should not work', - }).error.message - ).toEqual('child "meta" fails because ["meta" must be an object]'); - }); - - test('filters cannot be a string', () => { + test('You can optionally send in an array of tags', () => { expect( - updateRulesSchema.validate< - Partial & { filters: string }> - >({ + updateRulesSchema.validate>({ rule_id: 'rule-1', - type: 'query', - filters: 'some string', - }).error.message - ).toEqual('child "filters" fails because ["filters" must be an array]'); - }); - - test('threat is not defaulted to empty array on update', () => { - expect( - updateRulesSchema.validate>({ - id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', @@ -686,15 +667,18 @@ describe('update rules schema', () => { query: 'some query', language: 'kuery', max_signals: 1, - }).value.threat - ).toBe(undefined); + tags: ['tag_1', 'tag_2'], + }).error + ).toBeFalsy(); }); - test('threat is not defaulted to undefined on update with empty array', () => { + test('You cannot send in an array of tags that are numbers', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', - description: 'some description', + updateRulesSchema.validate> & { tags: number[] }>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', from: 'now-5m', to: 'now', index: ['index-1'], @@ -706,32 +690,23 @@ describe('update rules schema', () => { query: 'some query', language: 'kuery', max_signals: 1, - threat: [], - }).value.threat - ).toMatchObject([]); - }); - - test('threat is valid when updated with all sub-objects', () => { - const expected: ThreatParams[] = [ - { - framework: 'fake', - tactic: { - id: 'fakeId', - name: 'fakeName', - reference: 'fakeRef', - }, - technique: [ - { - id: 'techniqueId', - name: 'techniqueName', - reference: 'techniqueRef', - }, - ], - }, - ]; + tags: [0, 1, 2], + }).error.message + ).toEqual( + 'child "tags" fails because ["tags" at position 0 fails because ["0" must be a string]]' + ); + }); + + test('You cannot send in an array of threat that are missing "framework"', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate< + Partial> & { + threat: Array>>; + } + >({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', @@ -746,7 +721,6 @@ describe('update rules schema', () => { max_signals: 1, threat: [ { - framework: 'fake', tactic: { id: 'fakeId', name: 'fakeName', @@ -761,18 +735,22 @@ describe('update rules schema', () => { ], }, ], - }).value.threat - ).toMatchObject(expected); + }).error.message + ).toEqual( + 'child "threat" fails because ["threat" at position 0 fails because [child "framework" fails because ["framework" is required]]]' + ); }); - test('threat is invalid when updated with missing property framework', () => { + test('You cannot send in an array of threat that are missing "tactic"', () => { expect( updateRulesSchema.validate< - Partial> & { - threat: Array>>; + Partial> & { + threat: Array>>; } >({ - id: 'rule-1', + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', @@ -787,11 +765,7 @@ describe('update rules schema', () => { max_signals: 1, threat: [ { - tactic: { - id: 'fakeId', - name: 'fakeName', - reference: 'fakeRef', - }, + framework: 'fake', technique: [ { id: 'techniqueId', @@ -803,18 +777,20 @@ describe('update rules schema', () => { ], }).error.message ).toEqual( - 'child "threat" fails because ["threat" at position 0 fails because [child "framework" fails because ["framework" is required]]]' + 'child "threat" fails because ["threat" at position 0 fails because [child "tactic" fails because ["tactic" is required]]]' ); }); - test('threat is invalid when updated with missing tactic sub-object', () => { + test('You cannot send in an array of threat that are missing "technique"', () => { expect( updateRulesSchema.validate< - Partial> & { - threat: Array>>; + Partial> & { + threat: Array>>; } >({ - id: 'rule-1', + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', @@ -830,30 +806,52 @@ describe('update rules schema', () => { threat: [ { framework: 'fake', - technique: [ - { - id: 'techniqueId', - name: 'techniqueName', - reference: 'techniqueRef', - }, - ], + tactic: { + id: 'fakeId', + name: 'fakeName', + reference: 'fakeRef', + }, }, ], }).error.message ).toEqual( - 'child "threat" fails because ["threat" at position 0 fails because [child "tactic" fails because ["tactic" is required]]]' + 'child "threat" fails because ["threat" at position 0 fails because [child "technique" fails because ["technique" is required]]]' ); }); - test('threat is invalid when updated with missing technique', () => { + test('You can optionally send in an array of false positives', () => { + expect( + updateRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + false_positives: ['false_1', 'false_2'], + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + }).error + ).toBeFalsy(); + }); + + test('You cannot send in an array of false positives that are numbers', () => { expect( updateRulesSchema.validate< - Partial> & { - threat: Array>>; - } + Partial> & { false_positives: number[] } >({ - id: 'rule-1', + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, description: 'some description', + false_positives: [5, 4], from: 'now-5m', to: 'now', index: ['index-1'], @@ -865,26 +863,201 @@ describe('update rules schema', () => { query: 'some query', language: 'kuery', max_signals: 1, - threat: [ - { - framework: 'fake', - tactic: { - id: 'techniqueId', - name: 'techniqueName', - reference: 'techniqueRef', - }, - }, - ], }).error.message ).toEqual( - 'child "threat" fails because ["threat" at position 0 fails because [child "technique" fails because ["technique" is required]]]' + 'child "false_positives" fails because ["false_positives" at position 0 fails because ["0" must be a string]]' ); }); + test('You cannot set the immutable when trying to create a rule', () => { + expect( + updateRulesSchema.validate< + Partial> & { immutable: number } + >({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: 5, + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + }).error.message + ).toEqual('"immutable" is not allowed'); + }); + + test('You cannot set the risk_score to 101', () => { + expect( + updateRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 101, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + }).error.message + ).toEqual('child "risk_score" fails because ["risk_score" must be less than 101]'); + }); + + test('You cannot set the risk_score to -1', () => { + expect( + updateRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: -1, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + }).error.message + ).toEqual('child "risk_score" fails because ["risk_score" must be greater than -1]'); + }); + + test('You can set the risk_score to 0', () => { + expect( + updateRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 0, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + }).error + ).toBeFalsy(); + }); + + test('You can set the risk_score to 100', () => { + expect( + updateRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 100, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + }).error + ).toBeFalsy(); + }); + + test('You can set meta to any object you want', () => { + expect( + updateRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + meta: { + somethingMadeUp: { somethingElse: true }, + }, + }).error + ).toBeFalsy(); + }); + + test('You cannot create meta as a string', () => { + expect( + updateRulesSchema.validate & { meta: string }>>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + meta: 'should not work', + }).error.message + ).toEqual('child "meta" fails because ["meta" must be an object]'); + }); + + test('You can omit the query string when filters are present', () => { + expect( + updateRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + references: ['index-1'], + language: 'kuery', + filters: [], + max_signals: 1, + }).error + ).toBeFalsy(); + }); + test('validates with timeline_id and timeline_title', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', @@ -892,18 +1065,22 @@ describe('update rules schema', () => { name: 'some-name', severity: 'low', interval: '5m', - type: 'saved_query', - saved_id: 'some id', - timeline_id: 'some-id', - timeline_title: 'some-title', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_id: 'timeline-id', + timeline_title: 'timeline-title', }).error ).toBeFalsy(); }); test('You cannot omit timeline_title when timeline_id is present', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', @@ -911,17 +1088,21 @@ describe('update rules schema', () => { name: 'some-name', severity: 'low', interval: '5m', - type: 'saved_query', - saved_id: 'some id', - timeline_id: 'some-id', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_id: 'some_id', }).error.message ).toEqual('child "timeline_title" fails because ["timeline_title" is required]'); }); test('You cannot have a null value for timeline_title when timeline_id is present', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', @@ -929,9 +1110,11 @@ describe('update rules schema', () => { name: 'some-name', severity: 'low', interval: '5m', - type: 'saved_query', - saved_id: 'some id', - timeline_id: 'timeline-id', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_id: 'some_id', timeline_title: null, }).error.message ).toEqual('child "timeline_title" fails because ["timeline_title" must be a string]'); @@ -939,8 +1122,10 @@ describe('update rules schema', () => { test('You cannot have empty string for timeline_title when timeline_id is present', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', @@ -948,9 +1133,11 @@ describe('update rules schema', () => { name: 'some-name', severity: 'low', interval: '5m', - type: 'saved_query', - saved_id: 'some id', - timeline_id: 'some-id', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_id: 'some_id', timeline_title: '', }).error.message ).toEqual('child "timeline_title" fails because ["timeline_title" is not allowed to be empty]'); @@ -958,8 +1145,10 @@ describe('update rules schema', () => { test('You cannot have timeline_title with an empty timeline_id', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', @@ -967,8 +1156,10 @@ describe('update rules schema', () => { name: 'some-name', severity: 'low', interval: '5m', - type: 'saved_query', - saved_id: 'some id', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', timeline_id: '', timeline_title: 'some-title', }).error.message @@ -977,8 +1168,10 @@ describe('update rules schema', () => { test('You cannot have timeline_title without timeline_id', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, description: 'some description', from: 'now-5m', to: 'now', @@ -986,17 +1179,55 @@ describe('update rules schema', () => { name: 'some-name', severity: 'low', interval: '5m', - type: 'saved_query', - saved_id: 'some id', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', timeline_title: 'some-title', }).error.message ).toEqual('child "timeline_title" fails because ["timeline_title" is not allowed]'); }); + test('The default for "from" will be "now-6m"', () => { + expect( + updateRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).value.from + ).toEqual('now-6m'); + }); + + test('The default for "to" will be "now"', () => { + expect( + updateRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).value.to + ).toEqual('now'); + }); + test('You cannot set the severity to a value other than low, medium, high, or critical', () => { expect( - updateRulesSchema.validate>({ - id: 'rule-1', + updateRulesSchema.validate>({ + rule_id: 'rule-1', risk_score: 50, description: 'some description', name: 'some-name', diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.ts index 3aa8e007a8cbdb..3e5a608d6b657d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.ts @@ -37,31 +37,44 @@ import { } from './schemas'; /* eslint-enable @typescript-eslint/camelcase */ +import { DEFAULT_MAX_SIGNALS } from '../../../../../common/constants'; + +/** + * This almost identical to the create_rules_schema except for a few details. + * - The version will not be defaulted to a 1. If it is not given then its default will become the previous version auto-incremented + * This does break idempotency slightly as calls repeatedly without it will increment the number. If the version number is passed in + * this will update the rule's version number. + * - id is on here because you can pass in an id to update using it instead of rule_id. + */ export const updateRulesSchema = Joi.object({ - description, - enabled, - false_positives, + description: description.required(), + enabled: enabled.default(true), + id, + false_positives: false_positives.default([]), filters, - from, + from: from.default('now-6m'), rule_id, - id, index, - interval, - query: query.allow(''), - language, + interval: interval.default('5m'), + query: query.allow('').default(''), + language: language.default('kuery'), output_index, - saved_id, + saved_id: saved_id.when('type', { + is: 'saved_query', + then: Joi.required(), + otherwise: Joi.forbidden(), + }), timeline_id, timeline_title, meta, - risk_score, - max_signals, - name, - severity, - tags, - to, - type, - threat, - references, + risk_score: risk_score.required(), + max_signals: max_signals.default(DEFAULT_MAX_SIGNALS), + name: name.required(), + severity: severity.required(), + tags: tags.default([]), + to: to.default('now'), + type: type.required(), + threat: threat.default([]), + references: references.default([]), version, }).xor('id', 'rule_id'); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/tags/read_tags_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/tags/read_tags_route.ts index c598e22ff596c5..f6d297b0cbf439 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/tags/read_tags_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/tags/read_tags_route.ts @@ -34,7 +34,13 @@ export const createReadTagsRoute: Hapi.ServerRoute = { }); return tags; } catch (err) { - return transformError(err); + const error = transformError(err); + return headers + .response({ + message: error.message, + status_code: error.statusCode, + }) + .code(error.statusCode); } }, }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts index ffd0c791c5bb60..3e3ccfe5babef6 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts @@ -18,51 +18,69 @@ import { describe('utils', () => { describe('transformError', () => { - test('returns boom if it is a boom object', () => { - const boom = new Boom(''); + test('returns transformed output error from boom object with a 500 and payload of internal server error', () => { + const boom = new Boom('some boom message'); const transformed = transformError(boom); - expect(transformed).toBe(boom); + expect(transformed).toEqual({ + message: 'An internal server error occurred', + statusCode: 500, + }); }); - test('returns a boom if it is some non boom object that has a statusCode', () => { + test('returns transformed output if it is some non boom object that has a statusCode', () => { const error: Error & { statusCode?: number } = { statusCode: 403, name: 'some name', message: 'some message', }; const transformed = transformError(error); - expect(Boom.isBoom(transformed)).toBe(true); + expect(transformed).toEqual({ + message: 'some message', + statusCode: 403, + }); }); - test('returns a boom with the message set', () => { + test('returns a transformed message with the message set and statusCode', () => { const error: Error & { statusCode?: number } = { statusCode: 403, name: 'some name', message: 'some message', }; const transformed = transformError(error); - expect(transformed.message).toBe('some message'); + expect(transformed).toEqual({ + message: 'some message', + statusCode: 403, + }); }); - test('does not return a boom if it is some non boom object but it does not have a status Code.', () => { + test('transforms best it can if it is some non boom object but it does not have a status Code.', () => { const error: Error = { name: 'some name', message: 'some message', }; const transformed = transformError(error); - expect(Boom.isBoom(transformed)).toBe(false); + expect(transformed).toEqual({ + message: 'some message', + statusCode: 500, + }); }); - test('it detects a TypeError and returns a Boom', () => { + test('it detects a TypeError and returns a status code of 400 from that particular error type', () => { const error: TypeError = new TypeError('I have a type error'); const transformed = transformError(error); - expect(Boom.isBoom(transformed)).toBe(true); + expect(transformed).toEqual({ + message: 'I have a type error', + statusCode: 400, + }); }); test('it detects a TypeError and returns a Boom status of 400', () => { const error: TypeError = new TypeError('I have a type error'); - const transformed = transformError(error) as Boom; - expect(transformed.output.statusCode).toBe(400); + const transformed = transformError(error); + expect(transformed).toEqual({ + message: 'I have a type error', + statusCode: 400, + }); }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.ts index 416c76b5d4eb5d..af78f60f16ae43 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.ts @@ -8,20 +8,37 @@ import Boom from 'boom'; import { APP_ID, SIGNALS_INDEX_KEY } from '../../../../common/constants'; import { ServerFacade, RequestFacade } from '../../../types'; -export const transformError = (err: Error & { statusCode?: number }) => { +export interface OutputError { + message: string; + statusCode: number; +} + +export const transformError = (err: Error & { statusCode?: number }): OutputError => { if (Boom.isBoom(err)) { - return err; + return { + message: err.output.payload.message, + statusCode: err.output.statusCode, + }; } else { if (err.statusCode != null) { - return new Boom(err.message, { statusCode: err.statusCode }); + return { + message: err.message, + statusCode: err.statusCode, + }; } else if (err instanceof TypeError) { // allows us to throw type errors instead of booms in some conditions // where we don't want to mingle Boom with the rest of the code - return new Boom(err.message, { statusCode: 400 }); + return { + message: err.message, + statusCode: 400, + }; } else { // natively return the err and allow the regular framework // to deal with the error when it is a non Boom - return err; + return { + message: err.message ?? '(unknown error message)', + statusCode: 500, + }; } } }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts index 82fe16882882e5..61f2e878115093 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Alert } from '../../../../../alerting/common'; import { APP_ID, SIGNALS_ID } from '../../../../common/constants'; import { CreateRuleParams } from './types'; import { addTags } from './add_tags'; @@ -37,7 +38,7 @@ export const createRules = ({ type, references, version, -}: CreateRuleParams) => { +}: CreateRuleParams): Promise => { return alertsClient.create({ data: { name, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts new file mode 100644 index 00000000000000..f560b67cdc587e --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { defaults } from 'lodash/fp'; +import { PartialAlert } from '../../../../../alerting/server/types'; +import { readRules } from './read_rules'; +import { PatchRuleParams, IRuleSavedAttributesSavedObjectAttributes } from './types'; +import { addTags } from './add_tags'; +import { ruleStatusSavedObjectType } from './saved_object_mappings'; +import { calculateVersion, calculateName, calculateInterval } from './utils'; + +export const patchRules = async ({ + alertsClient, + actionsClient, // TODO: Use this whenever we add feature support for different action types + savedObjectsClient, + description, + falsePositives, + enabled, + query, + language, + outputIndex, + savedId, + timelineId, + timelineTitle, + meta, + filters, + from, + immutable, + id, + ruleId, + index, + interval, + maxSignals, + riskScore, + name, + severity, + tags, + threat, + to, + type, + references, + version, +}: PatchRuleParams): Promise => { + const rule = await readRules({ alertsClient, ruleId, id }); + if (rule == null) { + return null; + } + + const calculatedVersion = calculateVersion(rule.params.immutable, rule.params.version, { + description, + falsePositives, + query, + language, + outputIndex, + savedId, + timelineId, + timelineTitle, + meta, + filters, + from, + index, + interval, + maxSignals, + riskScore, + name, + severity, + tags, + threat, + to, + type, + references, + version, + }); + + const nextParams = defaults( + { + ...rule.params, + }, + { + description, + falsePositives, + from, + immutable, + query, + language, + outputIndex, + savedId, + timelineId, + timelineTitle, + meta, + filters, + index, + maxSignals, + riskScore, + severity, + threat, + to, + type, + references, + version: calculatedVersion, + } + ); + + const update = await alertsClient.update({ + id: rule.id, + data: { + tags: addTags(tags ?? rule.tags, rule.params.ruleId, immutable ?? rule.params.immutable), + name: calculateName({ updatedName: name, originalName: rule.name }), + schedule: { + interval: calculateInterval(interval, rule.schedule.interval), + }, + actions: rule.actions, + params: nextParams, + }, + }); + + if (rule.enabled && enabled === false) { + await alertsClient.disable({ id: rule.id }); + } else if (!rule.enabled && enabled === true) { + await alertsClient.enable({ id: rule.id }); + const ruleCurrentStatus = savedObjectsClient + ? await savedObjectsClient.find({ + type: ruleStatusSavedObjectType, + perPage: 1, + sortField: 'statusDate', + sortOrder: 'desc', + search: rule.id, + searchFields: ['alertId'], + }) + : null; + // set current status for this rule to be 'going to run' + if (ruleCurrentStatus && ruleCurrentStatus.saved_objects.length > 0) { + const currentStatusToDisable = ruleCurrentStatus.saved_objects[0]; + currentStatusToDisable.attributes.status = 'going to run'; + await savedObjectsClient?.update(ruleStatusSavedObjectType, currentStatusToDisable.id, { + ...currentStatusToDisable.attributes, + }); + } + } else { + // enabled is null or undefined and we do not touch the rule + } + + if (enabled != null) { + return { ...update, enabled }; + } else { + return update; + } +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/types.ts index 1d423c8b375d11..8c44d82f46b530 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/types.ts @@ -20,7 +20,12 @@ import { RuleAlertParams, RuleTypeParams, RuleAlertParamsRest } from '../types'; import { RequestFacade } from '../../../types'; import { Alert } from '../../../../../alerting/server/types'; -export type UpdateRuleAlertParamsRest = Partial & { +export type PatchRuleAlertParamsRest = Partial & { + id: string | undefined; + rule_id: RuleAlertParams['ruleId'] | undefined; +}; + +export type UpdateRuleAlertParamsRest = RuleAlertParamsRest & { id: string | undefined; rule_id: RuleAlertParams['ruleId'] | undefined; }; @@ -34,6 +39,14 @@ export interface FindParamsRest { filter: string; } +export interface PatchRulesRequest extends RequestFacade { + payload: PatchRuleAlertParamsRest; +} + +export interface BulkPatchRulesRequest extends RequestFacade { + payload: PatchRuleAlertParamsRest[]; +} + export interface UpdateRulesRequest extends RequestFacade { payload: UpdateRuleAlertParamsRest; } @@ -153,7 +166,12 @@ export interface Clients { actionsClient: ActionsClient; } -export type UpdateRuleParams = Partial & { +export type PatchRuleParams = Partial & { + id: string | undefined | null; + savedObjectsClient: SavedObjectsClientContract; +} & Clients; + +export type UpdateRuleParams = RuleAlertParams & { id: string | undefined | null; savedObjectsClient: SavedObjectsClientContract; } & Clients; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.ts index a169e5107c316c..2fa903f3d713f5 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.ts @@ -7,7 +7,7 @@ import { SavedObjectsClientContract } from 'kibana/server'; import { ActionsClient } from '../../../../../../../plugins/actions/server'; import { AlertsClient } from '../../../../../alerting'; -import { updateRules } from './update_rules'; +import { patchRules } from './patch_rules'; import { PrepackagedRules } from '../types'; export const updatePrepackagedRules = async ( @@ -45,7 +45,7 @@ export const updatePrepackagedRules = async ( // Note: we do not pass down enabled as we do not want to suddenly disable // or enable rules on the user when they were not expecting it if a rule updates - return updateRules({ + return patchRules({ alertsClient, actionsClient, description, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts index 634c0d5a52cb1f..1dc5d8429fab8f 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts @@ -4,79 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { defaults, pickBy, isEmpty } from 'lodash/fp'; import { PartialAlert } from '../../../../../alerting/server/types'; import { readRules } from './read_rules'; -import { UpdateRuleParams, IRuleSavedAttributesSavedObjectAttributes } from './types'; +import { IRuleSavedAttributesSavedObjectAttributes, UpdateRuleParams } from './types'; import { addTags } from './add_tags'; import { ruleStatusSavedObjectType } from './saved_object_mappings'; - -export const calculateInterval = ( - interval: string | undefined, - ruleInterval: string | undefined -): string => { - if (interval != null) { - return interval; - } else if (ruleInterval != null) { - return ruleInterval; - } else { - return '5m'; - } -}; - -export const calculateVersion = ( - immutable: boolean, - currentVersion: number, - updateProperties: Partial> -): number => { - // early return if we are pre-packaged/immutable rule to be safe. We are never responsible - // for changing the version number of an immutable. Immutables are only responsible for changing - // their own version number. This would be really bad if an immutable version number is bumped by us - // due to a bug, hence the extra check and early bail if that is detected. - if (immutable === true) { - if (updateProperties.version != null) { - // we are an immutable rule but we are asking to update the version number so go ahead - // and update it to what is asked. - return updateProperties.version; - } else { - // we are immutable and not asking to update the version number so return the existing version - return currentVersion; - } - } - - // white list all properties but the enabled/disabled flag. We don't want to auto-increment - // the version number if only the enabled/disabled flag is being set. Likewise if we get other - // properties we are not expecting such as updatedAt we do not to cause a version number bump - // on that either. - const removedNullValues = pickBy( - (value: unknown) => value != null, - updateProperties - ); - if (isEmpty(removedNullValues)) { - return currentVersion; - } else { - return currentVersion + 1; - } -}; - -export const calculateName = ({ - updatedName, - originalName, -}: { - updatedName: string | undefined; - originalName: string | undefined; -}): string => { - if (updatedName != null) { - return updatedName; - } else if (originalName != null) { - return originalName; - } else { - // You really should never get to this point. This is a fail safe way to send back - // the name of "untitled" just in case a rule name became null or undefined at - // some point since TypeScript allows it. - return 'untitled'; - } -}; +import { calculateVersion } from './utils'; export const updateRules = async ({ alertsClient, @@ -141,47 +74,40 @@ export const updateRules = async ({ version, }); - const nextParams = defaults( - { - ...rule.params, - }, - { - description, - falsePositives, - from, - immutable, - query, - language, - outputIndex, - savedId, - timelineId, - timelineTitle, - meta, - filters, - index, - maxSignals, - riskScore, - severity, - threat, - to, - type, - references, - version: calculatedVersion, - } - ); - const update = await alertsClient.update({ id: rule.id, data: { - tags: addTags(tags ?? rule.tags, rule.params.ruleId, immutable ?? rule.params.immutable), - name: calculateName({ updatedName: name, originalName: rule.name }), - schedule: { - interval: calculateInterval(interval, rule.schedule.interval), - }, + tags: addTags(tags, rule.params.ruleId, immutable), + name, + schedule: { interval }, actions: rule.actions, - params: nextParams, + params: { + description, + ruleId: rule.params.ruleId, + falsePositives, + from, + immutable, + query, + language, + outputIndex, + savedId, + timelineId, + timelineTitle, + meta, + filters, + index, + maxSignals, + riskScore, + severity, + threat, + to, + type, + references, + version: calculatedVersion, + }, }, }); + if (rule.enabled && enabled === false) { await alertsClient.disable({ id: rule.id }); } else if (!rule.enabled && enabled === true) { @@ -204,13 +130,7 @@ export const updateRules = async ({ ...currentStatusToDisable.attributes, }); } - } else { - // enabled is null or undefined and we do not touch the rule } - if (enabled != null) { - return { ...update, enabled }; - } else { - return update; - } + return { ...update, enabled }; }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/utils.test.ts similarity index 93% rename from x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts rename to x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/utils.test.ts index 0d426fb03bd378..b7c36b20f44bef 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/utils.test.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { calculateInterval, calculateName, calculateVersion } from './update_rules'; -import { UpdateRuleParams } from './types'; +import { calculateInterval, calculateVersion, calculateName } from './utils'; +import { PatchRuleParams } from './types'; -describe('update_rules', () => { +describe('utils', () => { describe('#calculateInterval', () => { test('given a undefined interval, it returns the ruleInterval ', () => { const interval = calculateInterval(undefined, '10m'); @@ -44,7 +44,7 @@ describe('update_rules', () => { test('returning an updated version number if not given an immutable but an updated falsy value', () => { expect( - calculateVersion(false, 1, ({ description: false } as unknown) as UpdateRuleParams) + calculateVersion(false, 1, ({ description: false } as unknown) as PatchRuleParams) ).toEqual(2); }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/utils.ts new file mode 100644 index 00000000000000..7d6091f6b97faa --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/utils.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pickBy, isEmpty } from 'lodash/fp'; +import { PatchRuleParams } from './types'; + +export const calculateInterval = ( + interval: string | undefined, + ruleInterval: string | undefined +): string => { + if (interval != null) { + return interval; + } else if (ruleInterval != null) { + return ruleInterval; + } else { + return '5m'; + } +}; + +export const calculateVersion = ( + immutable: boolean, + currentVersion: number, + updateProperties: Partial> +): number => { + // early return if we are pre-packaged/immutable rule to be safe. We are never responsible + // for changing the version number of an immutable. Immutables are only responsible for changing + // their own version number. This would be really bad if an immutable version number is bumped by us + // due to a bug, hence the extra check and early bail if that is detected. + if (immutable === true) { + if (updateProperties.version != null) { + // we are an immutable rule but we are asking to update the version number so go ahead + // and update it to what is asked. + return updateProperties.version; + } else { + // we are immutable and not asking to update the version number so return the existing version + return currentVersion; + } + } + + // white list all properties but the enabled/disabled flag. We don't want to auto-increment + // the version number if only the enabled/disabled flag is being set. Likewise if we get other + // properties we are not expecting such as updatedAt we do not to cause a version number bump + // on that either. + const removedNullValues = pickBy( + (value: unknown) => value != null, + updateProperties + ); + if (isEmpty(removedNullValues)) { + return currentVersion; + } else { + return currentVersion + 1; + } +}; + +export const calculateName = ({ + updatedName, + originalName, +}: { + updatedName: string | undefined; + originalName: string | undefined; +}): string => { + if (updatedName != null) { + return updatedName; + } else if (originalName != null) { + return originalName; + } else { + // You really should never get to this point. This is a fail safe way to send back + // the name of "untitled" just in case a rule name became null or undefined at + // some point since TypeScript allows it. + return 'untitled'; + } +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/patch_rule.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/patch_rule.sh new file mode 100755 index 00000000000000..8094d9bad552cd --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/patch_rule.sh @@ -0,0 +1,31 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + +# Uses a default if no argument is specified +RULES=(${@:-./rules/patches/simplest_updated_name.json}) + +# Example: ./patch_rule.sh +# Example: ./patch_rule.sh ./rules/patches/simplest_updated_name.json +# Example glob: ./patch_rule.sh ./rules/patches/* +for RULE in "${RULES[@]}" +do { + [ -e "$RULE" ] || continue + curl -s -k \ + -H 'Content-Type: application/json' \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X PATCH ${KIBANA_URL}${SPACE_URL}/api/detection_engine/rules \ + -d @${RULE} \ + | jq .; +} & +done + +wait diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/patch_rule_bulk.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/patch_rule_bulk.sh new file mode 100755 index 00000000000000..3ae32445433ad6 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/patch_rule_bulk.sh @@ -0,0 +1,22 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + +# Uses a default if no argument is specified +RULES=${1:-./rules/bulk/patch_names.json} + +# Example: ./patch_rule_bulk.sh +curl -s -k \ + -H 'Content-Type: application/json' \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X PATCH ${KIBANA_URL}${SPACE_URL}/api/detection_engine/rules/_bulk_update \ + -d @${RULES} \ + | jq .; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/bulk/multiple_simplest_queries.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/bulk/multiple_simplest_queries.json index 9e5328ffabe2ec..ef172acde3807c 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/bulk/multiple_simplest_queries.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/bulk/multiple_simplest_queries.json @@ -2,6 +2,7 @@ { "name": "Simplest Query Number 1", "description": "Simplest query with the least amount of fields required", + "rule_id": "query-rule-id-1", "risk_score": 1, "severity": "high", "type": "query", @@ -12,6 +13,7 @@ { "name": "Simplest Query Number 2", "description": "Simplest query with the least amount of fields required", + "rule_id": "query-rule-id-2", "risk_score": 2, "severity": "low", "type": "query", diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/bulk/update_names.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/bulk/patch_names.json similarity index 100% rename from x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/bulk/update_names.json rename to x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/bulk/patch_names.json diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/README.md b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/README.md new file mode 100644 index 00000000000000..bb47e4adfc56d4 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/README.md @@ -0,0 +1,25 @@ +These are example PATCH rules to see how to patch various parts of the rules. +You either have to use the id, or you have to use the rule_id in order to patch +the rules. rule_id acts as an external_id where you can patch rules across different +Kibana systems where id acts as a normal server generated id which is not normally shared +across different Kibana systems. + +The only thing you cannot patch is the `rule_id` or regular `id` of the system. If `rule_id` +is incorrect then you have to delete the rule completely and re-initialize it with the +correct `rule_id` + +First add all the examples from queries like so: + +```sh +./post_rule.sh ./rules/queries/*.json +``` + +Then to selectively patch a rule add the file of your choosing to patch: + +```sh +./patch_rule.sh ./rules/patches/.json +``` + +Take note that the ones with "id" must be changed to a GUID that only you know about through +a `./find_rules.sh`. For example to grab a GUID id off of the first found record that exists +you can do: `./find_rules.sh | jq '.data[0].id'` and then replace the id in `patches/simplest_update_risk_score_by_id.json` with that particular id to watch it happen. diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/disable_rule.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/disable_rule.json new file mode 100644 index 00000000000000..a94558143882b1 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/disable_rule.json @@ -0,0 +1,4 @@ +{ + "rule_id": "query-rule-id", + "enabled": false +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/enabled_rule.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/enabled_rule.json new file mode 100644 index 00000000000000..bfe7c7f546fc3b --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/enabled_rule.json @@ -0,0 +1,4 @@ +{ + "rule_id": "query-rule-id", + "enabled": true +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/simplest_update_risk_score_by_id.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/simplest_update_risk_score_by_id.json new file mode 100644 index 00000000000000..00966ddba7c7ab --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/simplest_update_risk_score_by_id.json @@ -0,0 +1,4 @@ +{ + "id": "ade31ba8-dc49-4c18-b7f4-370b35df5f57", + "risk_score": 38 +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/simplest_update_risk_score_by_rule_id.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/simplest_update_risk_score_by_rule_id.json new file mode 100644 index 00000000000000..ad3c78183297d0 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/simplest_update_risk_score_by_rule_id.json @@ -0,0 +1,4 @@ +{ + "rule_id": "query-rule-id", + "risk_score": 98 +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/simplest_updated_name.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/simplest_updated_name.json new file mode 100644 index 00000000000000..56c9f151dc7129 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/simplest_updated_name.json @@ -0,0 +1,4 @@ +{ + "rule_id": "query-rule-id", + "name": "Changes only the name to this new value" +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/update_interval.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/update_interval.json new file mode 100644 index 00000000000000..72a535f0ef6417 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/update_interval.json @@ -0,0 +1,4 @@ +{ + "rule_id": "query-rule-id", + "interval": "6m" +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/update_query_everything.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/update_query_everything.json new file mode 100644 index 00000000000000..eb210cd8153d7e --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/update_query_everything.json @@ -0,0 +1,82 @@ +{ + "name": "Updates a query with all possible fields that can be updated", + "description": "Kitchen Sink (everything) query that has all possible fields filled out.", + "false_positives": [ + "https://www.example.com/some-article-about-a-false-positive", + "some text string about why another condition could be a false positive" + ], + "rule_id": "rule-id-everything", + "filters": [ + { + "query": { + "match_phrase": { + "host.name": "siem-windows" + } + } + }, + { + "exists": { + "field": "host.hostname" + } + } + ], + "enabled": false, + "index": ["auditbeat-*", "filebeat-*"], + "interval": "5m", + "query": "user.name: root or user.name: admin", + "output_index": ".siem-signals-default", + "meta": { + "anything_you_want_ui_related_or_otherwise": { + "as_deep_structured_as_you_need": { + "any_data_type": {} + } + } + }, + "language": "kuery", + "risk_score": 1, + "max_signals": 100, + "tags": ["tag 1", "tag 2", "any tag you want"], + "to": "now", + "from": "now-6m", + "severity": "high", + "type": "query", + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0040", + "name": "impact", + "reference": "https://attack.mitre.org/tactics/TA0040/" + }, + "technique": [ + { + "id": "T1499", + "name": "endpoint denial of service", + "reference": "https://attack.mitre.org/techniques/T1499/" + } + ] + }, + { + "framework": "Some other Framework you want", + "tactic": { + "id": "some-other-id", + "name": "Some other name", + "reference": "https://example.com" + }, + "technique": [ + { + "id": "some-other-id", + "name": "some other technique name", + "reference": "https://example.com" + } + ] + } + ], + "references": [ + "http://www.example.com/some-article-about-attack", + "Some plain text string here explaining why this is a valid thing to look out for" + ], + "timeline_id": "other-timeline-id", + "timeline_title": "other-timeline-title", + "version": 42 +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/update_tags.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/update_tags.json new file mode 100644 index 00000000000000..be833105792c63 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/update_tags.json @@ -0,0 +1,4 @@ +{ + "rule_id": "tags-query", + "tags": ["tag_3"] +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/update_timelineid.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/update_timelineid.json new file mode 100644 index 00000000000000..27dee7dd81463c --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/update_timelineid.json @@ -0,0 +1,5 @@ +{ + "rule_id": "query-rule-id", + "timeline_id": "other-timeline-id", + "timeline_title": "other-timeline-title" +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/update_version.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/update_version.json new file mode 100644 index 00000000000000..8df63dd22bf9a9 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/update_version.json @@ -0,0 +1,4 @@ +{ + "rule_id": "query-rule-id", + "version": 500 +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/README.md b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/README.md index 97a5d31bb01330..5fdf0faa122e9d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/README.md +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/README.md @@ -1,4 +1,4 @@ -These are example PUT rules to see how to update various parts of the rules. +These are example update rules to see how to update various parts of the rules. You either have to use the id, or you have to use the rule_id in order to update the rules. rule_id acts as an external_id where you can update rules across different Kibana systems where id acts as a normal server generated id which is not normally shared @@ -14,7 +14,7 @@ First add all the examples from queries like so: ./post_rule.sh ./rules/queries/*.json ``` -Then to selectively update a rule add the file of your choosing to update: +Then to selectively update a rule add the file of your choosing to patch: ```sh ./update_rule.sh ./rules/updates/.json diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/disable_rule.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/disable_rule.json index a94558143882b1..8752d66e4a0dc6 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/disable_rule.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/disable_rule.json @@ -1,4 +1,10 @@ { + "name": "Some new name", + "description": "Changing the name and disabling this query", "rule_id": "query-rule-id", + "risk_score": 1, + "severity": "high", + "type": "query", + "query": "user.name: root or user.name: admin", "enabled": false } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/enabled_rule.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/enabled_rule.json index bfe7c7f546fc3b..3556e2c94da486 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/enabled_rule.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/enabled_rule.json @@ -1,4 +1,10 @@ { + "name": "Some new name", + "description": "Changing the name and enabling this query", "rule_id": "query-rule-id", + "risk_score": 1, + "severity": "high", + "type": "query", + "query": "user.name: root or user.name: admin", "enabled": true } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/simplest_update_risk_score_by_id.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/simplest_update_risk_score_by_id.json index 00966ddba7c7ab..847c7480ef6b5b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/simplest_update_risk_score_by_id.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/simplest_update_risk_score_by_id.json @@ -1,4 +1,9 @@ { - "id": "ade31ba8-dc49-4c18-b7f4-370b35df5f57", - "risk_score": 38 + "id": "1100ba1b-ed7e-4755-b326-1f6fa2bd6758", + "name": "Some new name", + "description": "Changing the name and changing the risk score", + "risk_score": 38, + "severity": "high", + "type": "query", + "query": "user.name: root or user.name: admin" } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/simplest_update_risk_score_by_rule_id.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/simplest_update_risk_score_by_rule_id.json index ad3c78183297d0..5c1e71e3833a9e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/simplest_update_risk_score_by_rule_id.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/simplest_update_risk_score_by_rule_id.json @@ -1,4 +1,9 @@ { "rule_id": "query-rule-id", - "risk_score": 98 + "name": "Some new name", + "description": "Changing the name and changing the risk score", + "risk_score": 98, + "severity": "high", + "type": "query", + "query": "user.name: root or user.name: admin" } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/simplest_updated_name.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/simplest_updated_name.json index 56c9f151dc7129..ef086743e07f46 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/simplest_updated_name.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/simplest_updated_name.json @@ -1,4 +1,9 @@ { + "name": "Changes only the name to this new value", + "description": "Query with a rule_id that acts like an external id", "rule_id": "query-rule-id", - "name": "Changes only the name to this new value" + "risk_score": 1, + "severity": "high", + "type": "query", + "query": "user.name: root or user.name: admin" } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_interval.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_interval.json index 72a535f0ef6417..80bf306fe36b41 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_interval.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_interval.json @@ -1,4 +1,10 @@ { "rule_id": "query-rule-id", - "interval": "6m" + "interval": "6m", + "name": "Some new name", + "description": "Changing the interval and risk score", + "type": "query", + "query": "user.name: root or user.name: admin", + "severity": "low", + "risk_score": 0 } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_tags.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_tags.json index be833105792c63..4b9f773a1a4b05 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_tags.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_tags.json @@ -1,4 +1,10 @@ { - "rule_id": "tags-query", - "tags": ["tag_3"] + "rule_id": "query-rule-id", + "tags": ["tag_1", "tag_2", "tag_3"], + "name": "Some new name", + "description": "Adding tags and a few other updates such as name", + "type": "query", + "query": "user.name: root or user.name: admin", + "severity": "low", + "risk_score": 10 } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_timelineid.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_timelineid.json index 27dee7dd81463c..0fb8626fe3ce42 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_timelineid.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_timelineid.json @@ -1,5 +1,11 @@ { "rule_id": "query-rule-id", "timeline_id": "other-timeline-id", - "timeline_title": "other-timeline-title" + "timeline_title": "other-timeline-title", + "name": "Some new name", + "description": "Adding tags and a few other updates such as name", + "type": "query", + "query": "user.name: root or user.name: admin", + "severity": "low", + "risk_score": 10 } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_version.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_version.json index 8df63dd22bf9a9..4df935fb3f6b8e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_version.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_version.json @@ -1,4 +1,10 @@ { "rule_id": "query-rule-id", - "version": 500 + "version": 500, + "name": "Changes the version to arbitrary number", + "description": "Changes the version to some arbitrary number", + "type": "query", + "query": "user.name: root or user.name: admin", + "severity": "low", + "risk_score": 10 } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/update_rule.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/update_rule.sh index aa22db965664a5..22bc4fb7bf5845 100755 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/update_rule.sh +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/update_rule.sh @@ -14,7 +14,7 @@ RULES=(${@:-./rules/updates/simplest_updated_name.json}) # Example: ./update_rule.sh # Example: ./update_rule.sh ./rules/updates/simplest_updated_name.json -# Example glob: ./post_rule.sh ./rules/updates/* +# Example glob: ./update_rule.sh ./rules/updates/* for RULE in "${RULES[@]}" do { [ -e "$RULE" ] || continue diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/update_rule_bulk.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/update_rule_bulk.sh index c9cb0676821c59..11fb8d0b6f81c9 100755 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/update_rule_bulk.sh +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/update_rule_bulk.sh @@ -10,7 +10,7 @@ set -e ./check_env_variables.sh # Uses a default if no argument is specified -RULES=${1:-./rules/bulk/update_names.json} +RULES=${1:-./rules/bulk/multiple_simplest_queries.json} # Example: ./update_rule_bulk.sh curl -s -k \ diff --git a/x-pack/legacy/plugins/uptime/common/constants/ui.ts b/x-pack/legacy/plugins/uptime/common/constants/ui.ts index c91a2f66251942..8389d86fd20727 100644 --- a/x-pack/legacy/plugins/uptime/common/constants/ui.ts +++ b/x-pack/legacy/plugins/uptime/common/constants/ui.ts @@ -4,6 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +export const MONITOR_ROUTE = '/monitor/:monitorId?'; + +export const OVERVIEW_ROUTE = '/'; + export enum STATUS { UP = 'up', DOWN = 'down', diff --git a/x-pack/legacy/plugins/uptime/common/graphql/introspection.json b/x-pack/legacy/plugins/uptime/common/graphql/introspection.json index e5d9816ebd28e8..18f26552d31535 100644 --- a/x-pack/legacy/plugins/uptime/common/graphql/introspection.json +++ b/x-pack/legacy/plugins/uptime/common/graphql/introspection.json @@ -72,18 +72,6 @@ "isDeprecated": false, "deprecationReason": null }, - { - "name": "getDocCount", - "description": "Gets the number of documents in the target index", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "OBJECT", "name": "DocCount", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, { "name": "getMonitors", "description": "", diff --git a/x-pack/legacy/plugins/uptime/common/graphql/types.ts b/x-pack/legacy/plugins/uptime/common/graphql/types.ts index c58dd9111cc3f7..643c419be04114 100644 --- a/x-pack/legacy/plugins/uptime/common/graphql/types.ts +++ b/x-pack/legacy/plugins/uptime/common/graphql/types.ts @@ -17,8 +17,6 @@ export type UnsignedInteger = any; export interface Query { /** Get a list of all recorded pings for all monitors */ allPings: PingResults; - /** Gets the number of documents in the target index */ - getDocCount: DocCount; getMonitors?: LatestMonitorsResult | null; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/index.ts b/x-pack/legacy/plugins/uptime/public/components/connected/index.ts index 2fd4c762cf45f5..13309acd036222 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/index.ts +++ b/x-pack/legacy/plugins/uptime/public/components/connected/index.ts @@ -7,7 +7,6 @@ export { PingHistogram } from './charts/ping_histogram'; export { Snapshot } from './charts/snapshot_container'; export { KueryBar } from './kuerybar/kuery_bar_container'; -export { OverviewPage } from './pages/overview_container'; export { FilterGroup } from './filter_group/filter_group_container'; export { MonitorStatusDetails } from './monitor/status_details_container'; export { MonitorStatusBar } from './monitor/status_bar_container'; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/pages/overview_container.ts b/x-pack/legacy/plugins/uptime/public/components/connected/pages/overview_container.tsx similarity index 100% rename from x-pack/legacy/plugins/uptime/public/components/connected/pages/overview_container.ts rename to x-pack/legacy/plugins/uptime/public/components/connected/pages/overview_container.tsx diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/pages/page_header_container.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/pages/page_header_container.tsx new file mode 100644 index 00000000000000..9429b87061ff7c --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/connected/pages/page_header_container.tsx @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; +import { selectSelectedMonitor } from '../../../state/selectors'; +import { AppState } from '../../../state'; +import { PageHeaderComponent } from '../../../pages/page_header'; + +const mapStateToProps = (state: AppState) => ({ + monitorStatus: selectSelectedMonitor(state), +}); + +export const PageHeader = connect(mapStateToProps, null)(PageHeaderComponent); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_charts.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_charts.test.tsx index 331b5c9c0b0965..f8e885147b9925 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_charts.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_charts.test.tsx @@ -6,10 +6,9 @@ import React from 'react'; import DateMath from '@elastic/datemath'; -import { shallowWithIntl } from 'test_utils/enzyme_helpers'; import { MonitorChartsComponent } from '../monitor_charts'; import { MonitorChart } from '../../../../common/graphql/types'; -import { renderWithRouter } from '../../../lib'; +import { shallowWithRouter } from '../../../lib'; describe('MonitorCharts component', () => { let dateMathSpy: any; @@ -63,18 +62,16 @@ describe('MonitorCharts component', () => { }; it('renders the component without errors', () => { - const component = shallowWithIntl( - renderWithRouter( - - ) + const component = shallowWithRouter( + ); expect(component).toMatchSnapshot(); }); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/uptime_date_picker.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/uptime_date_picker.test.tsx index 5ce88f2bd5c220..445d9302e3a9dc 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/uptime_date_picker.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/uptime_date_picker.test.tsx @@ -4,19 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { shallowWithIntl, renderWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; import { UptimeDatePicker } from '../uptime_date_picker'; -import { renderWithRouter } from '../../../lib'; +import { renderWithRouter, shallowWithRouter } from '../../../lib'; describe('UptimeDatePicker component', () => { it('validates props with shallow render', () => { - const component = shallowWithIntl(renderWithRouter()); + const component = shallowWithRouter(); expect(component).toMatchSnapshot(); }); it('renders properly with mock data', () => { - const component = renderWithIntl(renderWithRouter()); + const component = renderWithRouter(); expect(component).toMatchSnapshot(); }); }); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/monitor_bar_series.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/monitor_bar_series.test.tsx index c3e98134e438d8..5d4e112aa5f281 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/monitor_bar_series.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/monitor_bar_series.test.tsx @@ -5,9 +5,8 @@ */ import React from 'react'; -import { renderWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; import { MonitorBarSeries, MonitorBarSeriesProps } from '../monitor_bar_series'; -import { renderWithRouter } from '../../../../lib'; +import { renderWithRouter, shallowWithRouter } from '../../../../lib'; import { SummaryHistogramPoint } from '../../../../../common/graphql/types'; describe('MonitorBarSeries component', () => { @@ -161,13 +160,13 @@ describe('MonitorBarSeries component', () => { }); it('shallow renders a series when there are down items', () => { - const component = shallowWithIntl(renderWithRouter()); + const component = shallowWithRouter(); expect(component).toMatchSnapshot(); }); it('shallow renders null when there are no down items', () => { props.histogramSeries = []; - const component = shallowWithIntl(renderWithRouter()); + const component = shallowWithRouter(); expect(component).toEqual({}); }); @@ -189,20 +188,20 @@ describe('MonitorBarSeries component', () => { up: 0, }, ]; - const component = shallowWithIntl(renderWithRouter()); + const component = shallowWithRouter(); expect(component).toEqual({}); }); it('shallow renders nothing if the data series is null', () => { - const component = shallowWithIntl( - renderWithRouter() + const component = shallowWithRouter( + ); expect(component).toEqual({}); }); it('renders if the data series is present', () => { - const component = renderWithIntl( - renderWithRouter() + const component = renderWithRouter( + ); expect(component).toMatchSnapshot(); }); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/__tests__/filter_status_button.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/__tests__/filter_status_button.test.tsx index 0c0393cb4fedfb..1813229a97d1b5 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/__tests__/filter_status_button.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/__tests__/filter_status_button.test.tsx @@ -5,9 +5,8 @@ */ import React from 'react'; -import { shallowWithIntl } from 'test_utils/enzyme_helpers'; import { FilterStatusButton, FilterStatusButtonProps } from '../filter_status_button'; -import { renderWithRouter } from '../../../../lib/'; +import { shallowWithRouter } from '../../../../lib/'; describe('FilterStatusButton', () => { let props: FilterStatusButtonProps; @@ -21,7 +20,7 @@ describe('FilterStatusButton', () => { }); it('renders without errors for valid props', () => { - const wrapper = shallowWithIntl(renderWithRouter()); + const wrapper = shallowWithRouter(); expect(wrapper).toMatchSnapshot(); }); }); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/__snapshots__/location_map.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/__snapshots__/location_map.test.tsx.snap index ed50bc0be382a5..d782eb565ef99f 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/__snapshots__/location_map.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/__snapshots__/location_map.test.tsx.snap @@ -42,15 +42,15 @@ exports[`LocationMap component doesnt shows warning if geo is provided 1`] = ` } /> - - - - + + `; @@ -127,15 +127,15 @@ exports[`LocationMap component renders correctly against snapshot 1`] = ` } /> - - @@ -155,8 +155,8 @@ exports[`LocationMap component renders correctly against snapshot 1`] = ` } /> - - + + `; @@ -186,15 +186,15 @@ exports[`LocationMap component renders named locations that have missing geo dat } /> - - @@ -203,8 +203,8 @@ exports[`LocationMap component renders named locations that have missing geo dat upPoints={Array []} /> - - + + `; @@ -247,15 +247,15 @@ exports[`LocationMap component shows warning if geo information is missing 1`] = } /> - - @@ -271,8 +271,8 @@ exports[`LocationMap component shows warning if geo information is missing 1`] = } /> - - + + `; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/location_map.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/location_map.tsx index 8b9e410b0de79e..27fe3a22742702 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/location_map.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/location_map.tsx @@ -32,7 +32,6 @@ const EuiFlexItemTags = styled(EuiFlexItem)` padding-top: 5px; @media (max-width: 850px) { order: 1; - text-align: center; } `; @@ -80,14 +79,14 @@ export const LocationMap = ({ monitorLocations }: LocationMapProps) => { - - + + {isGeoInfoMissing && } - - + + ); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list.test.tsx index bb9ce59ea62b17..14e91b9db920e7 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list.test.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { renderWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { shallowWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; import { MonitorSummaryResult } from '../../../../../common/graphql/types'; import { MonitorListComponent } from '../monitor_list'; @@ -110,16 +110,14 @@ describe('MonitorList component', () => { }); it('renders the monitor list', () => { - const component = renderWithIntl( - renderWithRouter( - - ) + const component = renderWithRouter( + ); expect(component).toMatchSnapshot(); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_drawer/__tests__/monitor_list_drawer.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_drawer/__tests__/monitor_list_drawer.test.tsx index 9bd407902cb550..c222728df3bb3e 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_drawer/__tests__/monitor_list_drawer.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_drawer/__tests__/monitor_list_drawer.test.tsx @@ -5,11 +5,10 @@ */ import 'jest'; import { MonitorSummary, Check } from '../../../../../../common/graphql/types'; -import { shallowWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; import { MonitorListDrawerComponent } from '../monitor_list_drawer'; import { MonitorDetails } from '../../../../../../common/runtime_types'; -import { renderWithRouter } from '../../../../../lib'; +import { shallowWithRouter } from '../../../../../lib'; describe('MonitorListDrawer component', () => { let summary: MonitorSummary; @@ -52,41 +51,35 @@ describe('MonitorListDrawer component', () => { }); it('renders nothing when no summary data is present', () => { - const component = shallowWithIntl( - renderWithRouter( - - ) + const component = shallowWithRouter( + ); expect(component).toEqual({}); }); it('renders nothing when no check data is present', () => { delete summary.state.checks; - const component = shallowWithIntl( - renderWithRouter( - - ) + const component = shallowWithRouter( + ); expect(component).toEqual({}); }); it('renders a MonitorListDrawer when there is only one check', () => { - const component = shallowWithIntl( - renderWithRouter( - - ) + const component = shallowWithRouter( + ); expect(component).toMatchSnapshot(); }); @@ -116,14 +109,12 @@ describe('MonitorListDrawer component', () => { }, ]; summary.state.checks = checks; - const component = shallowWithIntl( - renderWithRouter( - - ) + const component = shallowWithRouter( + ); expect(component).toMatchSnapshot(); }); diff --git a/x-pack/legacy/plugins/uptime/public/hooks/__tests__/__snapshots__/use_url_params.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/hooks/__tests__/__snapshots__/use_url_params.test.tsx.snap index a2c52f94052898..827c9257893ad0 100644 --- a/x-pack/legacy/plugins/uptime/public/hooks/__tests__/__snapshots__/use_url_params.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/hooks/__tests__/__snapshots__/use_url_params.test.tsx.snap @@ -11,7 +11,7 @@ exports[`useUrlParams deletes keys that do not have truthy values 1`] = ` "entries": Array [ Object { "hash": "", - "key": "test", + "key": "TestKeyForTesting", "pathname": "/", "search": "?g=%22%22&dateRangeStart=now-12&dateRangeEnd=now&pagination=foo", "state": undefined, @@ -25,7 +25,7 @@ exports[`useUrlParams deletes keys that do not have truthy values 1`] = ` "listen": [Function], "location": Object { "hash": "", - "key": "test", + "key": "TestKeyForTesting", "pathname": "/", "search": "?g=%22%22&dateRangeStart=now-12&dateRangeEnd=now&pagination=foo", "state": undefined, diff --git a/x-pack/legacy/plugins/uptime/public/hooks/__tests__/use_url_params.test.tsx b/x-pack/legacy/plugins/uptime/public/hooks/__tests__/use_url_params.test.tsx index c9ba7b9bc0098d..da6b33bc493006 100644 --- a/x-pack/legacy/plugins/uptime/public/hooks/__tests__/use_url_params.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/hooks/__tests__/use_url_params.test.tsx @@ -4,12 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { mountWithIntl } from 'test_utils/enzyme_helpers'; import DateMath from '@elastic/datemath'; import React, { useState, Fragment } from 'react'; import { useUrlParams, UptimeUrlParamsHook } from '../use_url_params'; import { UptimeRefreshContext } from '../../contexts'; -import { renderWithRouter } from '../../lib'; +import { mountWithRouter } from '../../lib'; import { createMemoryHistory } from 'history'; interface MockUrlParamsComponentProps { @@ -51,13 +50,11 @@ describe('useUrlParams', () => { const history = createMemoryHistory(); jest.spyOn(history, 'push'); - const component = mountWithIntl( - renderWithRouter( - - - , - history - ) + const component = mountWithRouter( + + + , + history ); const setUrlParamsButton = component.find('#setUrlParams'); @@ -69,17 +66,15 @@ describe('useUrlParams', () => { }); it('gets the expected values using the context', () => { - const component = mountWithIntl( - renderWithRouter( - - - - ) + const component = mountWithRouter( + + + ); const getUrlParamsButton = component.find('#getUrlParams'); @@ -95,18 +90,16 @@ describe('useUrlParams', () => { history.location.key = 'test'; jest.spyOn(history, 'push'); - const component = mountWithIntl( - renderWithRouter( - - - , - history - ) + const component = mountWithRouter( + + + , + history ); const getUrlParamsButton = component.find('#getUrlParams'); diff --git a/x-pack/legacy/plugins/uptime/public/lib/helper/render_with_router.tsx b/x-pack/legacy/plugins/uptime/public/lib/helper/render_with_router.tsx index 5cd9ec23a35877..74d6cbf0a5a974 100644 --- a/x-pack/legacy/plugins/uptime/public/lib/helper/render_with_router.tsx +++ b/x-pack/legacy/plugins/uptime/public/lib/helper/render_with_router.tsx @@ -4,18 +4,36 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { ReactElement } from 'react'; import { Router } from 'react-router-dom'; import { MemoryHistory } from 'history/createMemoryHistory'; import { createMemoryHistory } from 'history'; +import { mountWithIntl, renderWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; -export const renderWithRouter = (Component: any, customHistory?: MemoryHistory) => { +const helperWithRouter: ( + helper: (node: ReactElement) => R, + component: ReactElement, + customHistory?: MemoryHistory +) => R = (helper, component, customHistory) => { if (customHistory) { - return {Component}; + customHistory.location.key = 'TestKeyForTesting'; + return helper({component}); } const history = createMemoryHistory(); history.location.key = 'TestKeyForTesting'; - return {Component}; + return helper({component}); +}; + +export const renderWithRouter = (component: ReactElement, customHistory?: MemoryHistory) => { + return helperWithRouter(renderWithIntl, component, customHistory); +}; + +export const shallowWithRouter = (component: ReactElement, customHistory?: MemoryHistory) => { + return helperWithRouter(shallowWithIntl, component, customHistory); +}; + +export const mountWithRouter = (component: ReactElement, customHistory?: MemoryHistory) => { + return helperWithRouter(mountWithIntl, component, customHistory); }; diff --git a/x-pack/legacy/plugins/uptime/public/lib/index.ts b/x-pack/legacy/plugins/uptime/public/lib/index.ts index 9a78c6df5d63d4..07a07923600440 100644 --- a/x-pack/legacy/plugins/uptime/public/lib/index.ts +++ b/x-pack/legacy/plugins/uptime/public/lib/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { renderWithRouter } from './helper/render_with_router'; +export { renderWithRouter, shallowWithRouter, mountWithRouter } from './helper/render_with_router'; diff --git a/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/monitor.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/monitor.test.tsx.snap new file mode 100644 index 00000000000000..6064caa868bf82 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/monitor.test.tsx.snap @@ -0,0 +1,56 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MonitorPage shallow renders expected elements for valid props 1`] = ` + + + +`; diff --git a/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/not_found.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/not_found.test.tsx.snap new file mode 100644 index 00000000000000..a4d13963aaf778 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/not_found.test.tsx.snap @@ -0,0 +1,56 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NotFoundPage render component for valid props 1`] = ` + + + +`; diff --git a/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/overview.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/overview.test.tsx.snap new file mode 100644 index 00000000000000..fff947bd960244 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/overview.test.tsx.snap @@ -0,0 +1,162 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MonitorPage shallow renders expected elements for valid props 1`] = ` + + + +`; diff --git a/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap new file mode 100644 index 00000000000000..2563b15eed5d5d --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap @@ -0,0 +1,1154 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PageHeaderComponent mount expected page title for valid monitor route 1`] = ` + + + + +
+ +
+ +

+ https://www.elastic.co +

+
+
+
+ +
+ + + +
+ +
+ + } + > +
+ + + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="QuickSelectPopover" + isOpen={false} + ownFocus={true} + panelPaddingSize="m" + > + +
+
+ + + +
+
+
+
+
+
+ } + iconType={false} + isCustom={true} + startDateControl={
} + > +
+ +
+ + +
+
+ +
+ + +
+ + + + + + + + + +
+
+
+ + + +
+ +
+ + +
+ + + + +`; + +exports[`PageHeaderComponent renders expected elements for valid props 1`] = ` +Array [ +
+
+

+ Overview +

+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+ + + +
+
+
+
, +
, +] +`; + +exports[`PageHeaderComponent renders expected title for valid monitor route 1`] = ` +Array [ +
+
+

+ https://www.elastic.co +

+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+ + + +
+
+
+
, +
, +] +`; + +exports[`PageHeaderComponent renders expected title for valid overview route 1`] = ` +Array [ +
+
+

+ Overview +

+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+ + + +
+
+
+
, +
, +] +`; + +exports[`PageHeaderComponent shallow renders expected elements for valid props 1`] = ` + + + +`; diff --git a/x-pack/legacy/plugins/uptime/public/pages/__tests__/monitor.test.tsx b/x-pack/legacy/plugins/uptime/public/pages/__tests__/monitor.test.tsx new file mode 100644 index 00000000000000..8a1256c741c85d --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/pages/__tests__/monitor.test.tsx @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { MonitorPage } from '../monitor'; +import { shallowWithRouter } from '../../lib'; + +describe('MonitorPage', () => { + it('shallow renders expected elements for valid props', () => { + expect(shallowWithRouter()).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/pages/__tests__/not_found.test.tsx b/x-pack/legacy/plugins/uptime/public/pages/__tests__/not_found.test.tsx new file mode 100644 index 00000000000000..2b6c60efc84b0f --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/pages/__tests__/not_found.test.tsx @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallowWithRouter } from '../../lib'; +import { NotFoundPage } from '../not_found'; + +describe('NotFoundPage', () => { + it('render component for valid props', () => { + const component = shallowWithRouter(); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/pages/__tests__/overview.test.tsx b/x-pack/legacy/plugins/uptime/public/pages/__tests__/overview.test.tsx new file mode 100644 index 00000000000000..365e96788bbbf4 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/pages/__tests__/overview.test.tsx @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { OverviewPageComponent } from '../overview'; +import { shallowWithRouter } from '../../lib'; + +describe('MonitorPage', () => { + const indexPattern = { + fields: [ + { + name: '@timestamp', + type: 'date', + esTypes: ['date'], + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + + { + name: 'monitor.check_group', + type: 'string', + esTypes: ['keyword'], + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'monitor.duration.us', + type: 'number', + esTypes: ['long'], + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'monitor.id', + type: 'string', + esTypes: ['keyword'], + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'monitor.ip', + type: 'ip', + esTypes: ['ip'], + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'monitor.name', + type: 'string', + esTypes: ['keyword'], + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'monitor.status', + type: 'string', + esTypes: ['keyword'], + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'monitor.timespan', + type: 'unknown', + esTypes: ['date_range'], + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'monitor.type', + type: 'string', + esTypes: ['keyword'], + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + ], + title: 'heartbeat-8*', + }; + + const autocomplete = { + getQuerySuggestions: jest.fn(), + hasQuerySuggestions: () => true, + getValueSuggestions: jest.fn(), + }; + + it('shallow renders expected elements for valid props', () => { + expect( + shallowWithRouter( + + ) + ).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/pages/__tests__/page_header.test.tsx b/x-pack/legacy/plugins/uptime/public/pages/__tests__/page_header.test.tsx new file mode 100644 index 00000000000000..38d074cdb5dba7 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/pages/__tests__/page_header.test.tsx @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { Route } from 'react-router-dom'; +import { PageHeaderComponent } from '../page_header'; +import { mountWithRouter, renderWithRouter, shallowWithRouter } from '../../lib'; +import { MONITOR_ROUTE, OVERVIEW_ROUTE } from '../../../common/constants'; +import { Ping } from '../../../common/graphql/types'; +import { createMemoryHistory } from 'history'; +import { ChromeBreadcrumb } from 'kibana/public'; + +describe('PageHeaderComponent', () => { + const monitorStatus: Ping = { + id: 'elastic-co', + tcp: { rtt: { connect: { us: 174982 } } }, + http: { + response: { + body: { + bytes: 2092041, + hash: '5d970606a6be810ae5d37115c4807fdd07ba4c3e407924ee5297e172d2efb3dc', + }, + status_code: 200, + }, + rtt: { + response_header: { us: 340175 }, + write_request: { us: 38 }, + validate: { us: 1797839 }, + content: { us: 1457663 }, + total: { us: 2030012 }, + }, + }, + monitor: { + ip: '2a04:4e42:3::729', + status: 'up', + duration: { us: 2030035 }, + type: 'http', + id: 'elastic-co', + name: 'elastic', + check_group: '2a017afa-4736-11ea-b3d0-acde48001122', + }, + resolve: { ip: '2a04:4e42:3::729', rtt: { us: 2102 } }, + url: { port: 443, full: 'https://www.elastic.co', scheme: 'https', domain: 'www.elastic.co' }, + ecs: { version: '1.4.0' }, + tls: { + certificate_not_valid_after: '2020-07-16T03:15:39.000Z', + rtt: { handshake: { us: 57115 } }, + certificate_not_valid_before: '2019-08-16T01:40:25.000Z', + }, + observer: { + geo: { name: 'US-West', location: '37.422994, -122.083666' }, + }, + timestamp: '2020-02-04T10:07:42.142Z', + }; + + it('shallow renders expected elements for valid props', () => { + const component = shallowWithRouter(); + expect(component).toMatchSnapshot(); + }); + + it('renders expected elements for valid props', () => { + const component = renderWithRouter(); + expect(component).toMatchSnapshot(); + }); + + it('renders expected title for valid overview route', () => { + const component = renderWithRouter( + + + + ); + expect(component).toMatchSnapshot(); + + const titleComponent = component.find('.euiTitle'); + expect(titleComponent.text()).toBe('Overview'); + }); + + it('renders expected title for valid monitor route', () => { + const history = createMemoryHistory({ initialEntries: ['/monitor/ZWxhc3RpYy1jbw=='] }); + + const component = renderWithRouter( + + + , + history + ); + expect(component).toMatchSnapshot(); + + const titleComponent = component.find('.euiTitle'); + expect(titleComponent.text()).toBe('https://www.elastic.co'); + }); + + it('mount expected page title for valid monitor route', () => { + const history = createMemoryHistory({ initialEntries: ['/monitor/ZWxhc3RpYy1jbw=='] }); + + const component = mountWithRouter( + + + , + history + ); + expect(component).toMatchSnapshot(); + + const titleComponent = component.find('.euiTitle'); + expect(titleComponent.text()).toBe('https://www.elastic.co'); + expect(document.title).toBe('Uptime | elastic - Kibana'); + }); + + it('mount and set expected breadcrumb for monitor route', () => { + const history = createMemoryHistory({ initialEntries: ['/monitor/ZWxhc3RpYy1jbw=='] }); + let breadcrumbObj: ChromeBreadcrumb[] = []; + const setBreadcrumb = (breadcrumbs: ChromeBreadcrumb[]) => { + breadcrumbObj = breadcrumbs; + }; + + mountWithRouter( + + + , + history + ); + + expect(breadcrumbObj).toStrictEqual([ + { href: '#/?', text: 'Uptime' }, + { text: 'https://www.elastic.co' }, + ]); + }); + + it('mount and set expected breadcrumb for overview route', () => { + let breadcrumbObj: ChromeBreadcrumb[] = []; + const setBreadcrumb = (breadcrumbs: ChromeBreadcrumb[]) => { + breadcrumbObj = breadcrumbs; + }; + + mountWithRouter( + + + + ); + + expect(breadcrumbObj).toStrictEqual([{ href: '#/', text: 'Uptime' }]); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/pages/index.ts b/x-pack/legacy/plugins/uptime/public/pages/index.ts index 17f083ca023ed4..3f74bda79bd461 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/index.ts +++ b/x-pack/legacy/plugins/uptime/public/pages/index.ts @@ -6,5 +6,3 @@ export { MonitorPage } from './monitor'; export { NotFoundPage } from './not_found'; -export { PageHeader } from './page_header'; -export { OverviewPage } from '../components/connected/'; diff --git a/x-pack/legacy/plugins/uptime/public/pages/monitor.tsx b/x-pack/legacy/plugins/uptime/public/pages/monitor.tsx index a8501ff14313a8..1d45c7b7ab99b7 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/monitor.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/monitor.tsx @@ -5,21 +5,15 @@ */ import { EuiSpacer } from '@elastic/eui'; -import React, { Fragment, useContext, useState } from 'react'; +import React, { useContext, useState } from 'react'; import { useParams } from 'react-router-dom'; import { MonitorCharts, PingList } from '../components/functional'; -import { UMUpdateBreadcrumbs } from '../lib/lib'; import { UptimeRefreshContext, UptimeThemeContext } from '../contexts'; import { useUptimeTelemetry, useUrlParams, UptimePage } from '../hooks'; import { useTrackPageview } from '../../../infra/public'; -import { PageHeader } from './page_header'; import { MonitorStatusDetails } from '../components/connected'; -interface MonitorPageProps { - setBreadcrumbs: UMUpdateBreadcrumbs; -} - -export const MonitorPage = ({ setBreadcrumbs }: MonitorPageProps) => { +export const MonitorPage = () => { // decode 64 base string, it was decoded to make it a valid url, since monitor id can be a url let { monitorId } = useParams(); monitorId = atob(monitorId || ''); @@ -46,8 +40,7 @@ export const MonitorPage = ({ setBreadcrumbs }: MonitorPageProps) => { useTrackPageview({ app: 'uptime', path: 'monitor', delay: 15000 }); return ( - - + <> @@ -69,6 +62,6 @@ export const MonitorPage = ({ setBreadcrumbs }: MonitorPageProps) => { status: selectedPingStatus, }} /> - + ); }; diff --git a/x-pack/legacy/plugins/uptime/public/pages/overview.tsx b/x-pack/legacy/plugins/uptime/public/pages/overview.tsx index ce5fb619aca025..ae7457e835c946 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/overview.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/overview.tsx @@ -13,11 +13,9 @@ import { OverviewPageParsingErrorCallout, StatusPanel, } from '../components/functional'; -import { UMUpdateBreadcrumbs } from '../lib/lib'; import { useUrlParams, useUptimeTelemetry, UptimePage } from '../hooks'; import { stringifyUrlParams } from '../lib/helper/stringify_url_params'; import { useTrackPageview } from '../../../infra/public'; -import { PageHeader } from './page_header'; import { DataPublicPluginStart, IIndexPattern } from '../../../../../../src/plugins/data/public'; import { UptimeThemeContext } from '../contexts'; import { FilterGroup, KueryBar } from '../components/connected'; @@ -25,7 +23,6 @@ import { useUpdateKueryString } from '../hooks'; interface OverviewPageProps { autocomplete: DataPublicPluginStart['autocomplete']; - setBreadcrumbs: UMUpdateBreadcrumbs; indexPattern: IIndexPattern; setEsKueryFilters: (esFilters: string) => void; } @@ -41,12 +38,7 @@ const EuiFlexItemStyled = styled(EuiFlexItem)` } `; -export const OverviewPageComponent = ({ - autocomplete, - setBreadcrumbs, - indexPattern, - setEsKueryFilters, -}: Props) => { +export const OverviewPageComponent = ({ autocomplete, indexPattern, setEsKueryFilters }: Props) => { const { colors } = useContext(UptimeThemeContext); const [getUrlParams] = useUrlParams(); const { absoluteDateRangeStart, absoluteDateRangeEnd, ...params } = getUrlParams(); @@ -81,7 +73,6 @@ export const OverviewPageComponent = ({ return ( <> - diff --git a/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx b/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx index 7c3f80d4beb981..5c051c491c6f5b 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx @@ -4,23 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import React, { useEffect, useState } from 'react'; -import { connect } from 'react-redux'; import { useRouteMatch } from 'react-router-dom'; -import { i18n } from '@kbn/i18n'; +import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { UptimeDatePicker } from '../components/functional/uptime_date_picker'; -import { AppState } from '../state'; -import { selectSelectedMonitor } from '../state/selectors'; import { getMonitorPageBreadcrumb, getOverviewPageBreadcrumbs } from '../breadcrumbs'; import { stringifyUrlParams } from '../lib/helper/stringify_url_params'; import { getTitle } from '../lib/helper/get_title'; import { UMUpdateBreadcrumbs } from '../lib/lib'; -import { MONITOR_ROUTE } from '../routes'; import { useUrlParams } from '../hooks'; +import { MONITOR_ROUTE } from '../../common/constants'; +import { Ping } from '../../common/graphql/types'; interface PageHeaderProps { - monitorStatus?: any; + monitorStatus?: Ping; setBreadcrumbs: UMUpdateBreadcrumbs; } @@ -32,24 +30,27 @@ export const PageHeaderComponent = ({ monitorStatus, setBreadcrumbs }: PageHeade const [getUrlParams] = useUrlParams(); const { absoluteDateRangeStart, absoluteDateRangeEnd, ...params } = getUrlParams(); - const headingText = i18n.translate('xpack.uptime.overviewPage.headerText', { - defaultMessage: 'Overview', - description: `The text that will be displayed in the app's heading when the Overview page loads.`, - }); + const headingText = !monitorPage + ? i18n.translate('xpack.uptime.overviewPage.headerText', { + defaultMessage: 'Overview', + description: `The text that will be displayed in the app's heading when the Overview page loads.`, + }) + : monitorStatus?.url?.full; const [headerText, setHeaderText] = useState(headingText); useEffect(() => { if (monitorPage) { - setHeaderText(monitorStatus?.url?.full); + setHeaderText(monitorStatus?.url?.full ?? ''); if (monitorStatus?.monitor) { const { name, id } = monitorStatus.monitor; - document.title = getTitle(name || id); + document.title = getTitle((name || id) ?? ''); } } else { + setHeaderText(headingText); document.title = getTitle(); } - }, [monitorStatus, monitorPage, setHeaderText]); + }, [monitorStatus, monitorPage, setHeaderText, headingText]); useEffect(() => { if (monitorPage) { @@ -61,10 +62,6 @@ export const PageHeaderComponent = ({ monitorStatus, setBreadcrumbs }: PageHeade } }, [headerText, setBreadcrumbs, params, monitorPage]); - useEffect(() => { - document.title = getTitle(); - }, []); - return ( <> @@ -81,9 +78,3 @@ export const PageHeaderComponent = ({ monitorStatus, setBreadcrumbs }: PageHeade ); }; - -const mapStateToProps = (state: AppState) => ({ - monitorStatus: selectSelectedMonitor(state), -}); - -export const PageHeader = connect(mapStateToProps, null)(PageHeaderComponent); diff --git a/x-pack/legacy/plugins/uptime/public/routes.tsx b/x-pack/legacy/plugins/uptime/public/routes.tsx index c318a82ab7f199..0f726d89e0d28b 100644 --- a/x-pack/legacy/plugins/uptime/public/routes.tsx +++ b/x-pack/legacy/plugins/uptime/public/routes.tsx @@ -6,26 +6,22 @@ import React, { FC } from 'react'; import { Route, Switch } from 'react-router-dom'; -import { MonitorPage, NotFoundPage, OverviewPage } from './pages'; import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; -import { UMUpdateBreadcrumbs } from './lib/lib'; - -export const MONITOR_ROUTE = '/monitor/:monitorId?'; -export const OVERVIEW_ROUTE = '/'; +import { OverviewPage } from './components/connected/pages/overview_container'; +import { MONITOR_ROUTE, OVERVIEW_ROUTE } from '../common/constants'; +import { MonitorPage, NotFoundPage } from './pages'; interface RouterProps { autocomplete: DataPublicPluginStart['autocomplete']; - basePath: string; - setBreadcrumbs: UMUpdateBreadcrumbs; } -export const PageRouter: FC = ({ autocomplete, basePath, setBreadcrumbs }) => ( +export const PageRouter: FC = ({ autocomplete }) => ( - + - + diff --git a/x-pack/legacy/plugins/uptime/public/uptime_app.tsx b/x-pack/legacy/plugins/uptime/public/uptime_app.tsx index baaed6616b6538..dbde9f8b6a8c04 100644 --- a/x-pack/legacy/plugins/uptime/public/uptime_app.tsx +++ b/x-pack/legacy/plugins/uptime/public/uptime_app.tsx @@ -23,6 +23,7 @@ import { CommonlyUsedRange } from './components/functional/uptime_date_picker'; import { store } from './state'; import { setBasePath } from './state/actions'; import { PageRouter } from './routes'; +import { PageHeader } from './components/connected/pages/page_header_container'; export interface UptimeAppColors { danger: string; @@ -98,12 +99,9 @@ const Application = (props: UptimeAppProps) => {
- + + // @ts-ignore we need to update the type of this prop +
diff --git a/x-pack/legacy/plugins/uptime/server/graphql/monitor_states/resolvers.ts b/x-pack/legacy/plugins/uptime/server/graphql/monitor_states/resolvers.ts index 45b073086d2129..e2b076d5708432 100644 --- a/x-pack/legacy/plugins/uptime/server/graphql/monitor_states/resolvers.ts +++ b/x-pack/legacy/plugins/uptime/server/graphql/monitor_states/resolvers.ts @@ -47,10 +47,10 @@ export const createMonitorStatesResolvers: CreateUMGraphQLResolvers = ( ? JSON.parse(decodeURIComponent(pagination)) : CONTEXT_DEFAULTS.CURSOR_PAGINATION; const [ - totalSummaryCount, + indexStatus, { summaries, nextPagePagination, prevPagePagination }, ] = await Promise.all([ - libs.requests.getDocCount({ callES: APICaller }), + libs.requests.getIndexStatus({ callES: APICaller }), libs.requests.getMonitorStates({ callES: APICaller, dateRangeStart, @@ -63,6 +63,9 @@ export const createMonitorStatesResolvers: CreateUMGraphQLResolvers = ( statusFilter: statusFilter || undefined, }), ]); + + const totalSummaryCount = indexStatus?.docCount ?? { count: undefined }; + return { summaries, nextPagePagination, @@ -71,7 +74,7 @@ export const createMonitorStatesResolvers: CreateUMGraphQLResolvers = ( }; }, async getStatesIndexStatus(_resolver, {}, { APICaller }): Promise { - return await libs.requests.getStatesIndexStatus({ callES: APICaller }); + return await libs.requests.getIndexStatus({ callES: APICaller }); }, }, }; diff --git a/x-pack/legacy/plugins/uptime/server/graphql/pings/resolvers.ts b/x-pack/legacy/plugins/uptime/server/graphql/pings/resolvers.ts index dea7469ab62178..de83a9ced16b2d 100644 --- a/x-pack/legacy/plugins/uptime/server/graphql/pings/resolvers.ts +++ b/x-pack/legacy/plugins/uptime/server/graphql/pings/resolvers.ts @@ -5,7 +5,7 @@ */ import { UMResolver } from '../../../common/graphql/resolver_types'; -import { AllPingsQueryArgs, DocCount, PingResults } from '../../../common/graphql/types'; +import { AllPingsQueryArgs, PingResults } from '../../../common/graphql/types'; import { UMServerLibs } from '../../lib/lib'; import { UMContext } from '../types'; import { CreateUMGraphQLResolvers } from '../types'; @@ -17,11 +17,8 @@ export type UMAllPingsResolver = UMResolver< UMContext >; -export type UMGetDocCountResolver = UMResolver, any, never, UMContext>; - export interface UMPingResolver { allPings: () => PingResults; - getDocCount: () => number; } export const createPingsResolvers: CreateUMGraphQLResolvers = ( @@ -29,7 +26,6 @@ export const createPingsResolvers: CreateUMGraphQLResolvers = ( ): { Query: { allPings: UMAllPingsResolver; - getDocCount: UMGetDocCountResolver; }; } => ({ Query: { @@ -49,8 +45,5 @@ export const createPingsResolvers: CreateUMGraphQLResolvers = ( location, }); }, - async getDocCount(_resolver, _args, { APICaller }): Promise { - return libs.requests.getDocCount({ callES: APICaller }); - }, }, }); diff --git a/x-pack/legacy/plugins/uptime/server/graphql/pings/schema.gql.ts b/x-pack/legacy/plugins/uptime/server/graphql/pings/schema.gql.ts index 37c5400ff9d06e..4b7ccbec374644 100644 --- a/x-pack/legacy/plugins/uptime/server/graphql/pings/schema.gql.ts +++ b/x-pack/legacy/plugins/uptime/server/graphql/pings/schema.gql.ts @@ -38,9 +38,6 @@ export const pingsSchema = gql` "Optional: agent location to filter by." location: String ): PingResults! - - "Gets the number of documents in the target index" - getDocCount: DocCount! } type ContainerImage { diff --git a/x-pack/legacy/plugins/uptime/server/lib/requests/__tests__/get_doc_count.test.ts b/x-pack/legacy/plugins/uptime/server/lib/requests/__tests__/get_doc_count.test.ts deleted file mode 100644 index 7dfb0314fe8ddb..00000000000000 --- a/x-pack/legacy/plugins/uptime/server/lib/requests/__tests__/get_doc_count.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { getDocCount } from '../get_doc_count'; - -describe('getDocCount', () => { - let mockHits: any[]; - let mockEsCountResult: any; - - beforeEach(() => { - mockHits = [ - { - _source: { - '@timestamp': '2018-10-30T18:51:59.792Z', - }, - }, - { - _source: { - '@timestamp': '2018-10-30T18:53:59.792Z', - }, - }, - { - _source: { - '@timestamp': '2018-10-30T18:55:59.792Z', - }, - }, - ]; - mockEsCountResult = { - count: mockHits.length, - }; - }); - - it('returns data in appropriate shape', async () => { - const mockEsClient = jest.fn(); - mockEsClient.mockReturnValue(mockEsCountResult); - const { count } = await getDocCount({ callES: mockEsClient }); - expect(count).toEqual(3); - }); -}); diff --git a/x-pack/legacy/plugins/uptime/server/lib/requests/get_doc_count.ts b/x-pack/legacy/plugins/uptime/server/lib/requests/get_doc_count.ts deleted file mode 100644 index 68c122aaaa9fb4..00000000000000 --- a/x-pack/legacy/plugins/uptime/server/lib/requests/get_doc_count.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { DocCount } from '../../../common/graphql/types'; -import { INDEX_NAMES } from '../../../common/constants'; -import { UMElasticsearchQueryFn } from '../adapters'; - -export const getDocCount: UMElasticsearchQueryFn<{}, DocCount> = async ({ callES }) => { - const { count } = await callES('count', { index: INDEX_NAMES.HEARTBEAT }); - - return { count }; -}; diff --git a/x-pack/legacy/plugins/uptime/server/lib/requests/get_states_index_status.ts b/x-pack/legacy/plugins/uptime/server/lib/requests/get_index_status.ts similarity index 84% rename from x-pack/legacy/plugins/uptime/server/lib/requests/get_states_index_status.ts rename to x-pack/legacy/plugins/uptime/server/lib/requests/get_index_status.ts index 5044b9a6932cf0..e801b05d057f47 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/requests/get_states_index_status.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/requests/get_index_status.ts @@ -8,9 +8,7 @@ import { UMElasticsearchQueryFn } from '../adapters'; import { StatesIndexStatus } from '../../../common/graphql/types'; import { INDEX_NAMES } from '../../../common/constants'; -export const getStatesIndexStatus: UMElasticsearchQueryFn<{}, StatesIndexStatus> = async ({ - callES, -}) => { +export const getIndexStatus: UMElasticsearchQueryFn<{}, StatesIndexStatus> = async ({ callES }) => { const { _shards: { total }, count, diff --git a/x-pack/legacy/plugins/uptime/server/lib/requests/index.ts b/x-pack/legacy/plugins/uptime/server/lib/requests/index.ts index f41b7257524fdd..97517b7faad356 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/requests/index.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/requests/index.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export { getDocCount } from './get_doc_count'; export { getFilterBar, GetFilterBarParams } from './get_filter_bar'; export { getUptimeIndexPattern as getIndexPattern } from './get_index_pattern'; export { getLatestMonitor, GetLatestMonitorParams } from './get_latest_monitor'; @@ -17,4 +16,4 @@ export { getPings, GetPingsParams } from './get_pings'; export { getPingHistogram, GetPingHistogramParams } from './get_ping_histogram'; export { UptimeRequests } from './uptime_requests'; export { getSnapshotCount, GetSnapshotCountParams } from './get_snapshot_counts'; -export { getStatesIndexStatus } from './get_states_index_status'; +export { getIndexStatus } from './get_index_status'; diff --git a/x-pack/legacy/plugins/uptime/server/lib/requests/types.ts b/x-pack/legacy/plugins/uptime/server/lib/requests/types.ts index 7f65e80113d8fe..e17eb546712a94 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/requests/types.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/requests/types.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DocCount, Ping, PingResults } from '../../../common/graphql/types'; +import { Ping, PingResults } from '../../../common/graphql/types'; import { UMElasticsearchQueryFn } from '../adapters'; import { GetPingHistogramParams, HistogramResult } from '../../../common/types'; @@ -54,11 +54,6 @@ export interface UMPingsAdapter { getLatestMonitorStatus: UMElasticsearchQueryFn; getPingHistogram: UMElasticsearchQueryFn; - - /** - * Gets data used for a composite histogram for the currently-running monitors. - */ - getDocCount: UMElasticsearchQueryFn<{}, DocCount>; } export interface HistogramQueryResult { diff --git a/x-pack/legacy/plugins/uptime/server/lib/requests/uptime_requests.ts b/x-pack/legacy/plugins/uptime/server/lib/requests/uptime_requests.ts index 182c944e8388ad..73be850306202e 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/requests/uptime_requests.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/requests/uptime_requests.ts @@ -5,13 +5,7 @@ */ import { UMElasticsearchQueryFn } from '../adapters'; -import { - DocCount, - Ping, - MonitorChart, - PingResults, - StatesIndexStatus, -} from '../../../common/graphql/types'; +import { Ping, MonitorChart, PingResults, StatesIndexStatus } from '../../../common/graphql/types'; import { GetFilterBarParams, GetLatestMonitorParams, @@ -36,7 +30,6 @@ import { HistogramResult } from '../../../common/types'; type ESQ = UMElasticsearchQueryFn; export interface UptimeRequests { - getDocCount: ESQ<{}, DocCount>; getFilterBar: ESQ; getIndexPattern: ESQ; getLatestMonitor: ESQ; @@ -48,5 +41,5 @@ export interface UptimeRequests { getPings: ESQ; getPingHistogram: ESQ; getSnapshotCount: ESQ; - getStatesIndexStatus: ESQ<{}, StatesIndexStatus>; + getIndexStatus: ESQ<{}, StatesIndexStatus>; } diff --git a/x-pack/plugins/canvas/i18n/index.ts b/x-pack/plugins/canvas/i18n/index.ts new file mode 100644 index 00000000000000..8a65a75c0cfb9f --- /dev/null +++ b/x-pack/plugins/canvas/i18n/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from '../../../legacy/plugins/canvas/i18n'; diff --git a/x-pack/plugins/canvas/kibana.json b/x-pack/plugins/canvas/kibana.json index f18e7fe0590bc5..6e12164b61c5ea 100644 --- a/x-pack/plugins/canvas/kibana.json +++ b/x-pack/plugins/canvas/kibana.json @@ -5,6 +5,6 @@ "configPath": ["xpack", "canvas"], "server": true, "ui": false, - "requiredPlugins": [], + "requiredPlugins": ["features", "home"], "optionalPlugins": ["usageCollection"] } diff --git a/x-pack/plugins/canvas/server/plugin.ts b/x-pack/plugins/canvas/server/plugin.ts index 0f27c68903b3df..a94c711b56e05d 100644 --- a/x-pack/plugins/canvas/server/plugin.ts +++ b/x-pack/plugins/canvas/server/plugin.ts @@ -7,11 +7,16 @@ import { first } from 'rxjs/operators'; import { CoreSetup, PluginInitializerContext, Plugin, Logger } from 'src/core/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { HomeServerPluginSetup } from 'src/plugins/home/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { initRoutes } from './routes'; import { registerCanvasUsageCollector } from './collectors'; +import { loadSampleData } from './sample_data'; interface PluginsSetup { usageCollection?: UsageCollectionSetup; + features: FeaturesPluginSetup; + home: HomeServerPluginSetup; } export class CanvasPlugin implements Plugin { @@ -21,10 +26,40 @@ export class CanvasPlugin implements Plugin { } public async setup(coreSetup: CoreSetup, plugins: PluginsSetup) { + plugins.features.registerFeature({ + id: 'canvas', + name: 'Canvas', + icon: 'canvasApp', + navLinkId: 'canvas', + app: ['canvas', 'kibana'], + catalogue: ['canvas'], + privileges: { + all: { + savedObject: { + all: ['canvas-workpad', 'canvas-element'], + read: ['index-pattern'], + }, + ui: ['save', 'show'], + }, + read: { + savedObject: { + all: [], + read: ['index-pattern', 'canvas-workpad', 'canvas-element'], + }, + ui: ['show'], + }, + }, + }); + const canvasRouter = coreSetup.http.createRouter(); initRoutes({ router: canvasRouter, logger: this.logger }); + loadSampleData( + plugins.home.sampleData.addSavedObjectsToSampleDataset, + plugins.home.sampleData.addAppLinksToSampleDataset + ); + // we need the kibana index provided by global config for the Canvas usage collector const globalConfig = await this.initializerContext.config.legacy.globalConfig$ .pipe(first()) diff --git a/x-pack/legacy/plugins/canvas/server/sample_data/ecommerce_saved_objects.json b/x-pack/plugins/canvas/server/sample_data/ecommerce_saved_objects.json similarity index 100% rename from x-pack/legacy/plugins/canvas/server/sample_data/ecommerce_saved_objects.json rename to x-pack/plugins/canvas/server/sample_data/ecommerce_saved_objects.json diff --git a/x-pack/legacy/plugins/canvas/server/sample_data/flights_saved_objects.json b/x-pack/plugins/canvas/server/sample_data/flights_saved_objects.json similarity index 100% rename from x-pack/legacy/plugins/canvas/server/sample_data/flights_saved_objects.json rename to x-pack/plugins/canvas/server/sample_data/flights_saved_objects.json diff --git a/x-pack/legacy/plugins/canvas/server/sample_data/index.ts b/x-pack/plugins/canvas/server/sample_data/index.ts similarity index 100% rename from x-pack/legacy/plugins/canvas/server/sample_data/index.ts rename to x-pack/plugins/canvas/server/sample_data/index.ts diff --git a/x-pack/legacy/plugins/canvas/server/sample_data/load_sample_data.ts b/x-pack/plugins/canvas/server/sample_data/load_sample_data.ts similarity index 95% rename from x-pack/legacy/plugins/canvas/server/sample_data/load_sample_data.ts rename to x-pack/plugins/canvas/server/sample_data/load_sample_data.ts index ed505c09cc7a40..6eda02ef417227 100644 --- a/x-pack/legacy/plugins/canvas/server/sample_data/load_sample_data.ts +++ b/x-pack/plugins/canvas/server/sample_data/load_sample_data.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +import { SampleDataRegistrySetup } from 'src/plugins/home/server'; import { CANVAS as label } from '../../i18n'; // @ts-ignore Untyped local import { ecommerceSavedObjects, flightsSavedObjects, webLogsSavedObjects } from './index'; -import { SampleDataRegistrySetup } from '../../../../../../src/plugins/home/server'; export function loadSampleData( addSavedObjectsToSampleDataset: SampleDataRegistrySetup['addSavedObjectsToSampleDataset'], diff --git a/x-pack/legacy/plugins/canvas/server/sample_data/web_logs_saved_objects.json b/x-pack/plugins/canvas/server/sample_data/web_logs_saved_objects.json similarity index 100% rename from x-pack/legacy/plugins/canvas/server/sample_data/web_logs_saved_objects.json rename to x-pack/plugins/canvas/server/sample_data/web_logs_saved_objects.json diff --git a/x-pack/plugins/task_manager/server/task_runner.ts b/x-pack/plugins/task_manager/server/task_runner.ts index 56ab49bdc629ea..682885aaa0b1ca 100644 --- a/x-pack/plugins/task_manager/server/task_runner.ts +++ b/x-pack/plugins/task_manager/server/task_runner.ts @@ -10,6 +10,7 @@ * rescheduling, middleware application, etc. */ +import apm from 'elastic-apm-node'; import { performance } from 'perf_hooks'; import Joi from 'joi'; import { identity, defaults, flow } from 'lodash'; @@ -156,15 +157,21 @@ export class TaskManagerRunner implements TaskRunner { taskInstance: this.instance, }); + const apmTrans = apm.startTransaction( + `taskManager run ${this.instance.taskType}`, + 'taskManager' + ); try { this.task = this.definition.createTaskRunner(modifiedContext); const result = await this.task.run(); const validatedResult = this.validateResult(result); + if (apmTrans) apmTrans.end('success'); return this.processResult(validatedResult); } catch (err) { this.logger.error(`Task ${this} failed: ${err}`); // in error scenario, we can not get the RunResult // re-use modifiedContext's state, which is correct as of beforeRun + if (apmTrans) apmTrans.end('error'); return this.processResult(asErr({ error: err, state: modifiedContext.taskInstance.state })); } } @@ -178,6 +185,11 @@ export class TaskManagerRunner implements TaskRunner { public async markTaskAsRunning(): Promise { performance.mark('markTaskAsRunning_start'); + const apmTrans = apm.startTransaction( + `taskManager markTaskAsRunning ${this.instance.taskType}`, + 'taskManager' + ); + const VERSION_CONFLICT_STATUS = 409; const now = new Date(); @@ -227,10 +239,12 @@ export class TaskManagerRunner implements TaskRunner { ); } + if (apmTrans) apmTrans.end('success'); performanceStopMarkingTaskAsRunning(); this.onTaskEvent(asTaskMarkRunningEvent(this.id, asOk(this.instance))); return true; } catch (error) { + if (apmTrans) apmTrans.end('failure'); performanceStopMarkingTaskAsRunning(); this.onTaskEvent(asTaskMarkRunningEvent(this.id, asErr(error))); if (error.statusCode !== VERSION_CONFLICT_STATUS) { diff --git a/x-pack/plugins/task_manager/server/task_store.ts b/x-pack/plugins/task_manager/server/task_store.ts index 4f2e97704941f3..3915eeffc55197 100644 --- a/x-pack/plugins/task_manager/server/task_store.ts +++ b/x-pack/plugins/task_manager/server/task_store.ts @@ -7,6 +7,7 @@ /* * This module contains helpers for managing the task manager storage layer. */ +import apm from 'elastic-apm-node'; import { Subject, Observable } from 'rxjs'; import { omit, difference } from 'lodash'; @@ -252,6 +253,7 @@ export class TaskStore { ) ); + const apmTrans = apm.startTransaction(`taskManager markAvailableTasksAsClaimed`, 'taskManager'); const { updated } = await this.updateByQuery( asUpdateByQuery({ query: matchesClauses( @@ -279,6 +281,7 @@ export class TaskStore { } ); + if (apmTrans) apmTrans.end(); return updated; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts index fc3fa3651f029b..1664962f896241 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts @@ -16,12 +16,15 @@ import { enableAlert, loadAlert, loadAlerts, + loadAlertState, loadAlertTypes, muteAlerts, unmuteAlerts, muteAlert, unmuteAlert, updateAlert, + muteAlertInstance, + unmuteAlertInstance, } from './alert_api'; import uuid from 'uuid'; @@ -76,6 +79,70 @@ describe('loadAlert', () => { }); }); +describe('loadAlertState', () => { + test('should call get API with base parameters', async () => { + const alertId = uuid.v4(); + const resolvedValue = { + alertTypeState: { + some: 'value', + }, + alertInstances: { + first_instance: {}, + second_instance: {}, + }, + }; + http.get.mockResolvedValueOnce(resolvedValue); + + expect(await loadAlertState({ http, alertId })).toEqual(resolvedValue); + expect(http.get).toHaveBeenCalledWith(`/api/alert/${alertId}/state`); + }); + + test('should parse AlertInstances', async () => { + const alertId = uuid.v4(); + const resolvedValue = { + alertTypeState: { + some: 'value', + }, + alertInstances: { + first_instance: { + state: {}, + meta: { + lastScheduledActions: { + group: 'first_group', + date: '2020-02-09T23:15:41.941Z', + }, + }, + }, + }, + }; + http.get.mockResolvedValueOnce(resolvedValue); + + expect(await loadAlertState({ http, alertId })).toEqual({ + ...resolvedValue, + alertInstances: { + first_instance: { + state: {}, + meta: { + lastScheduledActions: { + group: 'first_group', + date: new Date('2020-02-09T23:15:41.941Z'), + }, + }, + }, + }, + }); + expect(http.get).toHaveBeenCalledWith(`/api/alert/${alertId}/state`); + }); + + test('should handle empty response from api', async () => { + const alertId = uuid.v4(); + http.get.mockResolvedValueOnce(''); + + expect(await loadAlertState({ http, alertId })).toEqual({}); + expect(http.get).toHaveBeenCalledWith(`/api/alert/${alertId}/state`); + }); +}); + describe('loadAlerts', () => { test('should call find API with base parameters', async () => { const resolvedValue = { @@ -410,6 +477,34 @@ describe('disableAlert', () => { }); }); +describe('muteAlertInstance', () => { + test('should call mute instance alert API', async () => { + const result = await muteAlertInstance({ http, id: '1', instanceId: '123' }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alert/1/alert_instance/123/_mute", + ], + ] + `); + }); +}); + +describe('unmuteAlertInstance', () => { + test('should call mute instance alert API', async () => { + const result = await unmuteAlertInstance({ http, id: '1', instanceId: '123' }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alert/1/alert_instance/123/_unmute", + ], + ] + `); + }); +}); + describe('muteAlert', () => { test('should call mute alert API', async () => { const result = await muteAlert({ http, id: '1' }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts index acc318bd5fbea5..22fd01c1aee819 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts @@ -5,8 +5,12 @@ */ import { HttpSetup } from 'kibana/public'; +import * as t from 'io-ts'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; import { BASE_ALERT_API_PATH } from '../constants'; -import { Alert, AlertType, AlertWithoutId } from '../../types'; +import { Alert, AlertType, AlertWithoutId, AlertTaskState } from '../../types'; +import { alertStateSchema } from '../../../../../legacy/plugins/alerting/common'; export async function loadAlertTypes({ http }: { http: HttpSetup }): Promise { return await http.get(`${BASE_ALERT_API_PATH}/types`); @@ -22,6 +26,27 @@ export async function loadAlert({ return await http.get(`${BASE_ALERT_API_PATH}/${alertId}`); } +type EmptyHttpResponse = ''; +export async function loadAlertState({ + http, + alertId, +}: { + http: HttpSetup; + alertId: string; +}): Promise { + return await http + .get(`${BASE_ALERT_API_PATH}/${alertId}/state`) + .then((state: AlertTaskState | EmptyHttpResponse) => (state ? state : {})) + .then((state: AlertTaskState) => { + return pipe( + alertStateSchema.decode(state), + fold((e: t.Errors) => { + throw new Error(`Alert "${alertId}" has invalid state`); + }, t.identity) + ); + }); +} + export async function loadAlerts({ http, page, @@ -133,6 +158,30 @@ export async function disableAlerts({ await Promise.all(ids.map(id => disableAlert({ id, http }))); } +export async function muteAlertInstance({ + id, + instanceId, + http, +}: { + id: string; + instanceId: string; + http: HttpSetup; +}): Promise { + await http.post(`${BASE_ALERT_API_PATH}/${id}/alert_instance/${instanceId}/_mute`); +} + +export async function unmuteAlertInstance({ + id, + instanceId, + http, +}: { + id: string; + instanceId: string; + http: HttpSetup; +}): Promise { + await http.post(`${BASE_ALERT_API_PATH}/${id}/alert_instance/${instanceId}/_unmute`); +} + export async function muteAlert({ id, http }: { id: string; http: HttpSetup }): Promise { await http.post(`${BASE_ALERT_API_PATH}/${id}/_mute_all`); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx index 660c7afac28652..70654d1a9f5a46 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx @@ -32,6 +32,7 @@ const mockAlertApis = { unmuteAlert: jest.fn(), enableAlert: jest.fn(), disableAlert: jest.fn(), + requestRefresh: jest.fn(), }; // const AlertDetails = withBulkAlertOperations(RawAlertDetails); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx index ffdf846efd49d5..9c3b69962879f3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx @@ -19,6 +19,8 @@ import { EuiPageContentBody, EuiButtonEmpty, EuiSwitch, + EuiCallOut, + EuiSpacer, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { useAppDependencies } from '../../../app_context'; @@ -28,11 +30,13 @@ import { ComponentOpts as BulkOperationsComponentOpts, withBulkAlertOperations, } from '../../common/components/with_bulk_alert_api_operations'; +import { AlertInstancesRouteWithApi } from './alert_instances_route'; type AlertDetailsProps = { alert: Alert; alertType: AlertType; actionTypes: ActionType[]; + requestRefresh: () => Promise; } & Pick; export const AlertDetails: React.FunctionComponent = ({ @@ -43,6 +47,7 @@ export const AlertDetails: React.FunctionComponent = ({ enableAlert, unmuteAlert, muteAlert, + requestRefresh, }) => { const { capabilities } = useAppDependencies(); @@ -131,10 +136,11 @@ export const AlertDetails: React.FunctionComponent = ({ setIsEnabled(true); await enableAlert(alert); } + requestRefresh(); }} label={ } @@ -154,10 +160,11 @@ export const AlertDetails: React.FunctionComponent = ({ setIsMuted(true); await muteAlert(alert); } + requestRefresh(); }} label={ } @@ -166,6 +173,23 @@ export const AlertDetails: React.FunctionComponent = ({
+ + + + {alert.enabled ? ( + + ) : ( + +

+ +

+
+ )} +
+
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx index 4e00ea304d987d..9198607df7863e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx @@ -41,7 +41,7 @@ export const AlertDetailsRoute: React.FunctionComponent const [alert, setAlert] = useState(null); const [alertType, setAlertType] = useState(null); const [actionTypes, setActionTypes] = useState(null); - + const [refreshToken, requestRefresh] = React.useState(); useEffect(() => { getAlertData( alertId, @@ -53,10 +53,15 @@ export const AlertDetailsRoute: React.FunctionComponent setActionTypes, toastNotifications ); - }, [alertId, http, loadActionTypes, loadAlert, loadAlertTypes, toastNotifications]); + }, [alertId, http, loadActionTypes, loadAlert, loadAlertTypes, toastNotifications, refreshToken]); return alert && alertType && actionTypes ? ( - + requestRefresh(Date.now())} + /> ) : (
{ + jest.resetAllMocks(); + global.Date.now = jest.fn(() => fakeNow.getTime()); +}); + +jest.mock('../../../app_context', () => { + const toastNotifications = jest.fn(); + return { + useAppDependencies: jest.fn(() => ({ toastNotifications })), + }; +}); + +describe('alert_instances', () => { + it('render a list of alert instances', () => { + const alert = mockAlert(); + + const alertState = mockAlertState(); + const instances: AlertInstanceListItem[] = [ + alertInstanceToListItem(alert, 'first_instance', alertState.alertInstances!.first_instance), + alertInstanceToListItem(alert, 'second_instance', alertState.alertInstances!.second_instance), + ]; + + expect( + shallow() + .find(EuiBasicTable) + .prop('items') + ).toEqual(instances); + }); + + it('render all active alert instances', () => { + const alert = mockAlert(); + const instances = { + ['us-central']: { + state: {}, + meta: { + lastScheduledActions: { + group: 'warning', + date: fake2MinutesAgo, + }, + }, + }, + ['us-east']: {}, + }; + expect( + shallow( + + ) + .find(EuiBasicTable) + .prop('items') + ).toEqual([ + alertInstanceToListItem(alert, 'us-central', instances['us-central']), + alertInstanceToListItem(alert, 'us-east', instances['us-east']), + ]); + }); + + it('render all inactive alert instances', () => { + const alert = mockAlert({ + mutedInstanceIds: ['us-west', 'us-east'], + }); + + expect( + shallow( + + ) + .find(EuiBasicTable) + .prop('items') + ).toEqual([ + alertInstanceToListItem(alert, 'us-west'), + alertInstanceToListItem(alert, 'us-east'), + ]); + }); +}); + +describe('alertInstanceToListItem', () => { + it('handles active instances', () => { + const alert = mockAlert(); + const start = fake2MinutesAgo; + const instance: RawAlertInstance = { + meta: { + lastScheduledActions: { + date: start, + group: 'default', + }, + }, + }; + + expect(alertInstanceToListItem(alert, 'id', instance)).toEqual({ + instance: 'id', + status: { label: 'Active', healthColor: 'primary' }, + start, + duration: fakeNow.getTime() - fake2MinutesAgo.getTime(), + isMuted: false, + }); + }); + + it('handles active muted instances', () => { + const alert = mockAlert({ + mutedInstanceIds: ['id'], + }); + const start = fake2MinutesAgo; + const instance: RawAlertInstance = { + meta: { + lastScheduledActions: { + date: start, + group: 'default', + }, + }, + }; + + expect(alertInstanceToListItem(alert, 'id', instance)).toEqual({ + instance: 'id', + status: { label: 'Active', healthColor: 'primary' }, + start, + duration: fakeNow.getTime() - fake2MinutesAgo.getTime(), + isMuted: true, + }); + }); + + it('handles active instances with no meta', () => { + const alert = mockAlert(); + const instance: RawAlertInstance = {}; + + expect(alertInstanceToListItem(alert, 'id', instance)).toEqual({ + instance: 'id', + status: { label: 'Active', healthColor: 'primary' }, + start: undefined, + duration: 0, + isMuted: false, + }); + }); + + it('handles active instances with no lastScheduledActions', () => { + const alert = mockAlert(); + const instance: RawAlertInstance = { + meta: {}, + }; + + expect(alertInstanceToListItem(alert, 'id', instance)).toEqual({ + instance: 'id', + status: { label: 'Active', healthColor: 'primary' }, + start: undefined, + duration: 0, + isMuted: false, + }); + }); + + it('handles muted inactive instances', () => { + const alert = mockAlert({ + mutedInstanceIds: ['id'], + }); + expect(alertInstanceToListItem(alert, 'id')).toEqual({ + instance: 'id', + status: { label: 'Inactive', healthColor: 'subdued' }, + start: undefined, + duration: 0, + isMuted: true, + }); + }); +}); + +function mockAlert(overloads: Partial = {}): Alert { + return { + id: uuid.v4(), + enabled: true, + name: `alert-${uuid.v4()}`, + tags: [], + alertTypeId: '.noop', + consumer: 'consumer', + schedule: { interval: '1m' }, + actions: [], + params: {}, + createdBy: null, + updatedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + apiKeyOwner: null, + throttle: null, + muteAll: false, + mutedInstanceIds: [], + ...overloads, + }; +} + +function mockAlertState(overloads: Partial = {}): AlertTaskState { + return { + alertTypeState: { + some: 'value', + }, + alertInstances: { + first_instance: { + state: {}, + meta: { + lastScheduledActions: { + group: 'first_group', + date: new Date(), + }, + }, + }, + second_instance: {}, + }, + ...overloads, + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.tsx new file mode 100644 index 00000000000000..1f0e4f015f229d --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.tsx @@ -0,0 +1,206 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import moment, { Duration } from 'moment'; +import { i18n } from '@kbn/i18n'; +import { EuiBasicTable, EuiButtonToggle, EuiBadge, EuiHealth } from '@elastic/eui'; +// @ts-ignore +import { RIGHT_ALIGNMENT, CENTER_ALIGNMENT } from '@elastic/eui/lib/services'; +import { padLeft, difference } from 'lodash'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { RawAlertInstance } from '../../../../../../../legacy/plugins/alerting/common'; +import { Alert, AlertTaskState } from '../../../../types'; +import { + ComponentOpts as AlertApis, + withBulkAlertOperations, +} from '../../common/components/with_bulk_alert_api_operations'; + +type AlertInstancesProps = { + alert: Alert; + alertState: AlertTaskState; + requestRefresh: () => Promise; +} & Pick; + +export const alertInstancesTableColumns = ( + onMuteAction: (instance: AlertInstanceListItem) => Promise +) => [ + { + field: 'instance', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.columns.instance', + { defaultMessage: 'Instance' } + ), + sortable: false, + truncateText: true, + 'data-test-subj': 'alertInstancesTableCell-instance', + }, + { + field: 'status', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.columns.status', + { defaultMessage: 'Status' } + ), + render: (value: AlertInstanceListItemStatus, instance: AlertInstanceListItem) => { + return {value.label}; + }, + sortable: false, + 'data-test-subj': 'alertInstancesTableCell-status', + }, + { + field: 'start', + render: (value: Date | undefined, instance: AlertInstanceListItem) => { + return value ? moment(value).format('D MMM YYYY @ HH:mm:ss') : ''; + }, + name: i18n.translate( + 'xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.columns.start', + { defaultMessage: 'Start' } + ), + sortable: false, + 'data-test-subj': 'alertInstancesTableCell-start', + }, + { + field: 'duration', + align: CENTER_ALIGNMENT, + render: (value: number, instance: AlertInstanceListItem) => { + return value ? durationAsString(moment.duration(value)) : ''; + }, + name: i18n.translate( + 'xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.columns.duration', + { defaultMessage: 'Duration' } + ), + sortable: false, + 'data-test-subj': 'alertInstancesTableCell-duration', + }, + { + field: '', + align: RIGHT_ALIGNMENT, + name: i18n.translate( + 'xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.columns.actions', + { defaultMessage: 'Actions' } + ), + render: (alertInstance: AlertInstanceListItem) => { + return ( + + {alertInstance.isMuted ? ( + + + + ) : ( + + )} + onMuteAction(alertInstance)} + isSelected={alertInstance.isMuted} + isEmpty + isIconOnly + /> + + ); + }, + sortable: false, + 'data-test-subj': 'alertInstancesTableCell-actions', + }, +]; + +function durationAsString(duration: Duration): string { + return [duration.hours(), duration.minutes(), duration.seconds()] + .map(value => padLeft(`${value}`, 2, '0')) + .join(':'); +} + +export function AlertInstances({ + alert, + alertState: { alertInstances = {} }, + muteAlertInstance, + unmuteAlertInstance, + requestRefresh, +}: AlertInstancesProps) { + const onMuteAction = async (instance: AlertInstanceListItem) => { + await (instance.isMuted + ? unmuteAlertInstance(alert, instance.instance) + : muteAlertInstance(alert, instance.instance)); + requestRefresh(); + }; + return ( + + alertInstanceToListItem(alert, instanceId, instance) + ), + ...difference(alert.mutedInstanceIds, Object.keys(alertInstances)).map(instanceId => + alertInstanceToListItem(alert, instanceId) + ), + ]} + rowProps={() => ({ + 'data-test-subj': 'alert-instance-row', + })} + cellProps={() => ({ + 'data-test-subj': 'cell', + })} + columns={alertInstancesTableColumns(onMuteAction)} + data-test-subj="alertInstancesList" + /> + ); +} +export const AlertInstancesWithApi = withBulkAlertOperations(AlertInstances); + +interface AlertInstanceListItemStatus { + label: string; + healthColor: string; +} +export interface AlertInstanceListItem { + instance: string; + status: AlertInstanceListItemStatus; + start?: Date; + duration: number; + isMuted: boolean; +} + +const ACTIVE_LABEL = i18n.translate( + 'xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.status.active', + { defaultMessage: 'Active' } +); + +const INACTIVE_LABEL = i18n.translate( + 'xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.status.inactive', + { defaultMessage: 'Inactive' } +); + +const durationSince = (start?: Date) => (start ? Date.now() - start.getTime() : 0); + +export function alertInstanceToListItem( + alert: Alert, + instanceId: string, + instance?: RawAlertInstance +): AlertInstanceListItem { + const isMuted = alert.mutedInstanceIds.findIndex(muted => muted === instanceId) >= 0; + return { + instance: instanceId, + status: instance + ? { label: ACTIVE_LABEL, healthColor: 'primary' } + : { label: INACTIVE_LABEL, healthColor: 'subdued' }, + start: instance?.meta?.lastScheduledActions?.date, + duration: durationSince(instance?.meta?.lastScheduledActions?.date), + isMuted, + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx new file mode 100644 index 00000000000000..9bff33e4aa69ce --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as React from 'react'; +import uuid from 'uuid'; +import { shallow } from 'enzyme'; +import { ToastsApi } from 'kibana/public'; +import { AlertInstancesRoute, getAlertState } from './alert_instances_route'; +import { Alert } from '../../../../types'; +import { EuiLoadingSpinner } from '@elastic/eui'; + +jest.mock('../../../app_context', () => { + const toastNotifications = jest.fn(); + return { + useAppDependencies: jest.fn(() => ({ toastNotifications })), + }; +}); +describe('alert_state_route', () => { + it('render a loader while fetching data', () => { + const alert = mockAlert(); + + expect( + shallow().containsMatchingElement( + + ) + ).toBeTruthy(); + }); +}); + +describe('getAlertState useEffect handler', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('fetches alert state', async () => { + const alert = mockAlert(); + const alertState = mockAlertState(); + const { loadAlertState } = mockApis(); + const { setAlertState } = mockStateSetter(); + + loadAlertState.mockImplementationOnce(async () => alertState); + + const toastNotifications = ({ + addDanger: jest.fn(), + } as unknown) as ToastsApi; + + await getAlertState(alert.id, loadAlertState, setAlertState, toastNotifications); + + expect(loadAlertState).toHaveBeenCalledWith(alert.id); + expect(setAlertState).toHaveBeenCalledWith(alertState); + }); + + it('displays an error if the alert state isnt found', async () => { + const actionType = { + id: '.server-log', + name: 'Server log', + enabled: true, + }; + const alert = mockAlert({ + actions: [ + { + group: '', + id: uuid.v4(), + actionTypeId: actionType.id, + params: {}, + }, + ], + }); + + const { loadAlertState } = mockApis(); + const { setAlertState } = mockStateSetter(); + + loadAlertState.mockImplementation(async () => { + throw new Error('OMG'); + }); + + const toastNotifications = ({ + addDanger: jest.fn(), + } as unknown) as ToastsApi; + await getAlertState(alert.id, loadAlertState, setAlertState, toastNotifications); + expect(toastNotifications.addDanger).toHaveBeenCalledTimes(1); + expect(toastNotifications.addDanger).toHaveBeenCalledWith({ + title: 'Unable to load alert state: OMG', + }); + }); +}); + +function mockApis() { + return { + loadAlertState: jest.fn(), + requestRefresh: jest.fn(), + }; +} + +function mockStateSetter() { + return { + setAlertState: jest.fn(), + }; +} + +function mockAlert(overloads: Partial = {}): Alert { + return { + id: uuid.v4(), + enabled: true, + name: `alert-${uuid.v4()}`, + tags: [], + alertTypeId: '.noop', + consumer: 'consumer', + schedule: { interval: '1m' }, + actions: [], + params: {}, + createdBy: null, + updatedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + apiKeyOwner: null, + throttle: null, + muteAll: false, + mutedInstanceIds: [], + ...overloads, + }; +} + +function mockAlertState(overloads: Partial = {}): any { + return { + alertTypeState: { + some: 'value', + }, + alertInstances: { + first_instance: { + state: {}, + meta: { + lastScheduledActions: { + group: 'first_group', + date: new Date(), + }, + }, + }, + second_instance: {}, + }, + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx new file mode 100644 index 00000000000000..498ecffe9b947d --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { ToastsApi } from 'kibana/public'; +import React, { useState, useEffect } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { Alert, AlertTaskState } from '../../../../types'; +import { useAppDependencies } from '../../../app_context'; +import { + ComponentOpts as AlertApis, + withBulkAlertOperations, +} from '../../common/components/with_bulk_alert_api_operations'; +import { AlertInstancesWithApi as AlertInstances } from './alert_instances'; + +type WithAlertStateProps = { + alert: Alert; + requestRefresh: () => Promise; +} & Pick; + +export const AlertInstancesRoute: React.FunctionComponent = ({ + alert, + requestRefresh, + loadAlertState, +}) => { + const { http, toastNotifications } = useAppDependencies(); + + const [alertState, setAlertState] = useState(null); + + useEffect(() => { + getAlertState(alert.id, loadAlertState, setAlertState, toastNotifications); + }, [alert, http, loadAlertState, toastNotifications]); + + return alertState ? ( + + ) : ( +
+ +
+ ); +}; + +export async function getAlertState( + alertId: string, + loadAlertState: AlertApis['loadAlertState'], + setAlertState: React.Dispatch>, + toastNotifications: Pick +) { + try { + const loadedState = await loadAlertState(alertId); + setAlertState(loadedState); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertDetails.unableToLoadAlertStateMessage', + { + defaultMessage: 'Unable to load alert state: {message}', + values: { + message: e.message, + }, + } + ), + }); + } +} + +export const AlertInstancesRouteWithApi = withBulkAlertOperations(AlertInstancesRoute); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx index c61ba631ab8685..4b348b85fe5bc4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx @@ -6,7 +6,7 @@ import React from 'react'; -import { Alert, AlertType } from '../../../../types'; +import { Alert, AlertType, AlertTaskState } from '../../../../types'; import { useAppDependencies } from '../../../app_context'; import { deleteAlerts, @@ -19,7 +19,10 @@ import { enableAlert, muteAlert, unmuteAlert, + muteAlertInstance, + unmuteAlertInstance, loadAlert, + loadAlertState, loadAlertTypes, } from '../../../lib/alert_api'; @@ -31,10 +34,13 @@ export interface ComponentOpts { deleteAlerts: (alerts: Alert[]) => Promise; muteAlert: (alert: Alert) => Promise; unmuteAlert: (alert: Alert) => Promise; + muteAlertInstance: (alert: Alert, alertInstanceId: string) => Promise; + unmuteAlertInstance: (alert: Alert, alertInstanceId: string) => Promise; enableAlert: (alert: Alert) => Promise; disableAlert: (alert: Alert) => Promise; deleteAlert: (alert: Alert) => Promise; loadAlert: (id: Alert['id']) => Promise; + loadAlertState: (id: Alert['id']) => Promise; loadAlertTypes: () => Promise; } @@ -76,6 +82,16 @@ export function withBulkAlertOperations( return unmuteAlert({ http, id: alert.id }); } }} + muteAlertInstance={async (alert: Alert, instanceId: string) => { + if (!isAlertInstanceMuted(alert, instanceId)) { + return muteAlertInstance({ http, id: alert.id, instanceId }); + } + }} + unmuteAlertInstance={async (alert: Alert, instanceId: string) => { + if (isAlertInstanceMuted(alert, instanceId)) { + return unmuteAlertInstance({ http, id: alert.id, instanceId }); + } + }} enableAlert={async (alert: Alert) => { if (isAlertDisabled(alert)) { return enableAlert({ http, id: alert.id }); @@ -88,6 +104,7 @@ export function withBulkAlertOperations( }} deleteAlert={async (alert: Alert) => deleteAlert({ http, id: alert.id })} loadAlert={async (alertId: Alert['id']) => loadAlert({ http, alertId })} + loadAlertState={async (alertId: Alert['id']) => loadAlertState({ http, alertId })} loadAlertTypes={async () => loadAlertTypes({ http })} /> ); @@ -101,3 +118,7 @@ function isAlertDisabled(alert: Alert) { function isAlertMuted(alert: Alert) { return alert.muteAll === true; } + +function isAlertInstanceMuted(alert: Alert, instanceId: string) { + return alert.mutedInstanceIds.findIndex(muted => muted === instanceId) >= 0; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 10e18f3f423bd9..694efa864a6795 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -5,8 +5,13 @@ */ import { ActionType } from '../../actions/common'; import { TypeRegistry } from './application/type_registry'; -import { SanitizedAlert as Alert, AlertAction } from '../../../legacy/plugins/alerting/common'; -export { Alert, AlertAction }; +import { + SanitizedAlert as Alert, + AlertAction, + AlertTaskState, + RawAlertInstance, +} from '../../../legacy/plugins/alerting/common'; +export { Alert, AlertAction, AlertTaskState, RawAlertInstance }; export { ActionType }; export type ActionTypeIndex = Record; diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 11ee038cf39f05..c1f8047c8a5cc8 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -15,6 +15,7 @@ const onlyNotInCoverageTests = [ require.resolve('../test/api_integration/config.js'), require.resolve('../test/alerting_api_integration/spaces_only/config.ts'), require.resolve('../test/alerting_api_integration/security_and_spaces/config.ts'), + require.resolve('../test/detection_engine_api_integration/security_and_spaces/config.ts'), require.resolve('../test/plugin_api_integration/config.js'), require.resolve('../test/plugin_functional/config.ts'), require.resolve('../test/kerberos_api_integration/config.ts'), diff --git a/x-pack/test/detection_engine_api_integration/common/config.ts b/x-pack/test/detection_engine_api_integration/common/config.ts new file mode 100644 index 00000000000000..bf8e6982b545df --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/common/config.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import path from 'path'; +import { CA_CERT_PATH } from '@kbn/dev-utils'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +import { services } from './services'; + +interface CreateTestConfigOptions { + license: string; + disabledPlugins?: string[]; + ssl?: boolean; +} + +// test.not-enabled is specifically not enabled +const enabledActionTypes = [ + '.email', + '.index', + '.pagerduty', + '.server-log', + '.servicenow', + '.slack', + '.webhook', + 'test.authorization', + 'test.failing', + 'test.index-record', + 'test.noop', + 'test.rate-limit', +]; + +// eslint-disable-next-line import/no-default-export +export function createTestConfig(name: string, options: CreateTestConfigOptions) { + const { license = 'trial', disabledPlugins = [], ssl = false } = options; + + return async ({ readConfigFile }: FtrConfigProviderContext) => { + const xPackApiIntegrationTestsConfig = await readConfigFile( + require.resolve('../../api_integration/config.js') + ); + const servers = { + ...xPackApiIntegrationTestsConfig.get('servers'), + elasticsearch: { + ...xPackApiIntegrationTestsConfig.get('servers.elasticsearch'), + protocol: ssl ? 'https' : 'http', + }, + }; + + return { + testFiles: [require.resolve(`../${name}/tests/`)], + servers, + services, + junit: { + reportName: 'X-Pack Detection Engine API Integration Tests', + }, + esArchiver: xPackApiIntegrationTestsConfig.get('esArchiver'), + esTestCluster: { + ...xPackApiIntegrationTestsConfig.get('esTestCluster'), + license, + ssl, + serverArgs: [ + `xpack.license.self_generated.type=${license}`, + `xpack.security.enabled=${!disabledPlugins.includes('security') && license === 'trial'}`, + ], + }, + kbnTestServer: { + ...xPackApiIntegrationTestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xPackApiIntegrationTestsConfig.get('kbnTestServer.serverArgs'), + `--xpack.actions.whitelistedHosts=${JSON.stringify([ + 'localhost', + 'some.non.existent.com', + ])}`, + `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, + '--xpack.alerting.enabled=true', + '--xpack.event_log.logEntries=true', + ...disabledPlugins.map(key => `--xpack.${key}.enabled=false`), + `--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'alerts')}`, + `--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'actions')}`, + `--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'task_manager')}`, + `--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'aad')}`, + ...(ssl + ? [ + `--elasticsearch.hosts=${servers.elasticsearch.protocol}://${servers.elasticsearch.hostname}:${servers.elasticsearch.port}`, + `--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`, + ] + : []), + ], + }, + }; + }; +} diff --git a/x-pack/test/detection_engine_api_integration/common/ftr_provider_context.d.ts b/x-pack/test/detection_engine_api_integration/common/ftr_provider_context.d.ts new file mode 100644 index 00000000000000..e3add3748f56d7 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/common/ftr_provider_context.d.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; + +import { services } from './services'; + +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/detection_engine_api_integration/common/services.ts b/x-pack/test/detection_engine_api_integration/common/services.ts new file mode 100644 index 00000000000000..a927a31469bab1 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/common/services.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { services } from '../../api_integration/services'; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/config.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/config.ts new file mode 100644 index 00000000000000..081b901c47fc3c --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/config.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createTestConfig } from '../common/config'; + +// eslint-disable-next-line import/no-default-export +export default createTestConfig('security_and_spaces', { + disabledPlugins: [], + license: 'trial', + ssl: true, +}); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_prepackaged_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_prepackaged_rules.ts new file mode 100644 index 00000000000000..5e09013fb32a3e --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_prepackaged_rules.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { DETECTION_ENGINE_PREPACKAGED_URL } from '../../../../legacy/plugins/siem/common/constants'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { createSignalsIndex, deleteAllAlerts, deleteSignalsIndex } from './utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('legacyEs'); + + describe('add_prepackaged_rules', () => { + describe('validation errors', () => { + it('should give an error that the index must exist first if it does not exist before adding prepackaged rules', async () => { + const { body } = await supertest + .put(DETECTION_ENGINE_PREPACKAGED_URL) + .set('kbn-xsrf', 'true') + .send() + .expect(400); + + expect(body).to.eql({ + message: + 'Pre-packaged rules cannot be installed until the space index is created: .siem-signals-default', + status_code: 400, + }); + }); + }); + + describe('creating prepackaged rules', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(es); + }); + + it('should contain two output keys of rules_installed and rules_updated', async () => { + const { body } = await supertest + .put(DETECTION_ENGINE_PREPACKAGED_URL) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(Object.keys(body)).to.eql(['rules_installed', 'rules_updated']); + }); + + it('should create the prepackaged rules and return a count greater than zero', async () => { + const { body } = await supertest + .put(DETECTION_ENGINE_PREPACKAGED_URL) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(body.rules_installed).to.be.greaterThan(0); + }); + + it('should create the prepackaged rules that the rules_updated is of size zero', async () => { + const { body } = await supertest + .put(DETECTION_ENGINE_PREPACKAGED_URL) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(body.rules_updated).to.eql(0); + }); + + it('should be possible to call the API twice and the second time the number of rules installed should be zero', async () => { + await supertest + .put(DETECTION_ENGINE_PREPACKAGED_URL) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + const { body } = await supertest + .put(DETECTION_ENGINE_PREPACKAGED_URL) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(body.rules_installed).to.eql(0); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts new file mode 100644 index 00000000000000..d6a238e5b09401 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { DETECTION_ENGINE_RULES_URL } from '../../../../legacy/plugins/siem/common/constants'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getSimpleRule, + getSimpleRuleOutput, + getSimpleRuleOutputWithoutRuleId, + getSimpleRuleWithoutRuleId, + removeServerGeneratedProperties, + removeServerGeneratedPropertiesIncludingRuleId, +} from './utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const es = getService('legacyEs'); + + describe('create_rules', () => { + describe('validation errors', () => { + it('should give an error that the index must exist first if it does not exist before creating a rule', async () => { + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule()) + .expect(400); + + expect(body).to.eql({ + message: + 'To create a rule, the index must exist first. Index .siem-signals-default does not exist', + status_code: 400, + }); + }); + }); + + describe('creating rules', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(es); + }); + + it('should create a single rule with a rule_id', async () => { + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule()) + .expect(200); + + const bodyToCompare = removeServerGeneratedProperties(body); + expect(bodyToCompare).to.eql(getSimpleRuleOutput()); + }); + + it('should create a single rule without a rule_id', async () => { + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRuleWithoutRuleId()) + .expect(200); + + const bodyToCompare = removeServerGeneratedPropertiesIncludingRuleId(body); + expect(bodyToCompare).to.eql(getSimpleRuleOutputWithoutRuleId()); + }); + + it('should cause a 409 conflict if we attempt to create the same rule_id twice', async () => { + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule()) + .expect(200); + + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule()) + .expect(409); + + expect(body).to.eql({ + message: 'rule_id: "rule-1" already exists', + status_code: 409, + }); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts new file mode 100644 index 00000000000000..dfa297c85dfb88 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { DETECTION_ENGINE_RULES_URL } from '../../../../legacy/plugins/siem/common/constants'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getSimpleRule, + getSimpleRuleOutput, + getSimpleRuleOutputWithoutRuleId, + getSimpleRuleWithoutRuleId, + removeServerGeneratedProperties, + removeServerGeneratedPropertiesIncludingRuleId, +} from './utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('legacyEs'); + + describe('create_rules_bulk', () => { + describe('validation errors', () => { + it('should give a 200 even if the index does not exist as all bulks return a 200 but have an error of 409 bad request in the body', async () => { + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_bulk_create`) + .set('kbn-xsrf', 'true') + .send([getSimpleRule()]) + .expect(200); + + expect(body).to.eql([ + { + error: { + message: + 'To create a rule, the index must exist first. Index .siem-signals-default does not exist', + status_code: 400, + }, + rule_id: 'rule-1', + }, + ]); + }); + }); + + describe('creating rules in bulk', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(es); + }); + + it('should create a single rule with a rule_id', async () => { + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_bulk_create`) + .set('kbn-xsrf', 'true') + .send([getSimpleRule()]) + .expect(200); + + const bodyToCompare = removeServerGeneratedProperties(body[0]); + expect(bodyToCompare).to.eql(getSimpleRuleOutput()); + }); + + it('should create a single rule without a rule_id', async () => { + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_bulk_create`) + .set('kbn-xsrf', 'true') + .send([getSimpleRuleWithoutRuleId()]) + .expect(200); + + const bodyToCompare = removeServerGeneratedPropertiesIncludingRuleId(body[0]); + expect(bodyToCompare).to.eql(getSimpleRuleOutputWithoutRuleId()); + }); + + // TODO: This is a valid issue and will be fixed in an upcoming PR and then enabled once that PR is merged + it.skip('should return a 200 ok but have a 409 conflict if we attempt to create the same rule_id twice', async () => { + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_bulk_create`) + .set('kbn-xsrf', 'true') + .send([getSimpleRule(), getSimpleRule()]) + .expect(200); + + expect(body).to.eql([ + { + error: 'Conflict', + message: 'rule_id: "rule-1" already exists', + statusCode: 409, + }, + ]); + }); + + it('should return a 200 ok but have a 409 conflict if we attempt to create the same rule_id that already exists', async () => { + await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_bulk_create`) + .set('kbn-xsrf', 'true') + .send([getSimpleRule()]) + .expect(200); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_bulk_create`) + .set('kbn-xsrf', 'foo') + .send([getSimpleRule()]) + .expect(200); + + expect(body).to.eql([ + { + error: { + message: 'rule_id: "rule-1" already exists', + status_code: 409, + }, + rule_id: 'rule-1', + }, + ]); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules.ts new file mode 100644 index 00000000000000..ee34e5e2619873 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules.ts @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { DETECTION_ENGINE_RULES_URL } from '../../../../legacy/plugins/siem/common/constants'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getSimpleRule, + getSimpleRuleOutput, + getSimpleRuleOutputWithoutRuleId, + getSimpleRuleWithoutRuleId, + removeServerGeneratedProperties, + removeServerGeneratedPropertiesIncludingRuleId, +} from './utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('legacyEs'); + + describe('delete_rules', () => { + describe('deleting rules', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(es); + }); + + it('should delete a single rule with a rule_id', async () => { + // create a rule + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + // delete the rule by its rule_id + const { body } = await supertest + .delete(`${DETECTION_ENGINE_RULES_URL}?rule_id=rule-1`) + .set('kbn-xsrf', 'true') + .query() + .expect(200); + + const bodyToCompare = removeServerGeneratedProperties(body); + expect(bodyToCompare).to.eql(getSimpleRuleOutput()); + }); + + it('should delete a single rule using an auto generated rule_id', async () => { + // add a rule where the rule_id is auto-generated + const { body: bodyWithCreatedRule } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRuleWithoutRuleId()) + .expect(200); + + // delete that rule by its auto-generated rule_id + const { body } = await supertest + .delete(`${DETECTION_ENGINE_RULES_URL}?rule_id=${bodyWithCreatedRule.rule_id}`) + .set('kbn-xsrf', 'true') + .query() + .expect(200); + + const bodyToCompare = removeServerGeneratedPropertiesIncludingRuleId(body); + expect(bodyToCompare).to.eql(getSimpleRuleOutputWithoutRuleId()); + }); + + it('should delete a single rule using an auto generated id', async () => { + // add a rule + const { body: bodyWithCreatedRule } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule()) + .expect(200); + + // delete that rule by its auto-generated id + const { body } = await supertest + .delete(`${DETECTION_ENGINE_RULES_URL}?id=${bodyWithCreatedRule.id}`) + .set('kbn-xsrf', 'true') + .query() + .expect(200); + + const bodyToCompare = removeServerGeneratedPropertiesIncludingRuleId(body); + expect(bodyToCompare).to.eql(getSimpleRuleOutputWithoutRuleId()); + }); + + it('should return an error if the id does not exist when trying to delete it', async () => { + const { body } = await supertest + .delete(`${DETECTION_ENGINE_RULES_URL}?id=fake_id`) + .set('kbn-xsrf', 'true') + .query() + .expect(404); + + expect(body).to.eql({ + message: 'id: "fake_id" not found', + status_code: 404, + }); + }); + + it('should return an error if the rule_id does not exist when trying to delete it', async () => { + const { body } = await supertest + .delete(`${DETECTION_ENGINE_RULES_URL}?rule_id=fake_id`) + .set('kbn-xsrf', 'true') + .query() + .expect(404); + + expect(body).to.eql({ + message: 'rule_id: "fake_id" not found', + status_code: 404, + }); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules_bulk.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules_bulk.ts new file mode 100644 index 00000000000000..5a1c178f6b2110 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/delete_rules_bulk.ts @@ -0,0 +1,293 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { DETECTION_ENGINE_RULES_URL } from '../../../../legacy/plugins/siem/common/constants'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getSimpleRule, + getSimpleRuleOutput, + getSimpleRuleOutputWithoutRuleId, + getSimpleRuleWithoutRuleId, + removeServerGeneratedProperties, + removeServerGeneratedPropertiesIncludingRuleId, +} from './utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('legacyEs'); + + describe('delete_rules_bulk', () => { + describe('deleting rules bulk using DELETE', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(es); + }); + + it('should delete a single rule with a rule_id', async () => { + // add a rule + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule()) + .expect(200); + + // delete the rule in bulk + const { body } = await supertest + .delete(`${DETECTION_ENGINE_RULES_URL}/_bulk_delete`) + .set('kbn-xsrf', 'true') + .send([{ rule_id: 'rule-1' }]) + .query() + .expect(200); + + const bodyToCompare = removeServerGeneratedProperties(body[0]); + expect(bodyToCompare).to.eql(getSimpleRuleOutput()); + }); + + it('should delete a single rule using an auto generated rule_id', async () => { + // add a rule without a rule_id + const { body: bodyWithCreatedRule } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRuleWithoutRuleId()) + .expect(200); + + // delete that rule by its rule_id + const { body } = await supertest + .delete(`${DETECTION_ENGINE_RULES_URL}/_bulk_delete`) + .send([{ rule_id: bodyWithCreatedRule.rule_id }]) + .set('kbn-xsrf', 'true') + .query() + .expect(200); + + const bodyToCompare = removeServerGeneratedPropertiesIncludingRuleId(body[0]); + expect(bodyToCompare).to.eql(getSimpleRuleOutputWithoutRuleId()); + }); + + it('should delete a single rule using an auto generated id', async () => { + // add a rule + const { body: bodyWithCreatedRule } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule()) + .expect(200); + + // delete that rule by its id + const { body } = await supertest + .delete(`${DETECTION_ENGINE_RULES_URL}/_bulk_delete`) + .send([{ id: bodyWithCreatedRule.id }]) + .set('kbn-xsrf', 'true') + .query() + .expect(200); + + const bodyToCompare = removeServerGeneratedPropertiesIncludingRuleId(body[0]); + expect(bodyToCompare).to.eql(getSimpleRuleOutputWithoutRuleId()); + }); + + it('should return an error if the ruled_id does not exist when trying to delete a rule_id', async () => { + const { body } = await supertest + .delete(`${DETECTION_ENGINE_RULES_URL}/_bulk_delete`) + .send([{ rule_id: 'fake_id' }]) + .set('kbn-xsrf', 'true') + .query() + .expect(200); + + expect(body).to.eql([ + { + error: { + message: 'rule_id: "fake_id" not found', + status_code: 404, + }, + rule_id: 'fake_id', + }, + ]); + }); + + it('should return an error if the id does not exist when trying to delete an id', async () => { + const { body } = await supertest + .delete(`${DETECTION_ENGINE_RULES_URL}/_bulk_delete`) + .send([{ id: 'fake_id' }]) + .set('kbn-xsrf', 'true') + .query() + .expect(200); + + expect(body).to.eql([ + { + error: { + message: 'id: "fake_id" not found', + status_code: 404, + }, + rule_id: 'fake_id', // TODO This is a known issue where it should be id and not rule_id + }, + ]); + }); + + it('should delete a single rule using an auto generated rule_id but give an error if the second rule does not exist', async () => { + // add the rule + const { body: bodyWithCreatedRule } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRuleWithoutRuleId()) + .expect(200); + + const { body } = await supertest + .delete(`${DETECTION_ENGINE_RULES_URL}/_bulk_delete`) + .send([{ id: bodyWithCreatedRule.id }, { id: 'fake_id' }]) + .set('kbn-xsrf', 'true') + .query() + .expect(200); + + const bodyToCompare = removeServerGeneratedPropertiesIncludingRuleId(body[0]); + expect([bodyToCompare, body[1]]).to.eql([ + getSimpleRuleOutputWithoutRuleId(), + { rule_id: 'fake_id', error: { status_code: 404, message: 'id: "fake_id" not found' } }, + ]); + }); + }); + + // This is a repeat of the tests above but just using POST instead of DELETE + describe('deleting rules bulk using POST', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(es); + }); + + it('should delete a single rule with a rule_id', async () => { + // add a rule + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'foo') + .set('kbn-xsrf', 'true') + .send(getSimpleRule()) + .expect(200); + + // delete the rule in bulk + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_bulk_delete`) + .set('kbn-xsrf', 'true') + .send([{ rule_id: 'rule-1' }]) + .query() + .expect(200); + + const bodyToCompare = removeServerGeneratedProperties(body[0]); + expect(bodyToCompare).to.eql(getSimpleRuleOutput()); + }); + + it('should delete a single rule using an auto generated rule_id', async () => { + // add a rule without a rule_id + const { body: bodyWithCreatedRule } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRuleWithoutRuleId()) + .expect(200); + + // delete that rule by its rule_id + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_bulk_delete`) + .send([{ rule_id: bodyWithCreatedRule.rule_id }]) + .set('kbn-xsrf', 'true') + .query() + .expect(200); + + const bodyToCompare = removeServerGeneratedPropertiesIncludingRuleId(body[0]); + expect(bodyToCompare).to.eql(getSimpleRuleOutputWithoutRuleId()); + }); + + it('should delete a single rule using an auto generated id', async () => { + // add a rule + const { body: bodyWithCreatedRule } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule()) + .expect(200); + + // delete that rule by its id + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_bulk_delete`) + .send([{ id: bodyWithCreatedRule.id }]) + .set('kbn-xsrf', 'true') + .query() + .expect(200); + + const bodyToCompare = removeServerGeneratedPropertiesIncludingRuleId(body[0]); + expect(bodyToCompare).to.eql(getSimpleRuleOutputWithoutRuleId()); + }); + + it('should return an error if the ruled_id does not exist when trying to delete a rule_id', async () => { + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_bulk_delete`) + .send([{ rule_id: 'fake_id' }]) + .set('kbn-xsrf', 'true') + .query() + .expect(200); + + expect(body).to.eql([ + { + error: { + message: 'rule_id: "fake_id" not found', + status_code: 404, + }, + rule_id: 'fake_id', + }, + ]); + }); + + it('should return an error if the id does not exist when trying to delete an id', async () => { + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_bulk_delete`) + .send([{ id: 'fake_id' }]) + .set('kbn-xsrf', 'true') + .query() + .expect(200); + + expect(body).to.eql([ + { + error: { + message: 'id: "fake_id" not found', + status_code: 404, + }, + rule_id: 'fake_id', // TODO This is a known issue where it should be id and not rule_id + }, + ]); + }); + + it('should delete a single rule using an auto generated rule_id but give an error if the second rule does not exist', async () => { + // add the rule + const { body: bodyWithCreatedRule } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRuleWithoutRuleId()) + .expect(200); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_bulk_delete`) + .send([{ id: bodyWithCreatedRule.id }, { id: 'fake_id' }]) + .set('kbn-xsrf', 'true') + .query() + .expect(200); + + const bodyToCompare = removeServerGeneratedPropertiesIncludingRuleId(body[0]); + expect([bodyToCompare, body[1]]).to.eql([ + getSimpleRuleOutputWithoutRuleId(), + { rule_id: 'fake_id', error: { status_code: 404, message: 'id: "fake_id" not found' } }, + ]); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/export_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/export_rules.ts new file mode 100644 index 00000000000000..8882448dfcdc27 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/export_rules.ts @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { DETECTION_ENGINE_RULES_URL } from '../../../../legacy/plugins/siem/common/constants'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + binaryToString, + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getSimpleRule, + getSimpleRuleOutput, + removeServerGeneratedProperties, +} from './utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('legacyEs'); + + describe('export_rules', () => { + describe('exporting rules', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(es); + }); + + it('should set the response content types to be expected', async () => { + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule()) + .expect(200); + + await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_export`) + .set('kbn-xsrf', 'true') + .send() + .query() + .expect(200) + .expect('Content-Type', 'application/ndjson') + .expect('Content-Disposition', 'attachment; filename="export.ndjson"'); + }); + + it('should export a single rule with a rule_id', async () => { + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule()) + .expect(200); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_export`) + .set('kbn-xsrf', 'true') + .send() + .query() + .expect(200) + .parse(binaryToString); + + const bodySplitAndParsed = JSON.parse(body.toString().split(/\n/)[0]); + const bodyToTest = removeServerGeneratedProperties(bodySplitAndParsed); + + expect(bodyToTest).to.eql(getSimpleRuleOutput()); + }); + + it('should export a exported count with a single rule_id', async () => { + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule()) + .expect(200); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_export`) + .set('kbn-xsrf', 'true') + .send() + .query() + .expect(200) + .parse(binaryToString); + + const bodySplitAndParsed = JSON.parse(body.toString().split(/\n/)[1]); + + expect(bodySplitAndParsed).to.eql({ + exported_count: 1, + missing_rules: [], + missing_rules_count: 0, + }); + }); + + it('should export exactly two rules given two rules', async () => { + // post rule 1 + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + // post rule 2 + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-2')) + .expect(200); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_export`) + .set('kbn-xsrf', 'true') + .send() + .query() + .expect(200) + .parse(binaryToString); + + const firstRuleParsed = JSON.parse(body.toString().split(/\n/)[0]); + const secondRuleParsed = JSON.parse(body.toString().split(/\n/)[1]); + const firstRule = removeServerGeneratedProperties(firstRuleParsed); + const secondRule = removeServerGeneratedProperties(secondRuleParsed); + + expect([firstRule, secondRule]).to.eql([ + getSimpleRuleOutput('rule-2'), + getSimpleRuleOutput('rule-1'), + ]); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/find_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/find_rules.ts new file mode 100644 index 00000000000000..82e506b23ca971 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/find_rules.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { DETECTION_ENGINE_RULES_URL } from '../../../../legacy/plugins/siem/common/constants'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getComplexRule, + getComplexRuleOutput, + getSimpleRule, + getSimpleRuleOutput, + removeServerGeneratedProperties, +} from './utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('legacyEs'); + + describe('find_rules', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(es); + }); + + it('should return an empty find body correctly if no rules are loaded', async () => { + const { body } = await supertest + .get(`${DETECTION_ENGINE_RULES_URL}/_find`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(body).to.eql({ + data: [], + page: 1, + perPage: 20, + total: 0, + }); + }); + + it('should return a single rule when a single rule is loaded from a find with defaults added', async () => { + // add a single rule + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule()) + .expect(200); + + // query the single rule from _find + const { body } = await supertest + .get(`${DETECTION_ENGINE_RULES_URL}/_find`) + .set('kbn-xsrf', 'true') + .send(); + + body.data = [removeServerGeneratedProperties(body.data[0])]; + expect(body).to.eql({ + data: [getSimpleRuleOutput()], + page: 1, + perPage: 20, + total: 1, + }); + }); + + it('should return a single rule when a single rule is loaded from a find with everything for the rule added', async () => { + // add a single rule + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getComplexRule()) + .expect(200); + + // query and expect that we get back one record in the find + const { body } = await supertest + .get(`${DETECTION_ENGINE_RULES_URL}/_find`) + .set('kbn-xsrf', 'true') + .send(); + + body.data = [removeServerGeneratedProperties(body.data[0])]; + expect(body).to.eql({ + data: [getComplexRuleOutput()], + page: 1, + perPage: 20, + total: 1, + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/get_prepackaged_rules_status.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/get_prepackaged_rules_status.ts new file mode 100644 index 00000000000000..49cf150126fda2 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/get_prepackaged_rules_status.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { + DETECTION_ENGINE_PREPACKAGED_URL, + DETECTION_ENGINE_RULES_URL, +} from '../../../../legacy/plugins/siem/common/constants'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { createSignalsIndex, deleteAllAlerts, deleteSignalsIndex, getSimpleRule } from './utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('legacyEs'); + + describe('get_prepackaged_rules_status', () => { + describe('getting prepackaged rules status', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(es); + }); + + it('should return expected JSON keys of the pre-packaged rules status', async () => { + const { body } = await supertest + .get(`${DETECTION_ENGINE_PREPACKAGED_URL}/_status`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(Object.keys(body)).to.eql([ + 'rules_custom_installed', + 'rules_installed', + 'rules_not_installed', + 'rules_not_updated', + ]); + }); + + it('should return that rules_not_installed are greater than zero', async () => { + const { body } = await supertest + .get(`${DETECTION_ENGINE_PREPACKAGED_URL}/_status`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + expect(body.rules_not_installed).to.be.greaterThan(0); + }); + + it('should return that rules_custom_installed, rules_installed, and rules_not_updated are zero', async () => { + const { body } = await supertest + .get(`${DETECTION_ENGINE_PREPACKAGED_URL}/_status`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + expect(body.rules_custom_installed).to.eql(0); + expect(body.rules_installed).to.eql(0); + expect(body.rules_not_updated).to.eql(0); + }); + + it('should show that one custom rule is installed when a custom rule is added', async () => { + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule()) + .expect(200); + + const { body } = await supertest + .get(`${DETECTION_ENGINE_PREPACKAGED_URL}/_status`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + expect(body.rules_custom_installed).to.eql(1); + expect(body.rules_installed).to.eql(0); + expect(body.rules_not_updated).to.eql(0); + }); + + it('should show rules are installed when adding pre-packaged rules', async () => { + await supertest + .put(DETECTION_ENGINE_PREPACKAGED_URL) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + const { body } = await supertest + .get(`${DETECTION_ENGINE_PREPACKAGED_URL}/_status`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + expect(body.rules_installed).to.be.greaterThan(0); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts new file mode 100644 index 00000000000000..e8fd1e4298c226 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts @@ -0,0 +1,331 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { DETECTION_ENGINE_RULES_URL } from '../../../../legacy/plugins/siem/common/constants'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getSimpleRule, + getSimpleRuleAsNdjson, + getSimpleRuleOutput, + removeServerGeneratedProperties, + ruleToNdjson, +} from './utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('legacyEs'); + + describe('import_rules', () => { + describe('importing rules', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(es); + }); + + it('should set the response content types to be expected', async () => { + await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .query() + .expect('Content-Type', 'application/json; charset=utf-8') + .expect(200); + }); + + it('should reject with an error if the file type is not that of a ndjson', async () => { + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.txt') + .query() + .expect(400); + + expect(body).to.eql({ + status_code: 400, + message: 'Invalid file extension .txt', + }); + }); + + it('should report that it imported a simple rule successfully', async () => { + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .query() + .expect(200); + + expect(body).to.eql({ + errors: [], + success: true, + success_count: 1, + }); + }); + + it('should be able to read an imported rule back out correctly', async () => { + await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .query() + .expect(200); + + const { body } = await supertest + .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=rule-1`) + .send() + .expect(200); + + const bodyToCompare = removeServerGeneratedProperties(body); + expect(bodyToCompare).to.eql(getSimpleRuleOutput('rule-1')); + }); + + it('should be able to import two rules', async () => { + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2']), 'rules.ndjson') + .query() + .expect(200); + + expect(body).to.eql({ + errors: [], + success: true, + success_count: 2, + }); + }); + + it('should report a conflict if there is an attempt to import two rules with the same rule_id', async () => { + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-1']), 'rules.ndjson') + .query() + .expect(200); + + expect(body).to.eql({ + errors: [], // TODO: This should have a conflict within it as an error rather than an empty array + success: true, + success_count: 1, + }); + }); + + it('should NOT report a conflict if there is an attempt to import two rules with the same rule_id and overwrite is set to true', async () => { + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`) + .set('kbn-xsrf', 'true') + .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-1']), 'rules.ndjson') + .query() + .expect(200); + + expect(body).to.eql({ + errors: [], + success: true, + success_count: 1, + }); + }); + + it('should report a conflict if there is an attempt to import a rule with a rule_id that already exists', async () => { + await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .query() + .expect(200); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .query() + .expect(200); + + expect(body).to.eql({ + errors: [ + { + error: { + message: 'rule_id: "rule-1" already exists', + status_code: 409, + }, + rule_id: 'rule-1', + }, + ], + success: false, + success_count: 0, + }); + }); + + it('should NOT report a conflict if there is an attempt to import a rule with a rule_id that already exists and overwrite is set to true', async () => { + await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`) + .set('kbn-xsrf', 'true') + .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .query() + .expect(200); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`) + .set('kbn-xsrf', 'true') + .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .query() + .expect(200); + + expect(body).to.eql({ + errors: [], + success: true, + success_count: 1, + }); + }); + + it('should overwrite an existing rule if overwrite is set to true', async () => { + await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .query() + .expect(200); + + const simpleRule = getSimpleRule('rule-1'); + simpleRule.name = 'some other name'; + const ndjson = ruleToNdjson(simpleRule); + + await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`) + .set('kbn-xsrf', 'true') + .attach('file', ndjson, 'rules.ndjson') + .query() + .expect(200); + + const { body } = await supertest + .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=rule-1`) + .send() + .expect(200); + + const bodyToCompare = removeServerGeneratedProperties(body); + const ruleOutput = getSimpleRuleOutput('rule-1'); + ruleOutput.name = 'some other name'; + ruleOutput.version = 2; + expect(bodyToCompare).to.eql(ruleOutput); + }); + + it('should report a conflict if there is an attempt to import a rule with a rule_id that already exists, but still have some successes with other rules', async () => { + await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .query() + .expect(200); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2', 'rule-3']), 'rules.ndjson') + .query() + .expect(200); + + expect(body).to.eql({ + errors: [ + { + error: { + message: 'rule_id: "rule-1" already exists', + status_code: 409, + }, + rule_id: 'rule-1', + }, + ], + success: false, + success_count: 2, + }); + }); + + it('should report a mix of conflicts and a mix of successes', async () => { + await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2']), 'rules.ndjson') + .query() + .expect(200); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2', 'rule-3']), 'rules.ndjson') + .query() + .expect(200); + + expect(body).to.eql({ + errors: [ + { + error: { + message: 'rule_id: "rule-1" already exists', + status_code: 409, + }, + rule_id: 'rule-1', + }, + { + error: { + message: 'rule_id: "rule-2" already exists', + status_code: 409, + }, + rule_id: 'rule-2', + }, + ], + success: false, + success_count: 1, + }); + }); + + it('should be able to correctly read back a mixed import of different rules even if some cause conflicts', async () => { + await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2']), 'rules.ndjson') + .query() + .expect(200); + + await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2', 'rule-3']), 'rules.ndjson') + .query() + .expect(200); + + const { body: bodyOfRule1 } = await supertest + .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=rule-1`) + .send() + .expect(200); + + const { body: bodyOfRule2 } = await supertest + .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=rule-2`) + .send() + .expect(200); + + const { body: bodyOfRule3 } = await supertest + .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=rule-3`) + .send() + .expect(200); + + const bodyToCompareOfRule1 = removeServerGeneratedProperties(bodyOfRule1); + const bodyToCompareOfRule2 = removeServerGeneratedProperties(bodyOfRule2); + const bodyToCompareOfRule3 = removeServerGeneratedProperties(bodyOfRule3); + + expect([bodyToCompareOfRule1, bodyToCompareOfRule2, bodyToCompareOfRule3]).to.eql([ + getSimpleRuleOutput('rule-1'), + getSimpleRuleOutput('rule-2'), + getSimpleRuleOutput('rule-3'), + ]); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts new file mode 100644 index 00000000000000..ca6ef5b6cede9e --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default ({ loadTestFile }: FtrProviderContext): void => { + describe('detection engine api security and spaces enabled', function() { + this.tags('ciGroup1'); + + loadTestFile(require.resolve('./add_prepackaged_rules')); + loadTestFile(require.resolve('./create_rules')); + loadTestFile(require.resolve('./create_rules_bulk')); + loadTestFile(require.resolve('./delete_rules')); + loadTestFile(require.resolve('./delete_rules_bulk')); + loadTestFile(require.resolve('./export_rules')); + loadTestFile(require.resolve('./find_rules')); + loadTestFile(require.resolve('./get_prepackaged_rules_status')); + loadTestFile(require.resolve('./import_rules')); + loadTestFile(require.resolve('./read_rules')); + loadTestFile(require.resolve('./update_rules')); + loadTestFile(require.resolve('./update_rules_bulk')); + loadTestFile(require.resolve('./patch_rules_bulk')); + loadTestFile(require.resolve('./patch_rules')); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/patch_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/patch_rules.ts new file mode 100644 index 00000000000000..53a3d15690efcf --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/patch_rules.ts @@ -0,0 +1,212 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { DETECTION_ENGINE_RULES_URL } from '../../../../legacy/plugins/siem/common/constants'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getSimpleRule, + getSimpleRuleOutput, + removeServerGeneratedProperties, + removeServerGeneratedPropertiesIncludingRuleId, + getSimpleRuleOutputWithoutRuleId, +} from './utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const es = getService('legacyEs'); + + describe('patch_rules', () => { + describe('patch rules', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(es); + }); + + it('should patch a single rule property of name using a rule_id', async () => { + // create a simple rule + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + // patch a simple rule's name + const { body } = await supertest + .patch(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send({ rule_id: 'rule-1', name: 'some other name' }) + .expect(200); + + const outputRule = getSimpleRuleOutput(); + outputRule.name = 'some other name'; + outputRule.version = 2; + const bodyToCompare = removeServerGeneratedProperties(body); + expect(bodyToCompare).to.eql(outputRule); + }); + + it('should patch a single rule property of name using the auto-generated rule_id', async () => { + // create a simple rule + const rule = getSimpleRule('rule-1'); + delete rule.rule_id; + + const { body: createRuleBody } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(rule) + .expect(200); + + // patch a simple rule's name + const { body } = await supertest + .patch(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send({ rule_id: createRuleBody.rule_id, name: 'some other name' }) + .expect(200); + + const outputRule = getSimpleRuleOutputWithoutRuleId(); + outputRule.name = 'some other name'; + outputRule.version = 2; + const bodyToCompare = removeServerGeneratedPropertiesIncludingRuleId(body); + expect(bodyToCompare).to.eql(outputRule); + }); + + it('should patch a single rule property of name using the auto-generated id', async () => { + // create a simple rule + const { body: createdBody } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + // patch a simple rule's name + const { body } = await supertest + .patch(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send({ id: createdBody.id, name: 'some other name' }) + .expect(200); + + const outputRule = getSimpleRuleOutput(); + outputRule.name = 'some other name'; + outputRule.version = 2; + const bodyToCompare = removeServerGeneratedProperties(body); + expect(bodyToCompare).to.eql(outputRule); + }); + + it('should not change the version of a rule when it patches only enabled', async () => { + // create a simple rule + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + // patch a simple rule's enabled to false + const { body } = await supertest + .patch(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send({ rule_id: 'rule-1', enabled: false }) + .expect(200); + + const outputRule = getSimpleRuleOutput(); + outputRule.enabled = false; + + const bodyToCompare = removeServerGeneratedProperties(body); + expect(bodyToCompare).to.eql(outputRule); + }); + + it('should change the version of a rule when it patches enabled and another property', async () => { + // create a simple rule + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + // patch a simple rule's enabled to false and another property + const { body } = await supertest + .patch(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send({ rule_id: 'rule-1', severity: 'low', enabled: false }) + .expect(200); + + const outputRule = getSimpleRuleOutput(); + outputRule.enabled = false; + outputRule.severity = 'low'; + outputRule.version = 2; + + const bodyToCompare = removeServerGeneratedProperties(body); + expect(bodyToCompare).to.eql(outputRule); + }); + + it('should not change other properties when it does patches', async () => { + // create a simple rule + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + // patch a simple rule's timeline_title + await supertest + .patch(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send({ rule_id: 'rule-1', timeline_title: 'some title', timeline_id: 'some id' }) + .expect(200); + + // patch a simple rule's name + const { body } = await supertest + .patch(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send({ rule_id: 'rule-1', name: 'some other name' }) + .expect(200); + + const outputRule = getSimpleRuleOutput(); + outputRule.name = 'some other name'; + outputRule.timeline_title = 'some title'; + outputRule.timeline_id = 'some id'; + outputRule.version = 3; + + const bodyToCompare = removeServerGeneratedProperties(body); + expect(bodyToCompare).to.eql(outputRule); + }); + + it('should give a 404 if it is given a fake id', async () => { + const { body } = await supertest + .patch(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send({ id: 'fake_id', name: 'some other name' }) + .expect(404); + + expect(body).to.eql({ + status_code: 404, + message: 'id: "fake_id" not found', + }); + }); + + it('should give a 404 if it is given a fake rule_id', async () => { + const { body } = await supertest + .patch(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send({ rule_id: 'fake_id', name: 'some other name' }) + .expect(404); + + expect(body).to.eql({ + status_code: 404, + message: 'rule_id: "fake_id" not found', + }); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/patch_rules_bulk.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/patch_rules_bulk.ts new file mode 100644 index 00000000000000..3d14bc2db47b44 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/patch_rules_bulk.ts @@ -0,0 +1,356 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { DETECTION_ENGINE_RULES_URL } from '../../../../legacy/plugins/siem/common/constants'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getSimpleRule, + getSimpleRuleOutput, + removeServerGeneratedProperties, + getSimpleRuleOutputWithoutRuleId, + removeServerGeneratedPropertiesIncludingRuleId, +} from './utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const es = getService('legacyEs'); + + describe('patch_rules_bulk', () => { + describe('patch rules bulk', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(es); + }); + + it('should patch a single rule property of name using a rule_id', async () => { + // create a simple rule + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + // patch a simple rule's name + const { body } = await supertest + .patch(`${DETECTION_ENGINE_RULES_URL}/_bulk_update`) + .set('kbn-xsrf', 'true') + .send([{ rule_id: 'rule-1', name: 'some other name' }]) + .expect(200); + + const outputRule = getSimpleRuleOutput(); + outputRule.name = 'some other name'; + outputRule.version = 2; + const bodyToCompare = removeServerGeneratedProperties(body[0]); + expect(bodyToCompare).to.eql(outputRule); + }); + + it('should patch two rule properties of name using the two rules rule_id', async () => { + // create a simple rule + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + // create a second simple rule + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-2')) + .expect(200); + + // patch both rule names + const { body } = await supertest + .patch(`${DETECTION_ENGINE_RULES_URL}/_bulk_update`) + .set('kbn-xsrf', 'true') + .send([ + { rule_id: 'rule-1', name: 'some other name' }, + { rule_id: 'rule-2', name: 'some other name' }, + ]) + .expect(200); + + const outputRule1 = getSimpleRuleOutput(); + outputRule1.name = 'some other name'; + outputRule1.version = 2; + + const outputRule2 = getSimpleRuleOutput('rule-2'); + outputRule2.name = 'some other name'; + outputRule2.version = 2; + + const bodyToCompare1 = removeServerGeneratedProperties(body[0]); + const bodyToCompare2 = removeServerGeneratedProperties(body[1]); + expect([bodyToCompare1, bodyToCompare2]).to.eql([outputRule1, outputRule2]); + }); + + it('should patch a single rule property of name using an id', async () => { + // create a simple rule + const { body: createRuleBody } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + // patch a simple rule's name + const { body } = await supertest + .patch(`${DETECTION_ENGINE_RULES_URL}/_bulk_update`) + .set('kbn-xsrf', 'true') + .send([{ id: createRuleBody.id, name: 'some other name' }]) + .expect(200); + + const outputRule = getSimpleRuleOutput(); + outputRule.name = 'some other name'; + outputRule.version = 2; + const bodyToCompare = removeServerGeneratedProperties(body[0]); + expect(bodyToCompare).to.eql(outputRule); + }); + + it('should patch two rule properties of name using the two rules id', async () => { + // create a simple rule + const { body: createRule1 } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + // create a second simple rule + const { body: createRule2 } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-2')) + .expect(200); + + // patch both rule names + const { body } = await supertest + .patch(`${DETECTION_ENGINE_RULES_URL}/_bulk_update`) + .set('kbn-xsrf', 'true') + .send([ + { id: createRule1.id, name: 'some other name' }, + { id: createRule2.id, name: 'some other name' }, + ]) + .expect(200); + + const outputRule1 = getSimpleRuleOutputWithoutRuleId('rule-1'); + outputRule1.name = 'some other name'; + outputRule1.version = 2; + + const outputRule2 = getSimpleRuleOutputWithoutRuleId('rule-2'); + outputRule2.name = 'some other name'; + outputRule2.version = 2; + + const bodyToCompare1 = removeServerGeneratedPropertiesIncludingRuleId(body[0]); + const bodyToCompare2 = removeServerGeneratedPropertiesIncludingRuleId(body[1]); + expect([bodyToCompare1, bodyToCompare2]).to.eql([outputRule1, outputRule2]); + }); + + it('should patch a single rule property of name using the auto-generated id', async () => { + // create a simple rule + const { body: createdBody } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + // patch a simple rule's name + const { body } = await supertest + .patch(`${DETECTION_ENGINE_RULES_URL}/_bulk_update`) + .set('kbn-xsrf', 'true') + .send([{ id: createdBody.id, name: 'some other name' }]) + .expect(200); + + const outputRule = getSimpleRuleOutput(); + outputRule.name = 'some other name'; + outputRule.version = 2; + const bodyToCompare = removeServerGeneratedProperties(body[0]); + expect(bodyToCompare).to.eql(outputRule); + }); + + it('should not change the version of a rule when it patches only enabled', async () => { + // create a simple rule + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + // patch a simple rule's enabled to false + const { body } = await supertest + .patch(`${DETECTION_ENGINE_RULES_URL}/_bulk_update`) + .set('kbn-xsrf', 'true') + .send([{ rule_id: 'rule-1', enabled: false }]) + .expect(200); + + const outputRule = getSimpleRuleOutput(); + outputRule.enabled = false; + + const bodyToCompare = removeServerGeneratedProperties(body[0]); + expect(bodyToCompare).to.eql(outputRule); + }); + + it('should change the version of a rule when it patches enabled and another property', async () => { + // create a simple rule + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + // patch a simple rule's enabled to false and another property + const { body } = await supertest + .patch(`${DETECTION_ENGINE_RULES_URL}/_bulk_update`) + .set('kbn-xsrf', 'true') + .send([{ rule_id: 'rule-1', severity: 'low', enabled: false }]) + .expect(200); + + const outputRule = getSimpleRuleOutput(); + outputRule.enabled = false; + outputRule.severity = 'low'; + outputRule.version = 2; + + const bodyToCompare = removeServerGeneratedProperties(body[0]); + expect(bodyToCompare).to.eql(outputRule); + }); + + it('should not change other properties when it does patches', async () => { + // create a simple rule + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + // patch a simple rule's timeline_title + await supertest + .patch(`${DETECTION_ENGINE_RULES_URL}/_bulk_update`) + .set('kbn-xsrf', 'true') + .send([{ rule_id: 'rule-1', timeline_title: 'some title', timeline_id: 'some id' }]) + .expect(200); + + // patch a simple rule's name + const { body } = await supertest + .patch(`${DETECTION_ENGINE_RULES_URL}/_bulk_update`) + .set('kbn-xsrf', 'true') + .send([{ rule_id: 'rule-1', name: 'some other name' }]) + .expect(200); + + const outputRule = getSimpleRuleOutput(); + outputRule.name = 'some other name'; + outputRule.timeline_title = 'some title'; + outputRule.timeline_id = 'some id'; + outputRule.version = 3; + + const bodyToCompare = removeServerGeneratedProperties(body[0]); + expect(bodyToCompare).to.eql(outputRule); + }); + + it('should return a 200 but give a 404 in the message if it is given a fake id', async () => { + const { body } = await supertest + .patch(`${DETECTION_ENGINE_RULES_URL}/_bulk_update`) + .set('kbn-xsrf', 'true') + .send([{ id: 'fake_id', name: 'some other name' }]) + .expect(200); + + expect(body).to.eql([ + { rule_id: 'fake_id', error: { status_code: 404, message: 'id: "fake_id" not found' } }, + ]); + }); + + it('should return a 200 but give a 404 in the message if it is given a fake rule_id', async () => { + const { body } = await supertest + .patch(`${DETECTION_ENGINE_RULES_URL}/_bulk_update`) + .set('kbn-xsrf', 'true') + .send([{ rule_id: 'fake_id', name: 'some other name' }]) + .expect(200); + + expect(body).to.eql([ + { + rule_id: 'fake_id', + error: { status_code: 404, message: 'rule_id: "fake_id" not found' }, + }, + ]); + }); + + it('should patch one rule property and give an error about a second fake rule_id', async () => { + // create a simple rule + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + // patch one rule name and give a fake id for the second + const { body } = await supertest + .patch(`${DETECTION_ENGINE_RULES_URL}/_bulk_update`) + .set('kbn-xsrf', 'true') + .send([ + { rule_id: 'rule-1', name: 'some other name' }, + { rule_id: 'fake_id', name: 'some other name' }, + ]) + .expect(200); + + const outputRule = getSimpleRuleOutput(); + outputRule.name = 'some other name'; + outputRule.version = 2; + + const bodyToCompare = removeServerGeneratedProperties(body[0]); + expect([bodyToCompare, body[1]]).to.eql([ + outputRule, + { + error: { + message: 'rule_id: "fake_id" not found', + status_code: 404, + }, + rule_id: 'fake_id', + }, + ]); + }); + + it('should patch one rule property and give an error about a second fake id', async () => { + // create a simple rule + const { body: createdBody } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + // patch one rule name and give a fake id for the second + const { body } = await supertest + .patch(`${DETECTION_ENGINE_RULES_URL}/_bulk_update`) + .set('kbn-xsrf', 'true') + .send([ + { id: createdBody.id, name: 'some other name' }, + { id: 'fake_id', name: 'some other name' }, + ]) + .expect(200); + + const outputRule = getSimpleRuleOutput(); + outputRule.name = 'some other name'; + outputRule.version = 2; + + const bodyToCompare = removeServerGeneratedProperties(body[0]); + expect([bodyToCompare, body[1]]).to.eql([ + outputRule, + { + error: { + message: 'id: "fake_id" not found', + status_code: 404, + }, + rule_id: 'fake_id', // TODO: This should be id and not rule_id in the codebase + }, + ]); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/read_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/read_rules.ts new file mode 100644 index 00000000000000..2ea62b0756f73d --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/read_rules.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { DETECTION_ENGINE_RULES_URL } from '../../../../legacy/plugins/siem/common/constants'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getSimpleRule, + getSimpleRuleOutput, + getSimpleRuleOutputWithoutRuleId, + getSimpleRuleWithoutRuleId, + removeServerGeneratedProperties, + removeServerGeneratedPropertiesIncludingRuleId, +} from './utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const es = getService('legacyEs'); + + describe('read_rules', () => { + describe('reading rules', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(es); + }); + + it('should be able to read a single rule using rule_id', async () => { + // create a simple rule to read + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule()) + .expect(200); + + const { body } = await supertest + .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=rule-1`) + .set('kbn-xsrf', 'true') + .send(getSimpleRule()) + .expect(200); + + const bodyToCompare = removeServerGeneratedProperties(body); + expect(bodyToCompare).to.eql(getSimpleRuleOutput()); + }); + + it('should be able to read a single rule using id', async () => { + // create a simple rule to read + const { body: createRuleBody } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule()) + .expect(200); + + const { body } = await supertest + .get(`${DETECTION_ENGINE_RULES_URL}?id=${createRuleBody.id}`) + .set('kbn-xsrf', 'true') + .send(getSimpleRule()) + .expect(200); + + const bodyToCompare = removeServerGeneratedProperties(body); + expect(bodyToCompare).to.eql(getSimpleRuleOutput()); + }); + + it('should be able to read a single rule with an auto-generated rule_id', async () => { + // create a simple rule to read + const { body: createRuleBody } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRuleWithoutRuleId()) + .expect(200); + + const { body } = await supertest + .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=${createRuleBody.rule_id}`) + .set('kbn-xsrf', 'true') + .send(getSimpleRule()) + .expect(200); + + const bodyToCompare = removeServerGeneratedPropertiesIncludingRuleId(body); + expect(bodyToCompare).to.eql(getSimpleRuleOutputWithoutRuleId()); + }); + + it('should return 404 if given a fake id', async () => { + const { body } = await supertest + .get(`${DETECTION_ENGINE_RULES_URL}?id=fake_id`) + .set('kbn-xsrf', 'true') + .send(getSimpleRule()) + .expect(404); + + expect(body).to.eql({ + status_code: 404, + message: 'id: "fake_id" not found', + }); + }); + + it('should return 404 if given a fake rule_id', async () => { + const { body } = await supertest + .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=fake_id`) + .set('kbn-xsrf', 'true') + .send(getSimpleRule()) + .expect(404); + + expect(body).to.eql({ + status_code: 404, + message: 'rule_id: "fake_id" not found', + }); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/update_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/update_rules.ts new file mode 100644 index 00000000000000..92c78be72bf019 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/update_rules.ts @@ -0,0 +1,221 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { DETECTION_ENGINE_RULES_URL } from '../../../../legacy/plugins/siem/common/constants'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getSimpleRule, + getSimpleRuleOutput, + removeServerGeneratedProperties, + removeServerGeneratedPropertiesIncludingRuleId, + getSimpleRuleOutputWithoutRuleId, +} from './utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const es = getService('legacyEs'); + + describe('update_rules', () => { + describe('update rules', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(es); + }); + + it('should update a single rule property of name using a rule_id', async () => { + // create a simple rule + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + // update a simple rule's name + const updatedRule = getSimpleRule('rule-1'); + updatedRule.rule_id = 'rule-1'; + updatedRule.name = 'some other name'; + delete updatedRule.id; + + const { body } = await supertest + .put(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(updatedRule) + .expect(200); + + const outputRule = getSimpleRuleOutput(); + outputRule.name = 'some other name'; + outputRule.version = 2; + const bodyToCompare = removeServerGeneratedProperties(body); + expect(bodyToCompare).to.eql(outputRule); + }); + + it('should update a single rule property of name using an auto-generated rule_id', async () => { + const rule = getSimpleRule('rule-1'); + delete rule.rule_id; + // create a simple rule + const { body: createRuleBody } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(rule) + .expect(200); + + // update a simple rule's name + const updatedRule = getSimpleRule('rule-1'); + updatedRule.rule_id = createRuleBody.rule_id; + updatedRule.name = 'some other name'; + delete updatedRule.id; + + const { body } = await supertest + .put(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(updatedRule) + .expect(200); + + const outputRule = getSimpleRuleOutputWithoutRuleId(); + outputRule.name = 'some other name'; + outputRule.version = 2; + const bodyToCompare = removeServerGeneratedPropertiesIncludingRuleId(body); + expect(bodyToCompare).to.eql(outputRule); + }); + + it('should update a single rule property of name using the auto-generated id', async () => { + // create a simple rule + const { body: createdBody } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + // update a simple rule's name + const updatedRule = getSimpleRule('rule-1'); + updatedRule.name = 'some other name'; + updatedRule.id = createdBody.id; + delete updatedRule.rule_id; + + const { body } = await supertest + .put(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(updatedRule) + .expect(200); + + const outputRule = getSimpleRuleOutput(); + outputRule.name = 'some other name'; + outputRule.version = 2; + const bodyToCompare = removeServerGeneratedProperties(body); + expect(bodyToCompare).to.eql(outputRule); + }); + + it('should change the version of a rule when it updates enabled and another property', async () => { + // create a simple rule + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + // update a simple rule's enabled to false and another property + const updatedRule = getSimpleRule('rule-1'); + updatedRule.severity = 'low'; + updatedRule.enabled = false; + + const { body } = await supertest + .put(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(updatedRule) + .expect(200); + + const outputRule = getSimpleRuleOutput(); + outputRule.enabled = false; + outputRule.severity = 'low'; + outputRule.version = 2; + + const bodyToCompare = removeServerGeneratedProperties(body); + expect(bodyToCompare).to.eql(outputRule); + }); + + it('should change other properties when it does updates and effectively delete them such as timeline_title', async () => { + // create a simple rule + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + const ruleUpdate = getSimpleRule('rule-1'); + ruleUpdate.timeline_title = 'some title'; + ruleUpdate.timeline_id = 'some id'; + + // update a simple rule's timeline_title + await supertest + .put(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(ruleUpdate) + .expect(200); + + const ruleUpdate2 = getSimpleRule('rule-1'); + ruleUpdate2.name = 'some other name'; + + // update a simple rule's name + const { body } = await supertest + .put(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(ruleUpdate2) + .expect(200); + + const outputRule = getSimpleRuleOutput(); + outputRule.name = 'some other name'; + outputRule.version = 3; + + const bodyToCompare = removeServerGeneratedProperties(body); + expect(bodyToCompare).to.eql(outputRule); + }); + + it('should give a 404 if it is given a fake id', async () => { + const simpleRule = getSimpleRule(); + simpleRule.id = 'fake_id'; + delete simpleRule.rule_id; + + const { body } = await supertest + .put(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(simpleRule) + .expect(404); + + expect(body).to.eql({ + status_code: 404, + message: 'id: "fake_id" not found', + }); + }); + + it('should give a 404 if it is given a fake rule_id', async () => { + const simpleRule = getSimpleRule(); + simpleRule.rule_id = 'fake_id'; + delete simpleRule.id; + + const { body } = await supertest + .put(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(simpleRule) + .expect(404); + + expect(body).to.eql({ + status_code: 404, + message: 'rule_id: "fake_id" not found', + }); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/update_rules_bulk.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/update_rules_bulk.ts new file mode 100644 index 00000000000000..4894cac2b2608c --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/update_rules_bulk.ts @@ -0,0 +1,386 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { DETECTION_ENGINE_RULES_URL } from '../../../../legacy/plugins/siem/common/constants'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getSimpleRule, + getSimpleRuleOutput, + removeServerGeneratedProperties, + getSimpleRuleOutputWithoutRuleId, + removeServerGeneratedPropertiesIncludingRuleId, +} from './utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const es = getService('legacyEs'); + + describe('update_rules_bulk', () => { + describe('update rules bulk', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(es); + }); + + it('should update a single rule property of name using a rule_id', async () => { + // create a simple rule + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + const updatedRule = getSimpleRule('rule-1'); + updatedRule.name = 'some other name'; + + // update a simple rule's name + const { body } = await supertest + .put(`${DETECTION_ENGINE_RULES_URL}/_bulk_update`) + .set('kbn-xsrf', 'true') + .send([updatedRule]) + .expect(200); + + const outputRule = getSimpleRuleOutput(); + outputRule.name = 'some other name'; + outputRule.version = 2; + const bodyToCompare = removeServerGeneratedProperties(body[0]); + expect(bodyToCompare).to.eql(outputRule); + }); + + it('should update two rule properties of name using the two rules rule_id', async () => { + // create a simple rule + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + // create a second simple rule + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-2')) + .expect(200); + + const updatedRule1 = getSimpleRule('rule-1'); + updatedRule1.name = 'some other name'; + + const updatedRule2 = getSimpleRule('rule-2'); + updatedRule2.name = 'some other name'; + + // update both rule names + const { body } = await supertest + .put(`${DETECTION_ENGINE_RULES_URL}/_bulk_update`) + .set('kbn-xsrf', 'true') + .send([updatedRule1, updatedRule2]) + .expect(200); + + const outputRule1 = getSimpleRuleOutput(); + outputRule1.name = 'some other name'; + outputRule1.version = 2; + + const outputRule2 = getSimpleRuleOutput('rule-2'); + outputRule2.name = 'some other name'; + outputRule2.version = 2; + + const bodyToCompare1 = removeServerGeneratedProperties(body[0]); + const bodyToCompare2 = removeServerGeneratedProperties(body[1]); + expect([bodyToCompare1, bodyToCompare2]).to.eql([outputRule1, outputRule2]); + }); + + it('should update a single rule property of name using an id', async () => { + // create a simple rule + const { body: createRuleBody } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + // update a simple rule's name + const updatedRule1 = getSimpleRule('rule-1'); + updatedRule1.id = createRuleBody.id; + updatedRule1.name = 'some other name'; + delete updatedRule1.rule_id; + + const { body } = await supertest + .put(`${DETECTION_ENGINE_RULES_URL}/_bulk_update`) + .set('kbn-xsrf', 'true') + .send([updatedRule1]) + .expect(200); + + const outputRule = getSimpleRuleOutput(); + outputRule.name = 'some other name'; + outputRule.version = 2; + const bodyToCompare = removeServerGeneratedProperties(body[0]); + expect(bodyToCompare).to.eql(outputRule); + }); + + it('should update two rule properties of name using the two rules id', async () => { + // create a simple rule + const { body: createRule1 } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + // create a second simple rule + const { body: createRule2 } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-2')) + .expect(200); + + // update both rule names + const updatedRule1 = getSimpleRule('rule-1'); + updatedRule1.id = createRule1.id; + updatedRule1.name = 'some other name'; + delete updatedRule1.rule_id; + + const updatedRule2 = getSimpleRule('rule-1'); + updatedRule2.id = createRule2.id; + updatedRule2.name = 'some other name'; + delete updatedRule2.rule_id; + + const { body } = await supertest + .put(`${DETECTION_ENGINE_RULES_URL}/_bulk_update`) + .set('kbn-xsrf', 'true') + .send([updatedRule1, updatedRule2]) + .expect(200); + + const outputRule1 = getSimpleRuleOutputWithoutRuleId('rule-1'); + outputRule1.name = 'some other name'; + outputRule1.version = 2; + + const outputRule2 = getSimpleRuleOutputWithoutRuleId('rule-2'); + outputRule2.name = 'some other name'; + outputRule2.version = 2; + + const bodyToCompare1 = removeServerGeneratedPropertiesIncludingRuleId(body[0]); + const bodyToCompare2 = removeServerGeneratedPropertiesIncludingRuleId(body[1]); + expect([bodyToCompare1, bodyToCompare2]).to.eql([outputRule1, outputRule2]); + }); + + it('should update a single rule property of name using the auto-generated id', async () => { + // create a simple rule + const { body: createdBody } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + // update a simple rule's name + const updatedRule1 = getSimpleRule('rule-1'); + updatedRule1.id = createdBody.id; + updatedRule1.name = 'some other name'; + delete updatedRule1.rule_id; + + const { body } = await supertest + .put(`${DETECTION_ENGINE_RULES_URL}/_bulk_update`) + .set('kbn-xsrf', 'true') + .send([updatedRule1]) + .expect(200); + + const outputRule = getSimpleRuleOutput(); + outputRule.name = 'some other name'; + outputRule.version = 2; + const bodyToCompare = removeServerGeneratedProperties(body[0]); + expect(bodyToCompare).to.eql(outputRule); + }); + + it('should change the version of a rule when it updates enabled and another property', async () => { + // create a simple rule + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + // update a simple rule's enabled to false and another property + const updatedRule1 = getSimpleRule('rule-1'); + updatedRule1.severity = 'low'; + updatedRule1.enabled = false; + + const { body } = await supertest + .put(`${DETECTION_ENGINE_RULES_URL}/_bulk_update`) + .set('kbn-xsrf', 'true') + .send([updatedRule1]) + .expect(200); + + const outputRule = getSimpleRuleOutput(); + outputRule.enabled = false; + outputRule.severity = 'low'; + outputRule.version = 2; + + const bodyToCompare = removeServerGeneratedProperties(body[0]); + expect(bodyToCompare).to.eql(outputRule); + }); + + it('should change other properties when it does updates and effectively delete them such as timeline_title', async () => { + // create a simple rule + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + // update a simple rule's timeline_title + const ruleUpdate = getSimpleRule('rule-1'); + ruleUpdate.timeline_title = 'some title'; + ruleUpdate.timeline_id = 'some id'; + + await supertest + .put(`${DETECTION_ENGINE_RULES_URL}/_bulk_update`) + .set('kbn-xsrf', 'true') + .send([ruleUpdate]) + .expect(200); + + // update a simple rule's name + const ruleUpdate2 = getSimpleRule('rule-1'); + ruleUpdate2.name = 'some other name'; + + const { body } = await supertest + .put(`${DETECTION_ENGINE_RULES_URL}/_bulk_update`) + .set('kbn-xsrf', 'true') + .send([ruleUpdate2]) + .expect(200); + + const outputRule = getSimpleRuleOutput(); + outputRule.name = 'some other name'; + outputRule.version = 3; + + const bodyToCompare = removeServerGeneratedProperties(body[0]); + expect(bodyToCompare).to.eql(outputRule); + }); + + it('should return a 200 but give a 404 in the message if it is given a fake id', async () => { + const ruleUpdate = getSimpleRule('rule-1'); + ruleUpdate.id = 'fake_id'; + delete ruleUpdate.rule_id; + + const { body } = await supertest + .put(`${DETECTION_ENGINE_RULES_URL}/_bulk_update`) + .set('kbn-xsrf', 'true') + .send([ruleUpdate]) + .expect(200); + + expect(body).to.eql([ + { rule_id: 'fake_id', error: { status_code: 404, message: 'id: "fake_id" not found' } }, + ]); + }); + + it('should return a 200 but give a 404 in the message if it is given a fake rule_id', async () => { + const ruleUpdate = getSimpleRule('rule-1'); + ruleUpdate.rule_id = 'fake_id'; + delete ruleUpdate.id; + + const { body } = await supertest + .put(`${DETECTION_ENGINE_RULES_URL}/_bulk_update`) + .set('kbn-xsrf', 'true') + .send([ruleUpdate]) + .expect(200); + + expect(body).to.eql([ + { + rule_id: 'fake_id', + error: { status_code: 404, message: 'rule_id: "fake_id" not found' }, + }, + ]); + }); + + it('should update one rule property and give an error about a second fake rule_id', async () => { + // create a simple rule + await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + const ruleUpdate = getSimpleRule('rule-1'); + ruleUpdate.name = 'some other name'; + delete ruleUpdate.id; + + const ruleUpdate2 = getSimpleRule('fake_id'); + ruleUpdate2.name = 'some other name'; + delete ruleUpdate.id; + + // update one rule name and give a fake id for the second + const { body } = await supertest + .put(`${DETECTION_ENGINE_RULES_URL}/_bulk_update`) + .set('kbn-xsrf', 'true') + .send([ruleUpdate, ruleUpdate2]) + .expect(200); + + const outputRule = getSimpleRuleOutput(); + outputRule.name = 'some other name'; + outputRule.version = 2; + + const bodyToCompare = removeServerGeneratedProperties(body[0]); + expect([bodyToCompare, body[1]]).to.eql([ + outputRule, + { + error: { + message: 'rule_id: "fake_id" not found', + status_code: 404, + }, + rule_id: 'fake_id', + }, + ]); + }); + + it('should update one rule property and give an error about a second fake id', async () => { + // create a simple rule + const { body: createdBody } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleRule('rule-1')) + .expect(200); + + // update one rule name and give a fake id for the second + const rule1 = getSimpleRule(); + delete rule1.rule_id; + rule1.id = createdBody.id; + rule1.name = 'some other name'; + + const rule2 = getSimpleRule(); + delete rule2.rule_id; + rule2.id = 'fake_id'; + rule2.name = 'some other name'; + + const { body } = await supertest + .put(`${DETECTION_ENGINE_RULES_URL}/_bulk_update`) + .set('kbn-xsrf', 'true') + .send([rule1, rule2]) + .expect(200); + + const outputRule = getSimpleRuleOutput(); + outputRule.name = 'some other name'; + outputRule.version = 2; + + const bodyToCompare = removeServerGeneratedProperties(body[0]); + expect([bodyToCompare, body[1]]).to.eql([ + outputRule, + { + error: { + message: 'id: "fake_id" not found', + status_code: 404, + }, + rule_id: 'fake_id', // TODO: This should be id and not rule_id in the codebase + }, + ]); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/utils.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/utils.ts new file mode 100644 index 00000000000000..b78073c0e737bd --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/utils.ts @@ -0,0 +1,345 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { OutputRuleAlertRest } from '../../../../legacy/plugins/siem/server/lib/detection_engine/types'; +import { DETECTION_ENGINE_INDEX_URL } from '../../../../legacy/plugins/siem/common/constants'; + +/** + * This will remove server generated properties such as date times, etc... + * @param rule Rule to pass in to remove typical server generated properties + */ +export const removeServerGeneratedProperties = ( + rule: Partial +): Partial => { + const { + created_at, + updated_at, + id, + last_success_at, + last_success_message, + status, + status_date, + ...removedProperties + } = rule; + return removedProperties; +}; + +/** + * This will remove server generated properties such as date times, etc... including the rule_id + * @param rule Rule to pass in to remove typical server generated properties + */ +export const removeServerGeneratedPropertiesIncludingRuleId = ( + rule: Partial +): Partial => { + const ruleWithRemovedProperties = removeServerGeneratedProperties(rule); + const { rule_id, ...additionalRuledIdRemoved } = ruleWithRemovedProperties; + return additionalRuledIdRemoved; +}; + +/** + * This is a typical simple rule for testing that is easy for most basic testing + * @param ruleId + */ +export const getSimpleRule = (ruleId = 'rule-1'): Partial => ({ + name: 'Simple Rule Query', + description: 'Simple Rule Query', + risk_score: 1, + rule_id: ruleId, + severity: 'high', + type: 'query', + query: 'user.name: root or user.name: admin', +}); + +/** + * This is a typical simple rule for testing that is easy for most basic testing + */ +export const getSimpleRuleWithoutRuleId = (): Partial => { + const simpleRule = getSimpleRule(); + const { rule_id, ...ruleWithoutId } = simpleRule; + return ruleWithoutId; +}; + +/** + * Useful for export_api testing to convert from a multi-part binary back to a string + * @param res Response + * @param callback Callback + */ +export const binaryToString = (res: any, callback: any): void => { + res.setEncoding('binary'); + res.data = ''; + res.on('data', (chunk: any) => { + res.data += chunk; + }); + res.on('end', () => { + callback(null, Buffer.from(res.data)); + }); +}; + +/** + * This is the typical output of a simple rule that Kibana will output with all the defaults. + */ +export const getSimpleRuleOutput = (ruleId = 'rule-1'): Partial => ({ + created_by: 'elastic', + description: 'Simple Rule Query', + enabled: true, + false_positives: [], + from: 'now-6m', + immutable: false, + interval: '5m', + rule_id: ruleId, + language: 'kuery', + output_index: '.siem-signals-default', + max_signals: 100, + risk_score: 1, + name: 'Simple Rule Query', + query: 'user.name: root or user.name: admin', + references: [], + severity: 'high', + updated_by: 'elastic', + tags: [], + to: 'now', + type: 'query', + threat: [], + version: 1, +}); + +/** + * This is the typical output of a simple rule that Kibana will output with all the defaults. + */ +export const getSimpleRuleOutputWithoutRuleId = ( + ruleId = 'rule-1' +): Partial => { + const rule = getSimpleRuleOutput(ruleId); + const { rule_id, ...ruleWithoutRuleId } = rule; + return ruleWithoutRuleId; +}; + +/** + * Remove all alerts from the .kibana index + * @param es The ElasticSearch handle + */ +export const deleteAllAlerts = async (es: any): Promise => { + await es.deleteByQuery({ + index: '.kibana', + q: 'type:alert', + waitForCompletion: true, + refresh: 'wait_for', + }); +}; + +/** + * Creates the signals index for use inside of beforeEach blocks of tests + * @param supertest The supertest client library + */ +export const createSignalsIndex = async (supertest: any): Promise => { + await supertest + .post(DETECTION_ENGINE_INDEX_URL) + .set('kbn-xsrf', 'true') + .send() + .expect(200); +}; + +/** + * Deletes the signals index for use inside of afterEach blocks of tests + * @param supertest The supertest client library + */ +export const deleteSignalsIndex = async (supertest: any): Promise => { + await supertest + .delete(DETECTION_ENGINE_INDEX_URL) + .set('kbn-xsrf', 'true') + .send() + .expect(200); +}; + +/** + * Given an array of rule_id strings this will return a ndjson buffer which is useful + * for testing uploads. + * @param ruleIds Array of strings of rule_ids + */ +export const getSimpleRuleAsNdjson = (ruleIds: string[]): Buffer => { + const stringOfRules = ruleIds.map(ruleId => { + const simpleRule = getSimpleRule(ruleId); + return JSON.stringify(simpleRule); + }); + return Buffer.from(stringOfRules.join('\n')); +}; + +/** + * Given a rule this will convert it to an ndjson buffer which is useful for + * testing upload features. + * @param rule The rule to convert to ndjson + */ +export const ruleToNdjson = (rule: Partial): Buffer => { + const stringified = JSON.stringify(rule); + return Buffer.from(`${stringified}\n`); +}; + +/** + * This will return a complex rule with all the outputs possible + * @param ruleId The ruleId to set which is optional and defaults to rule-1 + */ +export const getComplexRule = (ruleId = 'rule-1'): Partial => ({ + name: 'Complex Rule Query', + description: 'Complex Rule Query', + false_positives: [ + 'https://www.example.com/some-article-about-a-false-positive', + 'some text string about why another condition could be a false positive', + ], + risk_score: 1, + rule_id: ruleId, + filters: [ + { + query: { + match_phrase: { + 'host.name': 'siem-windows', + }, + }, + }, + ], + enabled: false, + index: ['auditbeat-*', 'filebeat-*'], + interval: '5m', + output_index: '.siem-signals-default', + meta: { + anything_you_want_ui_related_or_otherwise: { + as_deep_structured_as_you_need: { + any_data_type: {}, + }, + }, + }, + max_signals: 10, + tags: ['tag 1', 'tag 2', 'any tag you want'], + to: 'now', + from: 'now-6m', + severity: 'high', + language: 'kuery', + type: 'query', + threat: [ + { + framework: 'MITRE ATT&CK', + tactic: { + id: 'TA0040', + name: 'impact', + reference: 'https://attack.mitre.org/tactics/TA0040/', + }, + technique: [ + { + id: 'T1499', + name: 'endpoint denial of service', + reference: 'https://attack.mitre.org/techniques/T1499/', + }, + ], + }, + { + framework: 'Some other Framework you want', + tactic: { + id: 'some-other-id', + name: 'Some other name', + reference: 'https://example.com', + }, + technique: [ + { + id: 'some-other-id', + name: 'some other technique name', + reference: 'https://example.com', + }, + ], + }, + ], + references: [ + 'http://www.example.com/some-article-about-attack', + 'Some plain text string here explaining why this is a valid thing to look out for', + ], + timeline_id: 'timeline_id', + timeline_title: 'timeline_title', + version: 1, + query: 'user.name: root or user.name: admin', +}); + +/** + * This will return a complex rule with all the outputs possible + * @param ruleId The ruleId to set which is optional and defaults to rule-1 + */ +export const getComplexRuleOutput = (ruleId = 'rule-1'): Partial => ({ + created_by: 'elastic', + name: 'Complex Rule Query', + description: 'Complex Rule Query', + false_positives: [ + 'https://www.example.com/some-article-about-a-false-positive', + 'some text string about why another condition could be a false positive', + ], + risk_score: 1, + rule_id: ruleId, + filters: [ + { + query: { + match_phrase: { + 'host.name': 'siem-windows', + }, + }, + }, + ], + enabled: false, + index: ['auditbeat-*', 'filebeat-*'], + immutable: false, + interval: '5m', + output_index: '.siem-signals-default', + meta: { + anything_you_want_ui_related_or_otherwise: { + as_deep_structured_as_you_need: { + any_data_type: {}, + }, + }, + }, + max_signals: 10, + tags: ['tag 1', 'tag 2', 'any tag you want'], + to: 'now', + from: 'now-6m', + severity: 'high', + language: 'kuery', + type: 'query', + threat: [ + { + framework: 'MITRE ATT&CK', + tactic: { + id: 'TA0040', + name: 'impact', + reference: 'https://attack.mitre.org/tactics/TA0040/', + }, + technique: [ + { + id: 'T1499', + name: 'endpoint denial of service', + reference: 'https://attack.mitre.org/techniques/T1499/', + }, + ], + }, + { + framework: 'Some other Framework you want', + tactic: { + id: 'some-other-id', + name: 'Some other name', + reference: 'https://example.com', + }, + technique: [ + { + id: 'some-other-id', + name: 'some other technique name', + reference: 'https://example.com', + }, + ], + }, + ], + references: [ + 'http://www.example.com/some-article-about-attack', + 'Some plain text string here explaining why this is a valid thing to look out for', + ], + timeline_id: 'timeline_id', + timeline_title: 'timeline_title', + updated_by: 'elastic', + version: 1, + query: 'user.name: root or user.name: admin', +}); diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts index e8ed54571c77cd..938b98591b6a23 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts @@ -6,141 +6,328 @@ import expect from '@kbn/expect'; import uuid from 'uuid'; +import { omit } from 'lodash'; +import moment from 'moment'; import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ getPageObjects, getService }: FtrProviderContext) => { const testSubjects = getService('testSubjects'); const pageObjects = getPageObjects(['common', 'triggersActionsUI', 'header', 'alertDetailsUI']); const browser = getService('browser'); + const log = getService('log'); const alerting = getService('alerting'); + const retry = getService('retry'); describe('Alert Details', function() { - const testRunUuid = uuid.v4(); - - before(async () => { - await pageObjects.common.navigateToApp('triggersActions'); - - const actions = await Promise.all([ - alerting.actions.createAction({ - name: `server-log-${testRunUuid}-${0}`, - actionTypeId: '.server-log', - config: {}, - secrets: {}, - }), - alerting.actions.createAction({ - name: `server-log-${testRunUuid}-${1}`, - actionTypeId: '.server-log', - config: {}, - secrets: {}, - }), - ]); - - const alert = await alerting.alerts.createAlwaysFiringWithActions( - `test-alert-${testRunUuid}`, - actions.map(action => ({ - id: action.id, - group: 'default', - params: { - message: 'from alert 1s', - level: 'warn', - }, - })) - ); - - // refresh to see alert - await browser.refresh(); - - await pageObjects.header.waitUntilLoadingHasFinished(); - - // Verify content - await testSubjects.existOrFail('alertsList'); - - // click on first alert - await pageObjects.triggersActionsUI.clickOnAlertInAlertsList(alert.name); - }); - - it('renders the alert details', async () => { - const headingText = await pageObjects.alertDetailsUI.getHeadingText(); - expect(headingText).to.be(`test-alert-${testRunUuid}`); - - const alertType = await pageObjects.alertDetailsUI.getAlertType(); - expect(alertType).to.be(`Always Firing`); - - const { actionType, actionCount } = await pageObjects.alertDetailsUI.getActionsLabels(); - expect(actionType).to.be(`Server log`); - expect(actionCount).to.be(`+1`); - }); + describe('Header', function() { + const testRunUuid = uuid.v4(); + before(async () => { + await pageObjects.common.navigateToApp('triggersActions'); + + const actions = await Promise.all([ + alerting.actions.createAction({ + name: `server-log-${testRunUuid}-${0}`, + actionTypeId: '.server-log', + config: {}, + secrets: {}, + }), + alerting.actions.createAction({ + name: `server-log-${testRunUuid}-${1}`, + actionTypeId: '.server-log', + config: {}, + secrets: {}, + }), + ]); + + const alert = await alerting.alerts.createAlwaysFiringWithActions( + `test-alert-${testRunUuid}`, + actions.map(action => ({ + id: action.id, + group: 'default', + params: { + message: 'from alert 1s', + level: 'warn', + }, + })) + ); + + // refresh to see alert + await browser.refresh(); + + await pageObjects.header.waitUntilLoadingHasFinished(); + + // Verify content + await testSubjects.existOrFail('alertsList'); + + // click on first alert + await pageObjects.triggersActionsUI.clickOnAlertInAlertsList(alert.name); + }); + + it('renders the alert details', async () => { + const headingText = await pageObjects.alertDetailsUI.getHeadingText(); + expect(headingText).to.be(`test-alert-${testRunUuid}`); + + const alertType = await pageObjects.alertDetailsUI.getAlertType(); + expect(alertType).to.be(`Always Firing`); + + const { actionType, actionCount } = await pageObjects.alertDetailsUI.getActionsLabels(); + expect(actionType).to.be(`Server log`); + expect(actionCount).to.be(`+1`); + }); + + it('should disable the alert', async () => { + const enableSwitch = await testSubjects.find('enableSwitch'); + + const isChecked = await enableSwitch.getAttribute('aria-checked'); + expect(isChecked).to.eql('true'); + + await enableSwitch.click(); + + const enabledSwitchAfterDisabling = await testSubjects.find('enableSwitch'); + const isCheckedAfterDisabling = await enabledSwitchAfterDisabling.getAttribute( + 'aria-checked' + ); + expect(isCheckedAfterDisabling).to.eql('false'); + }); + + it('shouldnt allow you to mute a disabled alert', async () => { + const disabledEnableSwitch = await testSubjects.find('enableSwitch'); + expect(await disabledEnableSwitch.getAttribute('aria-checked')).to.eql('false'); + + const muteSwitch = await testSubjects.find('muteSwitch'); + expect(await muteSwitch.getAttribute('aria-checked')).to.eql('false'); + + await muteSwitch.click(); + + const muteSwitchAfterTryingToMute = await testSubjects.find('muteSwitch'); + const isDisabledMuteAfterDisabling = await muteSwitchAfterTryingToMute.getAttribute( + 'aria-checked' + ); + expect(isDisabledMuteAfterDisabling).to.eql('false'); + }); + + it('should reenable a disabled the alert', async () => { + const enableSwitch = await testSubjects.find('enableSwitch'); + + const isChecked = await enableSwitch.getAttribute('aria-checked'); + expect(isChecked).to.eql('false'); + + await enableSwitch.click(); + + const enabledSwitchAfterReenabling = await testSubjects.find('enableSwitch'); + const isCheckedAfterDisabling = await enabledSwitchAfterReenabling.getAttribute( + 'aria-checked' + ); + expect(isCheckedAfterDisabling).to.eql('true'); + }); + + it('should mute the alert', async () => { + const muteSwitch = await testSubjects.find('muteSwitch'); + + const isChecked = await muteSwitch.getAttribute('aria-checked'); + expect(isChecked).to.eql('false'); + + await muteSwitch.click(); + + const muteSwitchAfterDisabling = await testSubjects.find('muteSwitch'); + const isCheckedAfterDisabling = await muteSwitchAfterDisabling.getAttribute('aria-checked'); + expect(isCheckedAfterDisabling).to.eql('true'); + }); - it('should disable the alert', async () => { - const enableSwitch = await testSubjects.find('enableSwitch'); + it('should unmute the alert', async () => { + const muteSwitch = await testSubjects.find('muteSwitch'); - const isChecked = await enableSwitch.getAttribute('aria-checked'); - expect(isChecked).to.eql('true'); + const isChecked = await muteSwitch.getAttribute('aria-checked'); + expect(isChecked).to.eql('true'); - await enableSwitch.click(); + await muteSwitch.click(); - const enabledSwitchAfterDisabling = await testSubjects.find('enableSwitch'); - const isCheckedAfterDisabling = await enabledSwitchAfterDisabling.getAttribute( - 'aria-checked' - ); - expect(isCheckedAfterDisabling).to.eql('false'); + const muteSwitchAfterUnmuting = await testSubjects.find('muteSwitch'); + const isCheckedAfterDisabling = await muteSwitchAfterUnmuting.getAttribute('aria-checked'); + expect(isCheckedAfterDisabling).to.eql('false'); + }); }); - it('shouldnt allow you to mute a disabled alert', async () => { - const disabledEnableSwitch = await testSubjects.find('enableSwitch'); - expect(await disabledEnableSwitch.getAttribute('aria-checked')).to.eql('false'); - - const muteSwitch = await testSubjects.find('muteSwitch'); - expect(await muteSwitch.getAttribute('aria-checked')).to.eql('false'); - - await muteSwitch.click(); + describe('Alert Instances', function() { + const testRunUuid = uuid.v4(); + let alert: any; + + before(async () => { + await pageObjects.common.navigateToApp('triggersActions'); + + const actions = await Promise.all([ + alerting.actions.createAction({ + name: `server-log-${testRunUuid}-${0}`, + actionTypeId: '.server-log', + config: {}, + secrets: {}, + }), + alerting.actions.createAction({ + name: `server-log-${testRunUuid}-${1}`, + actionTypeId: '.server-log', + config: {}, + secrets: {}, + }), + ]); + + const instances = [{ id: 'us-central' }, { id: 'us-east' }, { id: 'us-west' }]; + alert = await alerting.alerts.createAlwaysFiringWithActions( + `test-alert-${testRunUuid}`, + actions.map(action => ({ + id: action.id, + group: 'default', + params: { + message: 'from alert 1s', + level: 'warn', + }, + })), + { + instances, + } + ); + + // refresh to see alert + await browser.refresh(); + + await pageObjects.header.waitUntilLoadingHasFinished(); + + // Verify content + await testSubjects.existOrFail('alertsList'); + + // click on first alert + await pageObjects.triggersActionsUI.clickOnAlertInAlertsList(alert.name); + + // await first run to complete so we have an initial state + await retry.try(async () => { + const { alertInstances } = await alerting.alerts.getAlertState(alert.id); + expect(Object.keys(alertInstances).length).to.eql(instances.length); + }); + }); + + it('renders the active alert instances', async () => { + const testBeganAt = moment().utc(); + + // Verify content + await testSubjects.existOrFail('alertInstancesList'); + + const { + alertInstances: { + ['us-central']: { + meta: { + lastScheduledActions: { date }, + }, + }, + }, + } = await alerting.alerts.getAlertState(alert.id); + + const dateOnAllInstances = moment(date) + .utc() + .format('D MMM YYYY @ HH:mm:ss'); + + const instancesList = await pageObjects.alertDetailsUI.getAlertInstancesList(); + expect(instancesList.map(instance => omit(instance, 'duration'))).to.eql([ + { + instance: 'us-central', + status: 'Active', + start: dateOnAllInstances, + }, + { + instance: 'us-east', + status: 'Active', + start: dateOnAllInstances, + }, + { + instance: 'us-west', + status: 'Active', + start: dateOnAllInstances, + }, + ]); + + const durationFromInstanceTillPageLoad = moment.duration( + testBeganAt.diff(moment(date).utc()) + ); + instancesList + .map(alertInstance => alertInstance.duration.split(':').map(part => parseInt(part, 10))) + .map(([hours, minutes, seconds]) => + moment.duration({ + hours, + minutes, + seconds, + }) + ) + .forEach(alertInstanceDuration => { + // make sure the duration is within a 2 second range + expect(alertInstanceDuration.as('milliseconds')).to.greaterThan( + durationFromInstanceTillPageLoad.subtract(1000 * 2).as('milliseconds') + ); + expect(alertInstanceDuration.as('milliseconds')).to.lessThan( + durationFromInstanceTillPageLoad.add(1000 * 2).as('milliseconds') + ); + }); + }); + + it('renders the muted inactive alert instances', async () => { + // mute an alert instance that doesn't exist + await alerting.alerts.muteAlertInstance(alert.id, 'eu-east'); + + // refresh to see alert + await browser.refresh(); + + const instancesList = await pageObjects.alertDetailsUI.getAlertInstancesList(); + expect(instancesList.filter(alertInstance => alertInstance.instance === 'eu-east')).to.eql([ + { + instance: 'eu-east', + status: 'Inactive', + start: '', + duration: '', + }, + ]); + }); - const muteSwitchAfterTryingToMute = await testSubjects.find('muteSwitch'); - const isDisabledMuteAfterDisabling = await muteSwitchAfterTryingToMute.getAttribute( - 'aria-checked' - ); - expect(isDisabledMuteAfterDisabling).to.eql('false'); - }); + it('allows the user to mute a specific instance', async () => { + // Verify content + await testSubjects.existOrFail('alertInstancesList'); - it('should reenable a disabled the alert', async () => { - const enableSwitch = await testSubjects.find('enableSwitch'); + log.debug(`Ensuring us-central is not muted`); + await pageObjects.alertDetailsUI.ensureAlertInstanceMute('us-central', false); - const isChecked = await enableSwitch.getAttribute('aria-checked'); - expect(isChecked).to.eql('false'); + log.debug(`Muting us-central`); + await pageObjects.alertDetailsUI.clickAlertInstanceMuteButton('us-central'); - await enableSwitch.click(); + log.debug(`Ensuring us-central is muted`); + await pageObjects.alertDetailsUI.ensureAlertInstanceMute('us-central', true); + }); - const enabledSwitchAfterReenabling = await testSubjects.find('enableSwitch'); - const isCheckedAfterDisabling = await enabledSwitchAfterReenabling.getAttribute( - 'aria-checked' - ); - expect(isCheckedAfterDisabling).to.eql('true'); - }); + it('allows the user to unmute a specific instance', async () => { + // Verify content + await testSubjects.existOrFail('alertInstancesList'); - it('should mute the alert', async () => { - const muteSwitch = await testSubjects.find('muteSwitch'); + log.debug(`Ensuring us-east is not muted`); + await pageObjects.alertDetailsUI.ensureAlertInstanceMute('us-east', false); - const isChecked = await muteSwitch.getAttribute('aria-checked'); - expect(isChecked).to.eql('false'); + log.debug(`Muting us-east`); + await pageObjects.alertDetailsUI.clickAlertInstanceMuteButton('us-east'); - await muteSwitch.click(); + log.debug(`Ensuring us-east is muted`); + await pageObjects.alertDetailsUI.ensureAlertInstanceMute('us-east', true); - const muteSwitchAfterDisabling = await testSubjects.find('muteSwitch'); - const isCheckedAfterDisabling = await muteSwitchAfterDisabling.getAttribute('aria-checked'); - expect(isCheckedAfterDisabling).to.eql('true'); - }); + log.debug(`Unmuting us-east`); + await pageObjects.alertDetailsUI.clickAlertInstanceMuteButton('us-east'); - it('should unmute the alert', async () => { - const muteSwitch = await testSubjects.find('muteSwitch'); + log.debug(`Ensuring us-east is not muted`); + await pageObjects.alertDetailsUI.ensureAlertInstanceMute('us-east', false); + }); - const isChecked = await muteSwitch.getAttribute('aria-checked'); - expect(isChecked).to.eql('true'); + it('allows the user unmute an inactive instance', async () => { + log.debug(`Ensuring eu-east is muted`); + await pageObjects.alertDetailsUI.ensureAlertInstanceMute('eu-east', true); - await muteSwitch.click(); + log.debug(`Unmuting eu-east`); + await pageObjects.alertDetailsUI.clickAlertInstanceMuteButton('eu-east'); - const muteSwitchAfterUnmuting = await testSubjects.find('muteSwitch'); - const isCheckedAfterDisabling = await muteSwitchAfterUnmuting.getAttribute('aria-checked'); - expect(isCheckedAfterDisabling).to.eql('false'); + log.debug(`Ensuring eu-east is removed from list`); + await pageObjects.alertDetailsUI.ensureAlertInstanceExistance('eu-east', false); + }); }); }); }; diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/index.ts b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/index.ts index 6dd603a53bb0a1..0c8f94c01c7d59 100644 --- a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/index.ts +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/index.ts @@ -35,14 +35,15 @@ function createAlwaysFiringAlertType(setupContract: any) { name: 'Always Firing', actionGroups: { default: 'Default', other: 'Other' }, async executor(alertExecutorOptions: any) { - const { services, state } = alertExecutorOptions; + const { services, state, params } = alertExecutorOptions; + + (params.instances || []).forEach((instance: { id: string; state: any }) => { + services + .alertInstanceFactory(instance.id) + .replaceState({ instanceStateValue: true, ...(instance.state || {}) }) + .scheduleActions('default'); + }); - services - .alertInstanceFactory('1') - .replaceState({ instanceStateValue: true }) - .scheduleActions('default', { - instanceContextValue: true, - }); return { globalStateValue: true, groupInSeriesIndex: (state.groupInSeriesIndex || 0) + 1, diff --git a/x-pack/test/functional_with_es_ssl/page_objects/alert_details.ts b/x-pack/test/functional_with_es_ssl/page_objects/alert_details.ts index 6d2038a6ba04c3..fd936b37386770 100644 --- a/x-pack/test/functional_with_es_ssl/page_objects/alert_details.ts +++ b/x-pack/test/functional_with_es_ssl/page_objects/alert_details.ts @@ -4,10 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ +import expect from '@kbn/expect'; import { FtrProviderContext } from '../ftr_provider_context'; export function AlertDetailsPageProvider({ getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); + const find = getService('find'); + const log = getService('log'); + const retry = getService('retry'); return { async getHeadingText() { @@ -22,5 +26,71 @@ export function AlertDetailsPageProvider({ getService }: FtrProviderContext) { actionCount: await testSubjects.getVisibleText('actionCountLabel'), }; }, + async getAlertInstancesList() { + const table = await find.byCssSelector( + '.euiBasicTable[data-test-subj="alertInstancesList"]:not(.euiBasicTable-loading)' + ); + const $ = await table.parseDomContent(); + return $.findTestSubjects('alert-instance-row') + .toArray() + .map(row => { + return { + instance: $(row) + .findTestSubject('alertInstancesTableCell-instance') + .find('.euiTableCellContent') + .text(), + status: $(row) + .findTestSubject('alertInstancesTableCell-status') + .find('.euiTableCellContent') + .text(), + start: $(row) + .findTestSubject('alertInstancesTableCell-start') + .find('.euiTableCellContent') + .text(), + duration: $(row) + .findTestSubject('alertInstancesTableCell-duration') + .find('.euiTableCellContent') + .text(), + }; + }); + }, + async clickAlertInstanceMuteButton(instance: string) { + const muteAlertInstanceButton = await testSubjects.find( + `muteAlertInstanceButton_${instance}` + ); + await muteAlertInstanceButton.click(); + }, + async ensureAlertInstanceMute(instance: string, isMuted: boolean) { + await retry.try(async () => { + const muteAlertInstanceButton = await testSubjects.find( + `muteAlertInstanceButton_${instance}` + ); + log.debug(`checked:${await muteAlertInstanceButton.getAttribute('checked')}`); + expect(await muteAlertInstanceButton.getAttribute('checked')).to.eql( + isMuted ? 'true' : null + ); + + expect(await testSubjects.exists(`mutedAlertInstanceLabel_${instance}`)).to.eql(isMuted); + }); + }, + async ensureAlertInstanceExistance(instance: string, shouldExist: boolean) { + await retry.try(async () => { + const table = await find.byCssSelector( + '.euiBasicTable[data-test-subj="alertInstancesList"]:not(.euiBasicTable-loading)' + ); + const $ = await table.parseDomContent(); + expect( + $.findTestSubjects('alert-instance-row') + .toArray() + .filter( + row => + $(row) + .findTestSubject('alertInstancesTableCell-instance') + .find('.euiTableCellContent') + .text() === instance + ) + ).to.eql(shouldExist ? 1 : 0); + }); + }, }; } diff --git a/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts b/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts index 1a31d4796d5bc4..695751cf5ac490 100644 --- a/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts +++ b/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts @@ -28,7 +28,8 @@ export class Alerts { id: string; group: string; params: Record; - }> + }>, + params: Record = {} ) { this.log.debug(`creating alert ${name}`); @@ -41,7 +42,7 @@ export class Alerts { schedule: { interval: '1m' }, throttle: '1m', actions, - params: {}, + params, }); if (status !== 200) { throw new Error( @@ -76,4 +77,25 @@ export class Alerts { } this.log.debug(`deleted alert ${alert.id}`); } + + public async getAlertState(id: string) { + this.log.debug(`getting alert ${id} state`); + + const { data } = await this.axios.get(`/api/alert/${id}/state`); + return data; + } + + public async muteAlertInstance(id: string, instanceId: string) { + this.log.debug(`muting instance ${instanceId} under alert ${id}`); + + const { data: alert, status, statusText } = await this.axios.post( + `/api/alert/${id}/alert_instance/${instanceId}/_mute` + ); + if (status !== 204) { + throw new Error( + `Expected status code of 204, received ${status} ${statusText}: ${util.inspect(alert)}` + ); + } + this.log.debug(`muted alert instance ${instanceId}`); + } } diff --git a/yarn.lock b/yarn.lock index e1b39e85d17f16..491e8ab8cf95d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18856,9 +18856,9 @@ kind-of@^5.0.0, kind-of@^5.0.2: integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw== kind-of@^6.0.0, kind-of@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051" - integrity sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA== + version "6.0.3" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" + integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== klaw@^1.0.0: version "1.3.1"