From 5d333d5de00c4eff97207dfdc1c8feb03831e848 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Mon, 24 Feb 2020 19:53:17 -0500 Subject: [PATCH] [7.6] [SIEM] Detections container/rules unit tests (#58055) (#58138) * [SIEM] Detections container/rules unit tests (#58055) * add unit test for rules api * add unit test for useFetchIndexPatterns * fix useFetchIndexPatterns and add unit test for usePersistRule * add more unit test for container/rules * review Co-authored-by: Elastic Machine * fix types + adapt test to the old fetch way Co-authored-by: Elastic Machine --- .../detection_engine/rules/__mocks__/api.ts | 81 ++ .../detection_engine/rules/api.test.ts | 876 ++++++++++++++++++ .../containers/detection_engine/rules/api.ts | 1 - .../rules/fetch_index_patterns.test.tsx | 460 +++++++++ .../rules/fetch_index_patterns.tsx | 2 +- .../containers/detection_engine/rules/mock.ts | 139 +++ .../rules/persist_rule.test.tsx | 44 + .../detection_engine/rules/persist_rule.tsx | 5 +- .../detection_engine/rules/types.ts | 11 +- .../rules/use_pre_packaged_rules.test.tsx | 267 ++++++ .../rules/use_pre_packaged_rules.tsx | 8 +- .../detection_engine/rules/use_rule.test.tsx | 84 ++ .../detection_engine/rules/use_rule.tsx | 5 +- .../rules/use_rule_status.test.tsx | 65 ++ .../rules/use_rule_status.tsx | 5 +- .../detection_engine/rules/use_rules.test.tsx | 216 +++++ .../detection_engine/rules/use_rules.tsx | 7 +- .../detection_engine/rules/use_tags.test.tsx | 29 + .../detection_engine/rules/use_tags.tsx | 4 +- .../plugins/siem/public/mock/hook_wrapper.tsx | 1 + 20 files changed, 2292 insertions(+), 18 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/__mocks__/api.ts create mode 100644 x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts create mode 100644 x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/fetch_index_patterns.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/mock.ts create mode 100644 x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/persist_rule.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_pre_packaged_rules.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rules.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_tags.test.tsx diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/__mocks__/api.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/__mocks__/api.ts new file mode 100644 index 0000000000000..9f37f3fecd508 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/__mocks__/api.ts @@ -0,0 +1,81 @@ +/* + * 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 { + AddRulesProps, + NewRule, + PrePackagedRulesStatusResponse, + BasicFetchProps, + RuleStatusResponse, + Rule, + FetchRuleProps, + FetchRulesResponse, + FetchRulesProps, +} from '../types'; +import { ruleMock, savedRuleMock, rulesMock } from '../mock'; + +export const addRule = async ({ rule, signal }: AddRulesProps): Promise => + Promise.resolve(ruleMock); + +export const getPrePackagedRulesStatus = async ({ + signal, +}: { + signal: AbortSignal; +}): Promise => + Promise.resolve({ + rules_custom_installed: 33, + rules_installed: 12, + rules_not_installed: 0, + rules_not_updated: 0, + }); + +export const createPrepackagedRules = async ({ signal }: BasicFetchProps): Promise => + Promise.resolve(true); + +export const getRuleStatusById = async ({ + id, + signal, +}: { + id: string; + signal: AbortSignal; +}): Promise => + Promise.resolve({ + myOwnRuleID: { + current_status: { + alert_id: 'alertId', + status_date: 'mm/dd/yyyyTHH:MM:sssz', + status: 'succeeded', + last_failure_at: null, + last_success_at: 'mm/dd/yyyyTHH:MM:sssz', + last_failure_message: null, + last_success_message: 'it is a success', + }, + failures: [], + }, + }); + +export const fetchRuleById = async ({ id, signal }: FetchRuleProps): Promise => + Promise.resolve(savedRuleMock); + +export const fetchRules = async ({ + filterOptions = { + filter: '', + sortField: 'enabled', + sortOrder: 'desc', + showCustomRules: false, + showElasticRules: false, + tags: [], + }, + pagination = { + page: 1, + perPage: 20, + total: 0, + }, + signal, +}: FetchRulesProps): Promise => Promise.resolve(rulesMock); + +export const fetchTags = async ({ signal }: { signal: AbortSignal }): Promise => + Promise.resolve(['elastic', 'love', 'quality', 'code']); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts new file mode 100644 index 0000000000000..35ee01dd7e4f1 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts @@ -0,0 +1,876 @@ +/* + * 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 { + addRule, + fetchRules, + fetchRuleById, + enableRules, + deleteRules, + duplicateRules, + createPrepackagedRules, + importRules, + exportRules, + getRuleStatusById, + fetchTags, + getPrePackagedRulesStatus, +} from './api'; +import { ruleMock, rulesMock } from './mock'; +import { ToasterErrors } from '../../../components/ml/api/throw_if_not_ok'; +import { globalNode } from '../../../mock'; + +const abortCtrl = new AbortController(); +jest.mock('ui/chrome', () => ({ + getBasePath: () => { + return ''; + }, + getUiSettingsClient: () => ({ + get: jest.fn(), + }), +})); + +const mockfetchSuccess = (body: unknown, fetchMock?: jest.Mock) => { + if (fetchMock) { + globalNode.window.fetch = fetchMock; + } else { + globalNode.window.fetch = () => ({ + ok: true, + message: 'success', + text: 'success', + body, + json: () => body, + blob: () => body, + }); + } +}; + +const mockfetchError = () => { + globalNode.window.fetch = () => ({ + ok: false, + text: () => + JSON.stringify({ + message: 'super mega error, it is not that bad', + }), + body: null, + }); +}; + +describe('Detections Rules API', () => { + const fetchMock = jest.fn(); + describe('addRule', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockImplementation(() => ({ + ok: true, + message: 'success', + text: 'success', + body: ruleMock, + json: () => ruleMock, + })); + }); + test('check parameter url, body', async () => { + mockfetchSuccess(null, fetchMock); + + await addRule({ rule: ruleMock, signal: abortCtrl.signal }); + expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules', { + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': 'true', + }, + body: + '{"description":"some desc","enabled":true,"false_positives":[],"filters":[],"from":"now-360s","index":["apm-*-transaction*","auditbeat-*","endgame-*","filebeat-*","packetbeat-*","winlogbeat-*"],"interval":"5m","rule_id":"bbd3106e-b4b5-4d7c-a1a2-47531d6a2baf","language":"kuery","risk_score":75,"name":"Test rule","query":"user.email: \'root@elastic.co\'","references":[],"severity":"high","tags":["APM"],"to":"now","type":"query","threat":[]}', + method: 'POST', + signal: abortCtrl.signal, + }); + }); + test('happy path', async () => { + mockfetchSuccess(ruleMock); + const ruleResp = await addRule({ rule: ruleMock, signal: abortCtrl.signal }); + expect(ruleResp).toEqual(ruleMock); + }); + test('unhappy path', async () => { + mockfetchError(); + try { + await addRule({ rule: ruleMock, signal: abortCtrl.signal }); + } catch (exp) { + expect(exp).toBeInstanceOf(ToasterErrors); + expect(exp.message).toEqual('super mega error, it is not that bad'); + } + }); + }); + + describe('fetchRules', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockImplementation(() => ({ + ok: true, + message: 'success', + text: 'success', + body: rulesMock, + json: () => rulesMock, + })); + }); + + test('check parameter url, query without any options', async () => { + mockfetchSuccess(null, fetchMock); + + await fetchRules({ signal: abortCtrl.signal }); + expect(fetchMock).toHaveBeenCalledWith( + '/api/detection_engine/rules/_find?page=1&per_page=20&sort_field=enabled&sort_order=desc', + { + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': 'true', + }, + method: 'GET', + signal: abortCtrl.signal, + } + ); + }); + + test('check parameter url, query with a filter', async () => { + mockfetchSuccess(null, fetchMock); + + await fetchRules({ + filterOptions: { + filter: 'hello world', + sortField: 'enabled', + sortOrder: 'desc', + showCustomRules: false, + showElasticRules: false, + tags: [], + }, + signal: abortCtrl.signal, + }); + expect(fetchMock).toHaveBeenCalledWith( + '/api/detection_engine/rules/_find?page=1&per_page=20&sort_field=enabled&sort_order=desc&filter=alert.attributes.name:%20hello%20world', + { + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': 'true', + }, + method: 'GET', + signal: abortCtrl.signal, + } + ); + }); + + test('check parameter url, query with showCustomRules', async () => { + mockfetchSuccess(null, fetchMock); + + await fetchRules({ + filterOptions: { + filter: '', + sortField: 'enabled', + sortOrder: 'desc', + showCustomRules: true, + showElasticRules: false, + tags: [], + }, + signal: abortCtrl.signal, + }); + expect(fetchMock).toHaveBeenCalledWith( + '/api/detection_engine/rules/_find?page=1&per_page=20&sort_field=enabled&sort_order=desc&filter=alert.attributes.tags:%20%22__internal_immutable:false%22', + { + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': 'true', + }, + method: 'GET', + signal: abortCtrl.signal, + } + ); + }); + + test('check parameter url, query with showElasticRules', async () => { + mockfetchSuccess(null, fetchMock); + + await fetchRules({ + filterOptions: { + filter: '', + sortField: 'enabled', + sortOrder: 'desc', + showCustomRules: false, + showElasticRules: true, + tags: [], + }, + signal: abortCtrl.signal, + }); + expect(fetchMock).toHaveBeenCalledWith( + '/api/detection_engine/rules/_find?page=1&per_page=20&sort_field=enabled&sort_order=desc&filter=alert.attributes.tags:%20%22__internal_immutable:true%22', + { + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': 'true', + }, + method: 'GET', + signal: abortCtrl.signal, + } + ); + }); + + test('check parameter url, query with tags', async () => { + mockfetchSuccess(null, fetchMock); + + await fetchRules({ + filterOptions: { + filter: '', + sortField: 'enabled', + sortOrder: 'desc', + showCustomRules: false, + showElasticRules: false, + tags: ['hello', 'world'], + }, + signal: abortCtrl.signal, + }); + expect(fetchMock).toHaveBeenCalledWith( + '/api/detection_engine/rules/_find?page=1&per_page=20&sort_field=enabled&sort_order=desc&filter=alert.attributes.tags:hello%20AND%20alert.attributes.tags:world', + { + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': 'true', + }, + method: 'GET', + signal: abortCtrl.signal, + } + ); + }); + + test('check parameter url, query with all options', async () => { + mockfetchSuccess(null, fetchMock); + + await fetchRules({ + filterOptions: { + filter: 'ruleName', + sortField: 'enabled', + sortOrder: 'desc', + showCustomRules: true, + showElasticRules: true, + tags: ['hello', 'world'], + }, + signal: abortCtrl.signal, + }); + expect(fetchMock).toHaveBeenCalledWith( + '/api/detection_engine/rules/_find?page=1&per_page=20&sort_field=enabled&sort_order=desc&filter=alert.attributes.name:%20ruleName%20AND%20alert.attributes.tags:%20%22__internal_immutable:false%22%20AND%20alert.attributes.tags:%20%22__internal_immutable:true%22%20AND%20alert.attributes.tags:hello%20AND%20alert.attributes.tags:world', + { + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': 'true', + }, + method: 'GET', + + signal: abortCtrl.signal, + } + ); + }); + + test('happy path', async () => { + mockfetchSuccess(rulesMock); + + const rulesResp = await fetchRules({ signal: abortCtrl.signal }); + expect(rulesResp).toEqual(rulesMock); + }); + test('unhappy path', async () => { + mockfetchError(); + try { + await fetchRules({ signal: abortCtrl.signal }); + } catch (exp) { + expect(exp).toBeInstanceOf(ToasterErrors); + expect(exp.message).toEqual('super mega error, it is not that bad'); + } + }); + }); + + describe('fetchRuleById', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockImplementation(() => ({ + ok: true, + message: 'success', + text: 'success', + body: ruleMock, + json: () => ruleMock, + })); + }); + test('check parameter url, query', async () => { + mockfetchSuccess(null, fetchMock); + + await fetchRuleById({ id: 'mySuperRuleId', signal: abortCtrl.signal }); + expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules?id=mySuperRuleId', { + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': 'true', + }, + method: 'GET', + signal: abortCtrl.signal, + }); + }); + test('happy path', async () => { + mockfetchSuccess(ruleMock); + const ruleResp = await fetchRuleById({ id: 'mySuperRuleId', signal: abortCtrl.signal }); + expect(ruleResp).toEqual(ruleMock); + }); + test('unhappy path', async () => { + mockfetchError(); + try { + await fetchRuleById({ id: 'mySuperRuleId', signal: abortCtrl.signal }); + } catch (exp) { + expect(exp).toBeInstanceOf(ToasterErrors); + expect(exp.message).toEqual('super mega error, it is not that bad'); + } + }); + }); + + describe('enableRules', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockImplementation(() => ({ + ok: true, + message: 'success', + text: 'success', + body: ruleMock, + json: () => ruleMock, + })); + }); + test('check parameter url, body when enabling rules', async () => { + mockfetchSuccess(null, fetchMock); + + await enableRules({ ids: ['mySuperRuleId', 'mySuperRuleId_II'], enabled: true }); + expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_bulk_update', { + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': 'true', + }, + body: '[{"id":"mySuperRuleId","enabled":true},{"id":"mySuperRuleId_II","enabled":true}]', + method: 'PATCH', + }); + }); + test('check parameter url, body when disabling rules', async () => { + mockfetchSuccess(null, fetchMock); + + await enableRules({ ids: ['mySuperRuleId', 'mySuperRuleId_II'], enabled: false }); + expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_bulk_update', { + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': 'true', + }, + body: '[{"id":"mySuperRuleId","enabled":false},{"id":"mySuperRuleId_II","enabled":false}]', + method: 'PATCH', + }); + }); + test('happy path', async () => { + mockfetchSuccess(rulesMock.data); + const ruleResp = await enableRules({ + ids: ['mySuperRuleId', 'mySuperRuleId_II'], + enabled: true, + }); + expect(ruleResp).toEqual(rulesMock.data); + }); + test('unhappy path', async () => { + mockfetchError(); + try { + await enableRules({ ids: ['mySuperRuleId', 'mySuperRuleId_II'], enabled: true }); + } catch (exp) { + expect(exp).toBeInstanceOf(ToasterErrors); + expect(exp.message).toEqual('super mega error, it is not that bad'); + } + }); + }); + + describe('deleteRules', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockImplementation(() => ({ + ok: true, + message: 'success', + text: 'success', + body: ruleMock, + json: () => ruleMock, + })); + }); + test('check parameter url, body when deleting rules', async () => { + mockfetchSuccess(null, fetchMock); + + await deleteRules({ ids: ['mySuperRuleId', 'mySuperRuleId_II'] }); + expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_bulk_delete', { + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': 'true', + }, + body: '[{"id":"mySuperRuleId"},{"id":"mySuperRuleId_II"}]', + method: 'DELETE', + }); + }); + test('happy path', async () => { + mockfetchSuccess(ruleMock); + const ruleResp = await deleteRules({ + ids: ['mySuperRuleId', 'mySuperRuleId_II'], + }); + expect(ruleResp).toEqual(ruleMock); + }); + test('unhappy path', async () => { + mockfetchError(); + try { + await deleteRules({ ids: ['mySuperRuleId', 'mySuperRuleId_II'] }); + } catch (exp) { + expect(exp).toBeInstanceOf(ToasterErrors); + expect(exp.message).toEqual('super mega error, it is not that bad'); + } + }); + }); + + describe('duplicateRules', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockImplementation(() => ({ + ok: true, + message: 'success', + text: 'success', + body: ruleMock, + json: () => ruleMock, + })); + }); + test('check parameter url, body when duplicating rules', async () => { + mockfetchSuccess(null, fetchMock); + + await duplicateRules({ rules: rulesMock.data }); + expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_bulk_create', { + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': 'true', + }, + body: + '[{"description":"Elastic Endpoint detected Credential Dumping. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.","enabled":false,"false_positives":[],"from":"now-660s","index":["endgame-*"],"interval":"10m","language":"kuery","output_index":".siem-signals-default","max_signals":100,"risk_score":73,"name":"Credential Dumping - Detected - Elastic Endpoint [Duplicate]","query":"event.kind:alert and event.module:endgame and event.action:cred_theft_event and endgame.metadata.type:detection","filters":[],"references":[],"severity":"high","tags":["Elastic","Endpoint"],"to":"now","type":"query","threat":[],"version":1},{"description":"Elastic Endpoint detected an Adversary Behavior. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.","enabled":false,"false_positives":[],"from":"now-660s","index":["endgame-*"],"interval":"10m","language":"kuery","output_index":".siem-signals-default","max_signals":100,"risk_score":47,"name":"Adversary Behavior - Detected - Elastic Endpoint [Duplicate]","query":"event.kind:alert and event.module:endgame and event.action:rules_engine_event","filters":[],"references":[],"severity":"medium","tags":["Elastic","Endpoint"],"to":"now","type":"query","threat":[],"version":1}]', + method: 'POST', + }); + }); + test('happy path', async () => { + mockfetchSuccess(rulesMock.data); + const ruleResp = await duplicateRules({ rules: rulesMock.data }); + expect(ruleResp).toEqual(rulesMock.data); + }); + test('unhappy path', async () => { + mockfetchError(); + try { + await duplicateRules({ rules: rulesMock.data }); + } catch (exp) { + expect(exp).toBeInstanceOf(ToasterErrors); + expect(exp.message).toEqual('super mega error, it is not that bad'); + } + }); + }); + + describe('createPrepackagedRules', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockImplementation(() => ({ + ok: true, + message: 'success', + text: 'success', + body: ruleMock, + json: () => ruleMock, + })); + }); + test('check parameter url when creating pre-packaged rules', async () => { + mockfetchSuccess(null, fetchMock); + + await createPrepackagedRules({ signal: abortCtrl.signal }); + expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/prepackaged', { + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': 'true', + }, + signal: abortCtrl.signal, + method: 'PUT', + }); + }); + test('happy path', async () => { + mockfetchSuccess(true); + const resp = await createPrepackagedRules({ signal: abortCtrl.signal }); + expect(resp).toEqual(true); + }); + test('unhappy path', async () => { + mockfetchError(); + try { + await createPrepackagedRules({ signal: abortCtrl.signal }); + } catch (exp) { + expect(exp).toBeInstanceOf(ToasterErrors); + expect(exp.message).toEqual('super mega error, it is not that bad'); + } + }); + }); + + describe('importRules', () => { + const fileToImport: File = { + lastModified: 33, + name: 'fileToImport', + size: 89, + type: 'json', + arrayBuffer: jest.fn(), + slice: jest.fn(), + stream: jest.fn(), + text: jest.fn(), + } as File; + const formData = new FormData(); + formData.append('file', fileToImport); + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockImplementation(() => ({ + ok: true, + message: 'success', + text: 'success', + body: ruleMock, + json: () => ruleMock, + })); + }); + test('check parameter url, body and query when importing rules', async () => { + mockfetchSuccess(null, fetchMock); + await importRules({ fileToImport, signal: abortCtrl.signal }); + expect(fetchMock).toHaveBeenCalledWith( + '/api/detection_engine/rules/_import?overwrite=false', + { + credentials: 'same-origin', + headers: { + 'kbn-xsrf': 'true', + }, + signal: abortCtrl.signal, + method: 'POST', + body: formData, + } + ); + }); + + test('check parameter url, body and query when importing rules with overwrite', async () => { + mockfetchSuccess(null, fetchMock); + + await importRules({ fileToImport, overwrite: true, signal: abortCtrl.signal }); + expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_import?overwrite=true', { + credentials: 'same-origin', + headers: { + 'kbn-xsrf': 'true', + }, + signal: abortCtrl.signal, + method: 'POST', + body: formData, + }); + }); + + test('happy path', async () => { + mockfetchSuccess({ + success: true, + success_count: 33, + errors: [], + }); + const resp = await importRules({ fileToImport, signal: abortCtrl.signal }); + expect(resp).toEqual({ + success: true, + success_count: 33, + errors: [], + }); + }); + + test('unhappy path', async () => { + mockfetchError(); + try { + await importRules({ fileToImport, signal: abortCtrl.signal }); + } catch (exp) { + expect(exp).toBeInstanceOf(ToasterErrors); + expect(exp.message).toEqual('super mega error, it is not that bad'); + } + }); + }); + + describe('exportRules', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockImplementation(() => ({ + ok: true, + message: 'success', + text: 'success', + body: ruleMock, + json: () => ruleMock, + blob: () => ruleMock, + })); + }); + + test('check parameter url, body and query when exporting rules', async () => { + mockfetchSuccess(null, fetchMock); + await exportRules({ + ruleIds: ['mySuperRuleId', 'mySuperRuleId_II'], + signal: abortCtrl.signal, + }); + expect(fetchMock).toHaveBeenCalledWith( + '/api/detection_engine/rules/_export?exclude_export_details=false&file_name=rules_export.ndjson', + { + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': 'true', + }, + signal: abortCtrl.signal, + method: 'POST', + body: '{"objects":[{"rule_id":"mySuperRuleId"},{"rule_id":"mySuperRuleId_II"}]}', + } + ); + }); + + test('check parameter url, body and query when exporting rules with excludeExportDetails', async () => { + mockfetchSuccess(null, fetchMock); + await exportRules({ + excludeExportDetails: true, + ruleIds: ['mySuperRuleId', 'mySuperRuleId_II'], + signal: abortCtrl.signal, + }); + expect(fetchMock).toHaveBeenCalledWith( + '/api/detection_engine/rules/_export?exclude_export_details=true&file_name=rules_export.ndjson', + { + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': 'true', + }, + signal: abortCtrl.signal, + method: 'POST', + body: '{"objects":[{"rule_id":"mySuperRuleId"},{"rule_id":"mySuperRuleId_II"}]}', + } + ); + }); + + test('check parameter url, body and query when exporting rules with fileName', async () => { + mockfetchSuccess(null, fetchMock); + await exportRules({ + filename: 'myFileName.ndjson', + ruleIds: ['mySuperRuleId', 'mySuperRuleId_II'], + signal: abortCtrl.signal, + }); + expect(fetchMock).toHaveBeenCalledWith( + '/api/detection_engine/rules/_export?exclude_export_details=false&file_name=myFileName.ndjson', + { + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': 'true', + }, + signal: abortCtrl.signal, + method: 'POST', + body: '{"objects":[{"rule_id":"mySuperRuleId"},{"rule_id":"mySuperRuleId_II"}]}', + } + ); + }); + + test('check parameter url, body and query when exporting rules with all options', async () => { + mockfetchSuccess(null, fetchMock); + await exportRules({ + excludeExportDetails: true, + filename: 'myFileName.ndjson', + ruleIds: ['mySuperRuleId', 'mySuperRuleId_II'], + signal: abortCtrl.signal, + }); + expect(fetchMock).toHaveBeenCalledWith( + '/api/detection_engine/rules/_export?exclude_export_details=true&file_name=myFileName.ndjson', + { + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': 'true', + }, + signal: abortCtrl.signal, + method: 'POST', + body: '{"objects":[{"rule_id":"mySuperRuleId"},{"rule_id":"mySuperRuleId_II"}]}', + } + ); + }); + + test('happy path', async () => { + const blob: Blob = { + size: 89, + type: 'json', + arrayBuffer: jest.fn(), + slice: jest.fn(), + stream: jest.fn(), + text: jest.fn(), + } as Blob; + mockfetchSuccess(blob); + const resp = await exportRules({ + ruleIds: ['mySuperRuleId', 'mySuperRuleId_II'], + signal: abortCtrl.signal, + }); + expect(resp).toEqual(blob); + }); + + test('unhappy path', async () => { + mockfetchError(); + try { + await exportRules({ + ruleIds: ['mySuperRuleId', 'mySuperRuleId_II'], + signal: abortCtrl.signal, + }); + } catch (exp) { + expect(exp).toBeInstanceOf(ToasterErrors); + expect(exp.message).toEqual('super mega error, it is not that bad'); + } + }); + }); + + describe('getRuleStatusById', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockImplementation(() => ({ + ok: true, + message: 'success', + text: 'success', + body: ruleMock, + json: () => ruleMock, + })); + }); + test('check parameter url, query', async () => { + mockfetchSuccess(null, fetchMock); + + await getRuleStatusById({ id: 'mySuperRuleId', signal: abortCtrl.signal }); + expect(fetchMock).toHaveBeenCalledWith( + '/api/detection_engine/rules/_find_statuses?ids=%5B%22mySuperRuleId%22%5D', + { + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': 'true', + }, + method: 'GET', + signal: abortCtrl.signal, + } + ); + }); + test('happy path', async () => { + const statusMock = { + myRule: { + current_status: { + alert_id: 'alertId', + status_date: 'mm/dd/yyyyTHH:MM:sssz', + status: 'succeeded', + last_failure_at: null, + last_success_at: 'mm/dd/yyyyTHH:MM:sssz', + last_failure_message: null, + last_success_message: 'it is a success', + }, + failures: [], + }, + }; + mockfetchSuccess(statusMock); + const ruleResp = await getRuleStatusById({ id: 'mySuperRuleId', signal: abortCtrl.signal }); + expect(ruleResp).toEqual(statusMock); + }); + test('unhappy path', async () => { + mockfetchError(); + try { + await getRuleStatusById({ id: 'mySuperRuleId', signal: abortCtrl.signal }); + } catch (exp) { + expect(exp).toBeInstanceOf(ToasterErrors); + expect(exp.message).toEqual('super mega error, it is not that bad'); + } + }); + }); + + describe('fetchTags', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockImplementation(() => ({ + ok: true, + message: 'success', + text: 'success', + body: ruleMock, + json: () => ruleMock, + })); + }); + test('check parameter url when fetching tags', async () => { + mockfetchSuccess(null, fetchMock); + + await fetchTags({ signal: abortCtrl.signal }); + expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/tags', { + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': 'true', + }, + signal: abortCtrl.signal, + method: 'GET', + }); + }); + test('happy path', async () => { + mockfetchSuccess(['hello', 'tags']); + const resp = await fetchTags({ signal: abortCtrl.signal }); + expect(resp).toEqual(['hello', 'tags']); + }); + test('unhappy path', async () => { + mockfetchError(); + try { + await fetchTags({ signal: abortCtrl.signal }); + } catch (exp) { + expect(exp).toBeInstanceOf(ToasterErrors); + expect(exp.message).toEqual('super mega error, it is not that bad'); + } + }); + }); + + describe('getPrePackagedRulesStatus', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockImplementation(() => ({ + ok: true, + message: 'success', + text: 'success', + body: ruleMock, + json: () => ruleMock, + })); + }); + test('check parameter url when fetching tags', async () => { + mockfetchSuccess(null, fetchMock); + + await getPrePackagedRulesStatus({ signal: abortCtrl.signal }); + expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/prepackaged/_status', { + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': 'true', + }, + signal: abortCtrl.signal, + method: 'GET', + }); + }); + test('happy path', async () => { + const prePackagesRulesStatus = { + rules_custom_installed: 33, + rules_installed: 12, + rules_not_installed: 0, + rules_not_updated: 2, + }; + mockfetchSuccess(prePackagesRulesStatus); + const resp = await getPrePackagedRulesStatus({ signal: abortCtrl.signal }); + expect(resp).toEqual(prePackagesRulesStatus); + }); + test('unhappy path', async () => { + mockfetchError(); + try { + await getPrePackagedRulesStatus({ signal: abortCtrl.signal }); + } catch (exp) { + expect(exp).toBeInstanceOf(ToasterErrors); + expect(exp.message).toEqual('super mega error, it is not that bad'); + } + }); + }); +}); 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 5c9f86f503c2e..f1a784201eaa0 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 @@ -49,7 +49,6 @@ export const addRule = async ({ rule, signal }: AddRulesProps): Promise body: JSON.stringify(rule), signal, }); - await throwIfNotOk(response); return response.json(); }; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/fetch_index_patterns.test.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/fetch_index_patterns.test.tsx new file mode 100644 index 0000000000000..cad78ac565903 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/fetch_index_patterns.test.tsx @@ -0,0 +1,460 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; + +import { defaultIndexPattern } from '../../../../default_index_pattern'; +import { useApolloClient } from '../../../utils/apollo_context'; +import { mocksSource } from '../../source/mock'; + +import { useFetchIndexPatterns, Return } from './fetch_index_patterns'; + +const mockUseApolloClient = useApolloClient as jest.Mock; +jest.mock('../../../utils/apollo_context'); + +describe('useFetchIndexPatterns', () => { + beforeEach(() => { + mockUseApolloClient.mockClear(); + }); + test('happy path', async () => { + await act(async () => { + mockUseApolloClient.mockImplementation(() => ({ + query: () => Promise.resolve(mocksSource[0].result), + })); + const { result, waitForNextUpdate } = renderHook(() => + useFetchIndexPatterns(defaultIndexPattern) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual([ + { + browserFields: { + base: { + fields: { + '@timestamp': { + category: 'base', + description: + 'Date/time when the event originated. For log events this is the date/time when the event was generated, and not when it was read. Required field for all events.', + example: '2016-05-23T08:05:34.853Z', + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: '@timestamp', + searchable: true, + type: 'date', + aggregatable: true, + }, + }, + }, + agent: { + fields: { + 'agent.ephemeral_id': { + category: 'agent', + description: + 'Ephemeral identifier of this agent (if one exists). This id normally changes across restarts, but `agent.id` does not.', + example: '8a4f500f', + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'agent.ephemeral_id', + searchable: true, + type: 'string', + aggregatable: true, + }, + 'agent.hostname': { + category: 'agent', + description: null, + example: null, + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'agent.hostname', + searchable: true, + type: 'string', + aggregatable: true, + }, + 'agent.id': { + category: 'agent', + description: + 'Unique identifier of this agent (if one exists). Example: For Beats this would be beat.id.', + example: '8a4f500d', + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'agent.id', + searchable: true, + type: 'string', + aggregatable: true, + }, + 'agent.name': { + category: 'agent', + description: + 'Name of the agent. This is a name that can be given to an agent. This can be helpful if for example two Filebeat instances are running on the same host but a human readable separation is needed on which Filebeat instance data is coming from. If no name is given, the name is often left empty.', + example: 'foo', + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'agent.name', + searchable: true, + type: 'string', + aggregatable: true, + }, + }, + }, + auditd: { + fields: { + 'auditd.data.a0': { + category: 'auditd', + description: null, + example: null, + format: '', + indexes: ['auditbeat'], + name: 'auditd.data.a0', + searchable: true, + type: 'string', + aggregatable: true, + }, + 'auditd.data.a1': { + category: 'auditd', + description: null, + example: null, + format: '', + indexes: ['auditbeat'], + name: 'auditd.data.a1', + searchable: true, + type: 'string', + aggregatable: true, + }, + 'auditd.data.a2': { + category: 'auditd', + description: null, + example: null, + format: '', + indexes: ['auditbeat'], + name: 'auditd.data.a2', + searchable: true, + type: 'string', + aggregatable: true, + }, + }, + }, + client: { + fields: { + 'client.address': { + category: 'client', + description: + 'Some event client addresses are defined ambiguously. The event will sometimes list an IP, a domain or a unix socket. You should always store the raw address in the `.address` field. Then it should be duplicated to `.ip` or `.domain`, depending on which one it is.', + example: null, + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'client.address', + searchable: true, + type: 'string', + aggregatable: true, + }, + 'client.bytes': { + category: 'client', + description: 'Bytes sent from the client to the server.', + example: '184', + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'client.bytes', + searchable: true, + type: 'number', + aggregatable: true, + }, + 'client.domain': { + category: 'client', + description: 'Client domain.', + example: null, + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'client.domain', + searchable: true, + type: 'string', + aggregatable: true, + }, + 'client.geo.country_iso_code': { + category: 'client', + description: 'Country ISO code.', + example: 'CA', + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'client.geo.country_iso_code', + searchable: true, + type: 'string', + aggregatable: true, + }, + }, + }, + cloud: { + fields: { + 'cloud.account.id': { + category: 'cloud', + description: + 'The cloud account or organization id used to identify different entities in a multi-tenant environment. Examples: AWS account id, Google Cloud ORG Id, or other unique identifier.', + example: '666777888999', + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'cloud.account.id', + searchable: true, + type: 'string', + aggregatable: true, + }, + 'cloud.availability_zone': { + category: 'cloud', + description: 'Availability zone in which this host is running.', + example: 'us-east-1c', + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'cloud.availability_zone', + searchable: true, + type: 'string', + aggregatable: true, + }, + }, + }, + container: { + fields: { + 'container.id': { + category: 'container', + description: 'Unique container id.', + example: null, + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'container.id', + searchable: true, + type: 'string', + aggregatable: true, + }, + 'container.image.name': { + category: 'container', + description: 'Name of the image the container was built on.', + example: null, + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'container.image.name', + searchable: true, + type: 'string', + aggregatable: true, + }, + 'container.image.tag': { + category: 'container', + description: 'Container image tag.', + example: null, + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'container.image.tag', + searchable: true, + type: 'string', + aggregatable: true, + }, + }, + }, + destination: { + fields: { + 'destination.address': { + category: 'destination', + description: + 'Some event destination addresses are defined ambiguously. The event will sometimes list an IP, a domain or a unix socket. You should always store the raw address in the `.address` field. Then it should be duplicated to `.ip` or `.domain`, depending on which one it is.', + example: null, + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'destination.address', + searchable: true, + type: 'string', + aggregatable: true, + }, + 'destination.bytes': { + category: 'destination', + description: 'Bytes sent from the destination to the source.', + example: '184', + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'destination.bytes', + searchable: true, + type: 'number', + aggregatable: true, + }, + 'destination.domain': { + category: 'destination', + description: 'Destination domain.', + example: null, + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'destination.domain', + searchable: true, + type: 'string', + aggregatable: true, + }, + 'destination.ip': { + aggregatable: true, + category: 'destination', + description: + 'IP address of the destination. Can be one or multiple IPv4 or IPv6 addresses.', + example: '', + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'destination.ip', + searchable: true, + type: 'ip', + }, + 'destination.port': { + aggregatable: true, + category: 'destination', + description: 'Port of the destination.', + example: '', + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'destination.port', + searchable: true, + type: 'long', + }, + }, + }, + source: { + fields: { + 'source.ip': { + aggregatable: true, + category: 'source', + description: + 'IP address of the source. Can be one or multiple IPv4 or IPv6 addresses.', + example: '', + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'source.ip', + searchable: true, + type: 'ip', + }, + 'source.port': { + aggregatable: true, + category: 'source', + description: 'Port of the source.', + example: '', + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'source.port', + searchable: true, + type: 'long', + }, + }, + }, + event: { + fields: { + 'event.end': { + aggregatable: true, + category: 'event', + description: + 'event.end contains the date when the event ended or when the activity was last observed.', + example: null, + format: '', + indexes: [ + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'packetbeat-*', + 'winlogbeat-*', + ], + name: 'event.end', + searchable: true, + type: 'date', + }, + }, + }, + }, + isLoading: false, + indices: [ + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'packetbeat-*', + 'winlogbeat-*', + ], + indicesExists: true, + indexPatterns: { + fields: [ + { name: '@timestamp', searchable: true, type: 'date', aggregatable: true }, + { name: 'agent.ephemeral_id', searchable: true, type: 'string', aggregatable: true }, + { name: 'agent.hostname', searchable: true, type: 'string', aggregatable: true }, + { name: 'agent.id', searchable: true, type: 'string', aggregatable: true }, + { name: 'agent.name', searchable: true, type: 'string', aggregatable: true }, + { name: 'auditd.data.a0', searchable: true, type: 'string', aggregatable: true }, + { name: 'auditd.data.a1', searchable: true, type: 'string', aggregatable: true }, + { name: 'auditd.data.a2', searchable: true, type: 'string', aggregatable: true }, + { name: 'client.address', searchable: true, type: 'string', aggregatable: true }, + { name: 'client.bytes', searchable: true, type: 'number', aggregatable: true }, + { name: 'client.domain', searchable: true, type: 'string', aggregatable: true }, + { + name: 'client.geo.country_iso_code', + searchable: true, + type: 'string', + aggregatable: true, + }, + { name: 'cloud.account.id', searchable: true, type: 'string', aggregatable: true }, + { + name: 'cloud.availability_zone', + searchable: true, + type: 'string', + aggregatable: true, + }, + { name: 'container.id', searchable: true, type: 'string', aggregatable: true }, + { + name: 'container.image.name', + searchable: true, + type: 'string', + aggregatable: true, + }, + { name: 'container.image.tag', searchable: true, type: 'string', aggregatable: true }, + { name: 'destination.address', searchable: true, type: 'string', aggregatable: true }, + { name: 'destination.bytes', searchable: true, type: 'number', aggregatable: true }, + { name: 'destination.domain', searchable: true, type: 'string', aggregatable: true }, + { name: 'destination.ip', searchable: true, type: 'ip', aggregatable: true }, + { name: 'destination.port', searchable: true, type: 'long', aggregatable: true }, + { name: 'source.ip', searchable: true, type: 'ip', aggregatable: true }, + { name: 'source.port', searchable: true, type: 'long', aggregatable: true }, + { name: 'event.end', searchable: true, type: 'date', aggregatable: true }, + ], + title: 'apm-*-transaction*,auditbeat-*,endgame-*,filebeat-*,packetbeat-*,winlogbeat-*', + }, + }, + result.current[1], + ]); + }); + }); + + test('unhappy path', async () => { + await act(async () => { + mockUseApolloClient.mockImplementation(() => ({ + query: () => Promise.reject(new Error('Something went wrong')), + })); + const { result, waitForNextUpdate } = renderHook(() => + useFetchIndexPatterns(defaultIndexPattern) + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual([ + { + browserFields: {}, + indexPatterns: { + fields: [], + title: '', + }, + indices: [ + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'packetbeat-*', + 'winlogbeat-*', + ], + indicesExists: false, + isLoading: false, + }, + result.current[1], + ]); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/fetch_index_patterns.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/fetch_index_patterns.tsx index d376a1d6ad178..b7ad41b8ba1bb 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/fetch_index_patterns.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/fetch_index_patterns.tsx @@ -29,7 +29,7 @@ interface FetchIndexPatternReturn { indexPatterns: IIndexPattern; } -type Return = [FetchIndexPatternReturn, Dispatch>]; +export type Return = [FetchIndexPatternReturn, Dispatch>]; export const useFetchIndexPatterns = (defaultIndices: string[] = []): Return => { const apolloClient = useApolloClient(); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/mock.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/mock.ts new file mode 100644 index 0000000000000..51526c0ab9949 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/mock.ts @@ -0,0 +1,139 @@ +/* + * 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 { NewRule, FetchRulesResponse, Rule } from './types'; + +export const ruleMock: NewRule = { + description: 'some desc', + enabled: true, + false_positives: [], + filters: [], + from: 'now-360s', + index: [ + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'packetbeat-*', + 'winlogbeat-*', + ], + interval: '5m', + rule_id: 'bbd3106e-b4b5-4d7c-a1a2-47531d6a2baf', + language: 'kuery', + risk_score: 75, + name: 'Test rule', + query: "user.email: 'root@elastic.co'", + references: [], + severity: 'high', + tags: ['APM'], + to: 'now', + type: 'query', + threat: [], +}; + +export const savedRuleMock: Rule = { + created_at: 'mm/dd/yyyyTHH:MM:sssz', + created_by: 'mockUser', + description: 'some desc', + enabled: true, + false_positives: [], + filters: [], + from: 'now-360s', + id: '12345678987654321', + index: [ + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'packetbeat-*', + 'winlogbeat-*', + ], + interval: '5m', + immutable: false, + rule_id: 'bbd3106e-b4b5-4d7c-a1a2-47531d6a2baf', + language: 'kuery', + risk_score: 75, + name: 'Test rule', + max_signals: 100, + query: "user.email: 'root@elastic.co'", + references: [], + severity: 'high', + tags: ['APM'], + to: 'now', + type: 'query', + threat: [], + updated_at: 'mm/dd/yyyyTHH:MM:sssz', + updated_by: 'mockUser', +}; + +export const rulesMock: FetchRulesResponse = { + page: 1, + perPage: 2, + total: 2, + data: [ + { + created_at: '2020-02-14T19:49:28.178Z', + updated_at: '2020-02-14T19:49:28.320Z', + created_by: 'elastic', + description: + 'Elastic Endpoint detected Credential Dumping. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.', + enabled: false, + false_positives: [], + from: 'now-660s', + id: '80c59768-8e1f-400e-908e-7b25c4ce29c3', + immutable: true, + index: ['endgame-*'], + interval: '10m', + rule_id: '571afc56-5ed9-465d-a2a9-045f099f6e7e', + language: 'kuery', + output_index: '.siem-signals-default', + max_signals: 100, + risk_score: 73, + name: 'Credential Dumping - Detected - Elastic Endpoint', + query: + 'event.kind:alert and event.module:endgame and event.action:cred_theft_event and endgame.metadata.type:detection', + filters: [], + references: [], + severity: 'high', + updated_by: 'elastic', + tags: ['Elastic', 'Endpoint'], + to: 'now', + type: 'query', + threat: [], + version: 1, + }, + { + created_at: '2020-02-14T19:49:28.189Z', + updated_at: '2020-02-14T19:49:28.326Z', + created_by: 'elastic', + description: + 'Elastic Endpoint detected an Adversary Behavior. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.', + enabled: false, + false_positives: [], + from: 'now-660s', + id: '2e846086-bd64-4dbc-9c56-42b46b5b2c8c', + immutable: true, + index: ['endgame-*'], + interval: '10m', + rule_id: '77a3c3df-8ec4-4da4-b758-878f551dee69', + language: 'kuery', + output_index: '.siem-signals-default', + max_signals: 100, + risk_score: 47, + name: 'Adversary Behavior - Detected - Elastic Endpoint', + query: 'event.kind:alert and event.module:endgame and event.action:rules_engine_event', + filters: [], + references: [], + severity: 'medium', + updated_by: 'elastic', + tags: ['Elastic', 'Endpoint'], + to: 'now', + type: 'query', + threat: [], + version: 1, + }, + ], +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/persist_rule.test.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/persist_rule.test.tsx new file mode 100644 index 0000000000000..1bf21623992e6 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/persist_rule.test.tsx @@ -0,0 +1,44 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; + +import { usePersistRule, ReturnPersistRule } from './persist_rule'; +import { ruleMock } from './mock'; + +jest.mock('./api'); + +describe('usePersistRule', () => { + test('init', async () => { + const { result } = renderHook(() => usePersistRule()); + + expect(result.current).toEqual([{ isLoading: false, isSaved: false }, result.current[1]]); + }); + + test('saving rule with isLoading === true', async () => { + await act(async () => { + const { result, rerender, waitForNextUpdate } = renderHook(() => + usePersistRule() + ); + await waitForNextUpdate(); + result.current[1](ruleMock); + rerender(); + expect(result.current).toEqual([{ isLoading: true, isSaved: false }, result.current[1]]); + }); + }); + + test('saved rule with isSaved === true', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + usePersistRule() + ); + await waitForNextUpdate(); + result.current[1](ruleMock); + await waitForNextUpdate(); + expect(result.current).toEqual([{ isLoading: false, isSaved: true }, result.current[1]]); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/persist_rule.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/persist_rule.tsx index ea03c34ec31ba..e720a1e70f153 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/persist_rule.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/persist_rule.tsx @@ -18,9 +18,9 @@ interface PersistRuleReturn { isSaved: boolean; } -type Return = [PersistRuleReturn, Dispatch]; +export type ReturnPersistRule = [PersistRuleReturn, Dispatch]; -export const usePersistRule = (): Return => { +export const usePersistRule = (): ReturnPersistRule => { const [rule, setRule] = useState(null); const [isSaved, setIsSaved] = useState(false); const [isLoading, setIsLoading] = useState(false); @@ -35,7 +35,6 @@ export const usePersistRule = (): Return => { try { setIsLoading(true); await persistRule({ rule, signal: abortCtrl.signal }); - if (isSubscribed) { setIsSaved(true); } diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts index b30c3b211b1b8..ff49bb8a8c3a2 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts @@ -64,7 +64,6 @@ export const RuleSchema = t.intersection([ language: t.string, name: t.string, max_signals: t.number, - meta: MetaRule, query: t.string, references: t.array(t.string), risk_score: t.number, @@ -80,6 +79,7 @@ export const RuleSchema = t.intersection([ t.partial({ last_failure_at: t.string, last_failure_message: t.string, + meta: MetaRule, output_index: t.string, saved_id: t.string, status: t.string, @@ -197,3 +197,12 @@ export interface RuleInfoStatus { last_failure_message: string | null; last_success_message: string | null; } + +export type RuleStatusResponse = Record; + +export interface PrePackagedRulesStatusResponse { + rules_custom_installed: number; + rules_installed: number; + rules_not_installed: number; + rules_not_updated: number; +} diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_pre_packaged_rules.test.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_pre_packaged_rules.test.tsx new file mode 100644 index 0000000000000..426a1ab9238dc --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_pre_packaged_rules.test.tsx @@ -0,0 +1,267 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { ReturnPrePackagedRules, usePrePackagedRules } from './use_pre_packaged_rules'; +import * as api from './api'; + +jest.mock('./api'); + +describe('usePersistRule', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + test('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + usePrePackagedRules({ + canUserCRUD: null, + hasIndexWrite: null, + hasManageApiKey: null, + isAuthenticated: null, + hasEncryptionKey: null, + isSignalIndexExists: null, + }) + ); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + createPrePackagedRules: null, + loading: true, + loadingCreatePrePackagedRules: false, + refetchPrePackagedRulesStatus: null, + rulesCustomInstalled: null, + rulesInstalled: null, + rulesNotInstalled: null, + rulesNotUpdated: null, + }); + }); + }); + + test('fetch getPrePackagedRulesStatus', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + usePrePackagedRules({ + canUserCRUD: null, + hasIndexWrite: null, + hasManageApiKey: null, + isAuthenticated: null, + hasEncryptionKey: null, + isSignalIndexExists: null, + }) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ + createPrePackagedRules: result.current.createPrePackagedRules, + loading: false, + loadingCreatePrePackagedRules: false, + refetchPrePackagedRulesStatus: result.current.refetchPrePackagedRulesStatus, + rulesCustomInstalled: 33, + rulesInstalled: 12, + rulesNotInstalled: 0, + rulesNotUpdated: 0, + }); + }); + }); + + test('happy path to createPrePackagedRules', async () => { + const spyOnCreatePrepackagedRules = jest.spyOn(api, 'createPrepackagedRules'); + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + usePrePackagedRules({ + canUserCRUD: true, + hasIndexWrite: true, + hasManageApiKey: true, + isAuthenticated: true, + hasEncryptionKey: true, + isSignalIndexExists: true, + }) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + let resp = null; + if (result.current.createPrePackagedRules) { + resp = await result.current.createPrePackagedRules(); + } + expect(resp).toEqual(true); + expect(spyOnCreatePrepackagedRules).toHaveBeenCalled(); + expect(result.current).toEqual({ + createPrePackagedRules: result.current.createPrePackagedRules, + loading: false, + loadingCreatePrePackagedRules: false, + refetchPrePackagedRulesStatus: result.current.refetchPrePackagedRulesStatus, + rulesCustomInstalled: 33, + rulesInstalled: 12, + rulesNotInstalled: 0, + rulesNotUpdated: 0, + }); + }); + }); + + test('unhappy path to createPrePackagedRules', async () => { + const spyOnCreatePrepackagedRules = jest.spyOn(api, 'createPrepackagedRules'); + spyOnCreatePrepackagedRules.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + usePrePackagedRules({ + canUserCRUD: true, + hasIndexWrite: true, + hasManageApiKey: true, + isAuthenticated: true, + hasEncryptionKey: true, + isSignalIndexExists: true, + }) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + let resp = null; + if (result.current.createPrePackagedRules) { + resp = await result.current.createPrePackagedRules(); + } + expect(resp).toEqual(false); + expect(spyOnCreatePrepackagedRules).toHaveBeenCalled(); + }); + }); + + test('can NOT createPrePackagedRules because canUserCrud === false', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + usePrePackagedRules({ + canUserCRUD: false, + hasIndexWrite: true, + hasManageApiKey: true, + isAuthenticated: true, + hasEncryptionKey: true, + isSignalIndexExists: true, + }) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + let resp = null; + if (result.current.createPrePackagedRules) { + resp = await result.current.createPrePackagedRules(); + } + expect(resp).toEqual(false); + }); + }); + + test('can NOT createPrePackagedRules because hasIndexWrite === false', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + usePrePackagedRules({ + canUserCRUD: true, + hasIndexWrite: false, + hasManageApiKey: true, + isAuthenticated: true, + hasEncryptionKey: true, + isSignalIndexExists: true, + }) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + let resp = null; + if (result.current.createPrePackagedRules) { + resp = await result.current.createPrePackagedRules(); + } + expect(resp).toEqual(false); + }); + }); + + test('can NOT createPrePackagedRules because hasManageApiKey === false', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + usePrePackagedRules({ + canUserCRUD: true, + hasIndexWrite: true, + hasManageApiKey: false, + isAuthenticated: true, + hasEncryptionKey: true, + isSignalIndexExists: true, + }) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + let resp = null; + if (result.current.createPrePackagedRules) { + resp = await result.current.createPrePackagedRules(); + } + expect(resp).toEqual(false); + }); + }); + + test('can NOT createPrePackagedRules because isAuthenticated === false', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + usePrePackagedRules({ + canUserCRUD: true, + hasIndexWrite: true, + hasManageApiKey: true, + isAuthenticated: false, + hasEncryptionKey: true, + isSignalIndexExists: true, + }) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + let resp = null; + if (result.current.createPrePackagedRules) { + resp = await result.current.createPrePackagedRules(); + } + expect(resp).toEqual(false); + }); + }); + + test('can NOT createPrePackagedRules because hasEncryptionKey === false', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + usePrePackagedRules({ + canUserCRUD: true, + hasIndexWrite: true, + hasManageApiKey: true, + isAuthenticated: true, + hasEncryptionKey: false, + isSignalIndexExists: true, + }) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + let resp = null; + if (result.current.createPrePackagedRules) { + resp = await result.current.createPrePackagedRules(); + } + expect(resp).toEqual(false); + }); + }); + + test('can NOT createPrePackagedRules because isSignalIndexExists === false', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + usePrePackagedRules({ + canUserCRUD: true, + hasIndexWrite: true, + hasManageApiKey: true, + isAuthenticated: true, + hasEncryptionKey: true, + isSignalIndexExists: false, + }) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + let resp = null; + if (result.current.createPrePackagedRules) { + resp = await result.current.createPrePackagedRules(); + } + expect(resp).toEqual(false); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_pre_packaged_rules.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_pre_packaged_rules.tsx index d77d6283692a2..04d7e3ef67da4 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_pre_packaged_rules.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_pre_packaged_rules.tsx @@ -13,7 +13,7 @@ import * as i18n from './translations'; type Func = () => void; export type CreatePreBuiltRules = () => Promise; -interface Return { +export interface ReturnPrePackagedRules { createPrePackagedRules: null | CreatePreBuiltRules; loading: boolean; loadingCreatePrePackagedRules: boolean; @@ -50,10 +50,10 @@ export const usePrePackagedRules = ({ isAuthenticated, hasEncryptionKey, isSignalIndexExists, -}: UsePrePackagedRuleProps): Return => { +}: UsePrePackagedRuleProps): ReturnPrePackagedRules => { const [rulesStatus, setRuleStatus] = useState< Pick< - Return, + ReturnPrePackagedRules, | 'createPrePackagedRules' | 'refetchPrePackagedRulesStatus' | 'rulesCustomInstalled' @@ -167,6 +167,8 @@ export const usePrePackagedRules = ({ }, 300); timeoutId = reFetch(); } + } else { + resolve(false); } } catch (error) { if (isSubscribed) { diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule.test.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule.test.tsx new file mode 100644 index 0000000000000..e0bf2c4907370 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule.test.tsx @@ -0,0 +1,84 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { useRule, ReturnRule } from './use_rule'; +import * as api from './api'; + +jest.mock('./api'); + +describe('useRule', () => { + test('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useRule('myOwnRuleID') + ); + await waitForNextUpdate(); + expect(result.current).toEqual([true, null]); + }); + }); + + test('fetch rule', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useRule('myOwnRuleID') + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual([ + false, + { + created_at: 'mm/dd/yyyyTHH:MM:sssz', + created_by: 'mockUser', + description: 'some desc', + enabled: true, + false_positives: [], + filters: [], + from: 'now-360s', + id: '12345678987654321', + immutable: false, + index: [ + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'packetbeat-*', + 'winlogbeat-*', + ], + interval: '5m', + language: 'kuery', + name: 'Test rule', + max_signals: 100, + query: "user.email: 'root@elastic.co'", + references: [], + risk_score: 75, + rule_id: 'bbd3106e-b4b5-4d7c-a1a2-47531d6a2baf', + severity: 'high', + tags: ['APM'], + threat: [], + to: 'now', + type: 'query', + updated_at: 'mm/dd/yyyyTHH:MM:sssz', + updated_by: 'mockUser', + }, + ]); + }); + }); + + test('fetch a new rule', async () => { + const spyOnfetchRuleById = jest.spyOn(api, 'fetchRuleById'); + await act(async () => { + const { rerender, waitForNextUpdate } = renderHook(id => useRule(id), { + initialProps: 'myOwnRuleID', + }); + await waitForNextUpdate(); + await waitForNextUpdate(); + rerender('newRuleId'); + await waitForNextUpdate(); + expect(spyOnfetchRuleById).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule.tsx index 22ba86cd09f74..ab08bd39688ce 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule.tsx @@ -12,7 +12,7 @@ import { fetchRuleById } from './api'; import * as i18n from './translations'; import { Rule } from './types'; -type Return = [boolean, Rule | null]; +export type ReturnRule = [boolean, Rule | null]; /** * Hook for using to get a Rule from the Detection Engine API @@ -20,7 +20,7 @@ type Return = [boolean, Rule | null]; * @param id desired Rule ID's (not rule_id) * */ -export const useRule = (id: string | undefined): Return => { +export const useRule = (id: string | undefined): ReturnRule => { const [rule, setRule] = useState(null); const [loading, setLoading] = useState(true); const [, dispatchToaster] = useStateToaster(); @@ -36,7 +36,6 @@ export const useRule = (id: string | undefined): Return => { id: idToFetch, signal: abortCtrl.signal, }); - if (isSubscribed) { setRule(ruleResponse); } diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.test.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.test.tsx new file mode 100644 index 0000000000000..25011adcfe98b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.test.tsx @@ -0,0 +1,65 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { useRuleStatus, ReturnRuleStatus } from './use_rule_status'; +import * as api from './api'; + +jest.mock('./api'); + +describe('useRuleStatus', () => { + test('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useRuleStatus('myOwnRuleID') + ); + await waitForNextUpdate(); + expect(result.current).toEqual([true, null, null]); + }); + }); + + test('fetch rule status', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useRuleStatus('myOwnRuleID') + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual([ + false, + { + current_status: { + alert_id: 'alertId', + last_failure_at: null, + last_failure_message: null, + last_success_at: 'mm/dd/yyyyTHH:MM:sssz', + last_success_message: 'it is a success', + status: 'succeeded', + status_date: 'mm/dd/yyyyTHH:MM:sssz', + }, + failures: [], + }, + result.current[2], + ]); + }); + }); + + test('re-fetch rule status', async () => { + const spyOngetRuleStatusById = jest.spyOn(api, 'getRuleStatusById'); + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useRuleStatus('myOwnRuleID') + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + if (result.current[2]) { + result.current[2]('myOwnRuleID'); + } + await waitForNextUpdate(); + expect(spyOngetRuleStatusById).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.tsx index 466c2cddac97d..fcf95ac061ba3 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.tsx @@ -13,7 +13,7 @@ import * as i18n from './translations'; import { RuleStatus } from './types'; type Func = (ruleId: string) => void; -type Return = [boolean, RuleStatus | null, Func | null]; +export type ReturnRuleStatus = [boolean, RuleStatus | null, Func | null]; /** * Hook for using to get a Rule from the Detection Engine API @@ -21,7 +21,7 @@ type Return = [boolean, RuleStatus | null, Func | null]; * @param id desired Rule ID's (not rule_id) * */ -export const useRuleStatus = (id: string | undefined | null): Return => { +export const useRuleStatus = (id: string | undefined | null): ReturnRuleStatus => { const [ruleStatus, setRuleStatus] = useState(null); const fetchRuleStatus = useRef(null); const [loading, setLoading] = useState(true); @@ -34,6 +34,7 @@ export const useRuleStatus = (id: string | undefined | null): Return => { const fetchData = async (idToFetch: string) => { try { setLoading(true); + const ruleStatusResponse = await getRuleStatusById({ id: idToFetch, signal: abortCtrl.signal, diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rules.test.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rules.test.tsx new file mode 100644 index 0000000000000..b369d3a50730d --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rules.test.tsx @@ -0,0 +1,216 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { useRules, ReturnRules } from './use_rules'; +import * as api from './api'; +import { PaginationOptions, FilterOptions } from '.'; + +jest.mock('./api'); + +describe('useRules', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + test('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + [PaginationOptions, FilterOptions], + ReturnRules + >(props => + useRules( + { + page: 1, + perPage: 10, + total: 100, + }, + { + filter: '', + sortField: 'created_at', + sortOrder: 'desc', + } + ) + ); + await waitForNextUpdate(); + expect(result.current).toEqual([ + true, + { + data: [], + page: 1, + perPage: 20, + total: 0, + }, + null, + ]); + }); + }); + + test('fetch rules', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + [PaginationOptions, FilterOptions], + ReturnRules + >(() => + useRules( + { + page: 1, + perPage: 10, + total: 100, + }, + { + filter: '', + sortField: 'created_at', + sortOrder: 'desc', + } + ) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual([ + false, + { + data: [ + { + created_at: '2020-02-14T19:49:28.178Z', + created_by: 'elastic', + description: + 'Elastic Endpoint detected Credential Dumping. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.', + enabled: false, + false_positives: [], + filters: [], + from: 'now-660s', + id: '80c59768-8e1f-400e-908e-7b25c4ce29c3', + immutable: true, + index: ['endgame-*'], + interval: '10m', + language: 'kuery', + max_signals: 100, + name: 'Credential Dumping - Detected - Elastic Endpoint', + output_index: '.siem-signals-default', + query: + 'event.kind:alert and event.module:endgame and event.action:cred_theft_event and endgame.metadata.type:detection', + references: [], + risk_score: 73, + rule_id: '571afc56-5ed9-465d-a2a9-045f099f6e7e', + severity: 'high', + tags: ['Elastic', 'Endpoint'], + threat: [], + to: 'now', + type: 'query', + updated_at: '2020-02-14T19:49:28.320Z', + updated_by: 'elastic', + version: 1, + }, + { + created_at: '2020-02-14T19:49:28.189Z', + created_by: 'elastic', + description: + 'Elastic Endpoint detected an Adversary Behavior. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.', + enabled: false, + false_positives: [], + filters: [], + from: 'now-660s', + id: '2e846086-bd64-4dbc-9c56-42b46b5b2c8c', + immutable: true, + index: ['endgame-*'], + interval: '10m', + language: 'kuery', + max_signals: 100, + name: 'Adversary Behavior - Detected - Elastic Endpoint', + output_index: '.siem-signals-default', + query: + 'event.kind:alert and event.module:endgame and event.action:rules_engine_event', + references: [], + risk_score: 47, + rule_id: '77a3c3df-8ec4-4da4-b758-878f551dee69', + severity: 'medium', + tags: ['Elastic', 'Endpoint'], + threat: [], + to: 'now', + type: 'query', + updated_at: '2020-02-14T19:49:28.326Z', + updated_by: 'elastic', + version: 1, + }, + ], + page: 1, + perPage: 2, + total: 2, + }, + result.current[2], + ]); + }); + }); + + test('re-fetch rules', async () => { + const spyOnfetchRules = jest.spyOn(api, 'fetchRules'); + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + [PaginationOptions, FilterOptions], + ReturnRules + >(id => + useRules( + { + page: 1, + perPage: 10, + total: 100, + }, + { + filter: '', + sortField: 'created_at', + sortOrder: 'desc', + } + ) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + if (result.current[2]) { + result.current[2](); + } + await waitForNextUpdate(); + expect(spyOnfetchRules).toHaveBeenCalledTimes(2); + }); + }); + + test('fetch rules if props changes', async () => { + const spyOnfetchRules = jest.spyOn(api, 'fetchRules'); + await act(async () => { + const { rerender, waitForNextUpdate } = renderHook< + [PaginationOptions, FilterOptions], + ReturnRules + >(args => useRules(args[0], args[1]), { + initialProps: [ + { + page: 1, + perPage: 10, + total: 100, + }, + { + filter: '', + sortField: 'created_at', + sortOrder: 'desc', + }, + ], + }); + await waitForNextUpdate(); + await waitForNextUpdate(); + rerender([ + { + page: 1, + perPage: 10, + total: 100, + }, + { + filter: 'hello world', + sortField: 'created_at', + sortOrder: 'desc', + }, + ]); + await waitForNextUpdate(); + expect(spyOnfetchRules).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rules.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rules.tsx index af6e437255acd..301a68dc6f445 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rules.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rules.tsx @@ -13,7 +13,7 @@ import { errorToToaster } from '../../../components/ml/api/error_to_toaster'; import * as i18n from './translations'; type Func = () => void; -type Return = [boolean, FetchRulesResponse, Func | null]; +export type ReturnRules = [boolean, FetchRulesResponse, Func | null]; /** * Hook for using the list of Rules from the Detection Engine API @@ -21,7 +21,10 @@ type Return = [boolean, FetchRulesResponse, Func | null]; * @param pagination desired pagination options (e.g. page/perPage) * @param filterOptions desired filters (e.g. filter/sortField/sortOrder) */ -export const useRules = (pagination: PaginationOptions, filterOptions: FilterOptions): Return => { +export const useRules = ( + pagination: PaginationOptions, + filterOptions: FilterOptions +): ReturnRules => { const [rules, setRules] = useState({ page: 1, perPage: 20, diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_tags.test.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_tags.test.tsx new file mode 100644 index 0000000000000..4a796efa5b0cb --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_tags.test.tsx @@ -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 { renderHook, act } from '@testing-library/react-hooks'; +import { useTags, ReturnTags } from './use_tags'; + +jest.mock('./api'); + +describe('useTags', () => { + test('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => useTags()); + await waitForNextUpdate(); + expect(result.current).toEqual([true, []]); + }); + }); + + test('fetch tags', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => useTags()); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual([false, ['elastic', 'love', 'quality', 'code']]); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_tags.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_tags.tsx index 1c961d530422a..196d4b1420561 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_tags.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_tags.tsx @@ -10,13 +10,13 @@ import { fetchTags } from './api'; import { errorToToaster } from '../../../components/ml/api/error_to_toaster'; import * as i18n from './translations'; -type Return = [boolean, string[]]; +export type ReturnTags = [boolean, string[]]; /** * Hook for using the list of Tags from the Detection Engine API * */ -export const useTags = (): Return => { +export const useTags = (): ReturnTags => { const [tags, setTags] = useState([]); const [loading, setLoading] = useState(true); const [, dispatchToaster] = useStateToaster(); diff --git a/x-pack/legacy/plugins/siem/public/mock/hook_wrapper.tsx b/x-pack/legacy/plugins/siem/public/mock/hook_wrapper.tsx index 292ddc036dcaf..70c76de01e95a 100644 --- a/x-pack/legacy/plugins/siem/public/mock/hook_wrapper.tsx +++ b/x-pack/legacy/plugins/siem/public/mock/hook_wrapper.tsx @@ -12,6 +12,7 @@ interface HookWrapperProps { // eslint-disable-next-line @typescript-eslint/no-explicit-any hookProps?: any; } + export const HookWrapper = ({ hook, hookProps }: HookWrapperProps) => { const myHook = hook ? (hookProps ? hook(hookProps) : hook()) : null; return
{JSON.stringify(myHook)}
;