From f891f21c937db3a02157032b9eaff013fb83b8fe Mon Sep 17 00:00:00 2001 From: Andrew Macri Date: Fri, 28 Apr 2023 18:05:06 -0600 Subject: [PATCH] ## [Security Solution] fixes Data Quality dashboard errors when a `basePath` is configured This PR implements a fix for an [issue](https://github.com/elastic/kibana/issues/156231) where the Data Quality dashboard displays errors when a `basePath` is configured, preventing indices from being checked. ### Desk testing - Verify the fix per the reporduction steps in - Also verify the page still behaves correctly when Kibana is started with no base path, via: ``` yarn start --no-base-path ``` ### Before / after screenshots **Before:** ![before](https://user-images.githubusercontent.com/4459398/235273609-952fd7e4-0a22-4344-b1e4-48411c6d0e33.png) _Above: Before the fix, errors occur when a `basePath` is configured_ **After:** ![after](https://user-images.githubusercontent.com/4459398/235276257-c62feb05-699a-418c-8da2-b90b6683e5b4.png) _Above: After the fix, errors do NOT occur_ --- .../body/data_quality_details/index.test.tsx | 6 +- .../indices_details/index.test.tsx | 6 +- .../data_quality_panel/body/index.test.tsx | 18 +- .../data_quality_context/index.test.tsx | 40 +++ .../data_quality_context/index.tsx | 39 +++ .../check_all/check_index.test.ts | 9 +- .../summary_actions/check_all/check_index.ts | 12 +- .../summary_actions/check_all/index.tsx | 4 + .../index_properties/index.test.tsx | 23 +- .../impl/data_quality/index.test.tsx | 2 + .../impl/data_quality/index.tsx | 32 +- .../mock/test_providers/test_providers.tsx | 19 +- .../use_ilm_explain/index.test.tsx | 101 ++++++ .../data_quality/use_ilm_explain/index.tsx | 27 +- .../data_quality/use_mappings/helpers.test.ts | 25 +- .../impl/data_quality/use_mappings/helpers.ts | 24 +- .../data_quality/use_mappings/index.test.tsx | 103 +++++++ .../impl/data_quality/use_mappings/index.tsx | 14 +- .../data_quality/use_stats/index.test.tsx | 101 ++++++ .../impl/data_quality/use_stats/index.tsx | 27 +- .../use_unallowed_values/helpers.test.ts | 23 +- .../use_unallowed_values/helpers.ts | 33 +- .../use_unallowed_values/index.test.tsx | 154 ++++++++++ .../{index.ts => index.tsx} | 13 +- .../ecs_data_quality_dashboard/tsconfig.json | 2 + .../overview/pages/data_quality.test.tsx | 290 ++++++++++++++++++ .../public/overview/pages/data_quality.tsx | 80 ++--- 27 files changed, 1050 insertions(+), 177 deletions(-) create mode 100644 x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/data_quality_context/index.test.tsx create mode 100644 x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/data_quality_context/index.tsx create mode 100644 x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_ilm_explain/index.test.tsx create mode 100644 x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_mappings/index.test.tsx create mode 100644 x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_stats/index.test.tsx create mode 100644 x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_unallowed_values/index.test.tsx rename x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_unallowed_values/{index.ts => index.tsx} (85%) create mode 100644 x-pack/plugins/security_solution/public/overview/pages/data_quality.test.tsx diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/index.test.tsx index 8b7c9b01e3c5e6f..bc878a4d1fb3ddd 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/index.test.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/index.test.tsx @@ -7,7 +7,7 @@ import { DARK_THEME } from '@elastic/charts'; import numeral from '@elastic/numeral'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { EMPTY_STAT } from '../../../helpers'; @@ -66,7 +66,7 @@ const defaultProps: Props = { describe('DataQualityDetails', () => { describe('when ILM phases are provided', () => { - beforeEach(() => { + beforeEach(async () => { jest.clearAllMocks(); render( @@ -74,6 +74,8 @@ describe('DataQualityDetails', () => { ); + + await waitFor(() => {}); }); test('it renders the storage details', () => { diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/indices_details/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/indices_details/index.test.tsx index ee4977ebe78582e..2b21154d70b3ada 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/indices_details/index.test.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/indices_details/index.test.tsx @@ -7,7 +7,7 @@ import { DARK_THEME } from '@elastic/charts'; import numeral from '@elastic/numeral'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { EMPTY_STAT } from '../../../../helpers'; @@ -67,7 +67,7 @@ const defaultProps: Props = { }; describe('IndicesDetails', () => { - beforeEach(() => { + beforeEach(async () => { jest.clearAllMocks(); render( @@ -75,6 +75,8 @@ describe('IndicesDetails', () => { ); + + await waitFor(() => {}); }); describe('rendering patterns', () => { diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/index.test.tsx index 0f27f307f79137e..242229870de2522 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/index.test.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/body/index.test.tsx @@ -7,7 +7,7 @@ import { DARK_THEME } from '@elastic/charts'; import numeral from '@elastic/numeral'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { EMPTY_STAT } from '../../helpers'; @@ -25,7 +25,7 @@ const formatNumber = (value: number | undefined) => const ilmPhases: string[] = ['hot', 'warm', 'unmanaged']; describe('IndexInvalidValues', () => { - test('it renders the data quality summary', () => { + test('it renders the data quality summary', async () => { render( { ); - expect(screen.getByTestId('dataQualitySummary')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByTestId('dataQualitySummary')).toBeInTheDocument(); + }); }); describe('patterns', () => { const patterns = ['.alerts-security.alerts-default', 'auditbeat-*', 'logs-*', 'packetbeat-*']; patterns.forEach((pattern) => { - test(`it renders the '${pattern}' pattern`, () => { + test(`it renders the '${pattern}' pattern`, async () => { render( { ); - expect(screen.getByTestId(`${pattern}PatternPanel`)).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByTestId(`${pattern}PatternPanel`)).toBeInTheDocument(); + }); }); }); @@ -94,7 +98,9 @@ describe('IndexInvalidValues', () => { ); const items = await screen.findAllByTestId('bodyPatternSpacer'); - expect(items).toHaveLength(patterns.length - 1); + await waitFor(() => { + expect(items).toHaveLength(patterns.length - 1); + }); }); }); }); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/data_quality_context/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/data_quality_context/index.test.tsx new file mode 100644 index 000000000000000..2ce0b3d1a6ad01e --- /dev/null +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/data_quality_context/index.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; + +import { DataQualityProvider, useDataQualityContext } from '.'; + +const mockHttpFetch = jest.fn(); +const ContextWrapper: React.FC = ({ children }) => ( + {children} +); + +describe('DataQualityContext', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('it throws an error when useDataQualityContext hook is used without a DataQualityContext', () => { + const { result } = renderHook(useDataQualityContext); + + expect(result.error).toEqual( + new Error('useDataQualityContext must be used within a DataQualityProvider') + ); + }); + + test('it should return the httpFetch function', async () => { + const { result } = renderHook(useDataQualityContext, { wrapper: ContextWrapper }); + const httpFetch = await result.current.httpFetch; + + const path = '/path/to/resource'; + httpFetch(path); + + expect(mockHttpFetch).toBeCalledWith(path); + }); +}); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/data_quality_context/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/data_quality_context/index.tsx new file mode 100644 index 000000000000000..b98761515e33c99 --- /dev/null +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/data_quality_context/index.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { HttpHandler } from '@kbn/core-http-browser'; +import React, { useMemo } from 'react'; + +interface DataQualityProviderProps { + httpFetch: HttpHandler; +} + +const DataQualityContext = React.createContext(undefined); + +export const DataQualityProvider: React.FC = ({ + children, + httpFetch, +}) => { + const value = useMemo( + () => ({ + httpFetch, + }), + [httpFetch] + ); + + return {children}; +}; + +export const useDataQualityContext = () => { + const context = React.useContext(DataQualityContext); + + if (context == null) { + throw new Error('useDataQualityContext must be used within a DataQualityProvider'); + } + + return context; +}; diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/check_index.test.ts b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/check_index.test.ts index fd457193a9c6fe5..fd167fb713e5c61 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/check_index.test.ts +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/check_index.test.ts @@ -87,6 +87,7 @@ describe('checkIndex', () => { const indexName = 'auditbeat-custom-index-1'; const pattern = 'auditbeat-*'; + const httpFetch = jest.fn(); describe('happy path', () => { const onCheckCompleted = jest.fn(); @@ -99,6 +100,7 @@ describe('checkIndex', () => { ecsMetadata, formatBytes, formatNumber, + httpFetch, indexName, onCheckCompleted, pattern, @@ -145,6 +147,7 @@ describe('checkIndex', () => { ecsMetadata, formatBytes, formatNumber, + httpFetch, indexName, onCheckCompleted, pattern, @@ -166,6 +169,7 @@ describe('checkIndex', () => { ecsMetadata: null, // <-- formatBytes, formatNumber, + httpFetch, indexName, onCheckCompleted, pattern, @@ -218,6 +222,7 @@ describe('checkIndex', () => { ecsMetadata, formatBytes, formatNumber, + httpFetch, indexName, onCheckCompleted, pattern, @@ -226,7 +231,7 @@ describe('checkIndex', () => { }); test('it invokes onCheckCompleted with the expected `error`', () => { - expect(onCheckCompleted.mock.calls[0][0].error).toEqual(`Error: ${error}`); + expect(onCheckCompleted.mock.calls[0][0].error).toEqual(error); }); test('it invokes onCheckCompleted with the expected `indexName`', () => { @@ -268,6 +273,7 @@ describe('checkIndex', () => { ecsMetadata, formatBytes, formatNumber, + httpFetch, indexName, onCheckCompleted, pattern, @@ -326,6 +332,7 @@ describe('checkIndex', () => { ecsMetadata, formatBytes, formatNumber, + httpFetch, indexName, onCheckCompleted, pattern, diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/check_index.ts b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/check_index.ts index c65b3f7559071ec..145ed31bf5a1b38 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/check_index.ts +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/check_index.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { HttpHandler } from '@kbn/core-http-browser'; import { getUnallowedValueRequestItems } from '../../../allowed_values/helpers'; import { getMappingsProperties, @@ -27,6 +28,7 @@ export async function checkIndex({ ecsMetadata, formatBytes, formatNumber, + httpFetch, indexName, onCheckCompleted, pattern, @@ -36,13 +38,18 @@ export async function checkIndex({ ecsMetadata: Record | null; formatBytes: (value: number | undefined) => string; formatNumber: (value: number | undefined) => string; + httpFetch: HttpHandler; indexName: string; onCheckCompleted: OnCheckCompleted; pattern: string; version: string; }) { try { - const indexes = await fetchMappings({ abortController, patternOrIndexName: indexName }); + const indexes = await fetchMappings({ + abortController, + httpFetch, + patternOrIndexName: indexName, + }); const requestItems = getUnallowedValueRequestItems({ ecsMetadata, @@ -51,6 +58,7 @@ export async function checkIndex({ const searchResults = await fetchUnallowedValues({ abortController, + httpFetch, indexName, requestItems, }); @@ -87,7 +95,7 @@ export async function checkIndex({ } catch (error) { if (!abortController.signal.aborted) { onCheckCompleted({ - error: error != null ? error.toString() : i18n.AN_ERROR_OCCURRED_CHECKING_INDEX(indexName), + error: error != null ? error.message : i18n.AN_ERROR_OCCURRED_CHECKING_INDEX(indexName), formatBytes, formatNumber, indexName, diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/index.tsx index ef768249aa2a457..83cc9b5ade1a9a3 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/index.tsx @@ -12,6 +12,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import styled from 'styled-components'; import { checkIndex } from './check_index'; +import { useDataQualityContext } from '../../../data_quality_context'; import { getAllIndicesToCheck } from './helpers'; import * as i18n from '../../../../translations'; import type { EcsMetadata, IndexToCheck, OnCheckCompleted } from '../../../../types'; @@ -58,6 +59,7 @@ const CheckAllComponent: React.FC = ({ setCheckAllTotalIndiciesToCheck, setIndexToCheck, }) => { + const { httpFetch } = useDataQualityContext(); const abortController = useRef(new AbortController()); const [isRunning, setIsRunning] = useState(false); @@ -91,6 +93,7 @@ const CheckAllComponent: React.FC = ({ ecsMetadata: EcsFlat as unknown as Record, formatBytes, formatNumber, + httpFetch, indexName, onCheckCompleted, pattern, @@ -121,6 +124,7 @@ const CheckAllComponent: React.FC = ({ cancelIfRunning, formatBytes, formatNumber, + httpFetch, incrementCheckAllIndiciesChecked, isRunning, onCheckCompleted, diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/index_properties/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/index_properties/index.test.tsx index b6914daef0e7daa..c578ec8c91f961c 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/index_properties/index.test.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/data_quality_panel/index_properties/index.test.tsx @@ -7,6 +7,7 @@ import { DARK_THEME } from '@elastic/charts'; import numeral from '@elastic/numeral'; +import { HttpHandler } from '@kbn/core-http-browser'; import { render, screen, waitFor } from '@testing-library/react'; import React from 'react'; @@ -30,12 +31,14 @@ const formatNumber = (value: number | undefined) => const pattern = 'auditbeat-*'; const patternRollup = auditbeatWithAllResults; -let mockFetchMappings = jest.fn( +const mockFetchMappings = jest.fn( ({ abortController, + httpFetch, patternOrIndexName, }: { abortController: AbortController; + httpFetch: HttpHandler; patternOrIndexName: string; }) => new Promise((resolve) => { @@ -46,24 +49,29 @@ let mockFetchMappings = jest.fn( jest.mock('../../use_mappings/helpers', () => ({ fetchMappings: ({ abortController, + httpFetch, patternOrIndexName, }: { abortController: AbortController; + httpFetch: HttpHandler; patternOrIndexName: string; }) => mockFetchMappings({ abortController, + httpFetch, patternOrIndexName, }), })); -let mockFetchUnallowedValues = jest.fn( +const mockFetchUnallowedValues = jest.fn( ({ abortController, + httpFetch, indexName, requestItems, }: { abortController: AbortController; + httpFetch: HttpHandler; indexName: string; requestItems: UnallowedValueRequestItem[]; }) => new Promise((resolve) => resolve(mockUnallowedValuesResponse)) @@ -76,15 +84,18 @@ jest.mock('../../use_unallowed_values/helpers', () => { ...original, fetchUnallowedValues: ({ abortController, + httpFetch, indexName, requestItems, }: { abortController: AbortController; + httpFetch: HttpHandler; indexName: string; requestItems: UnallowedValueRequestItem[]; }) => mockFetchUnallowedValues({ abortController, + httpFetch, indexName, requestItems, }), @@ -131,7 +142,7 @@ describe('IndexProperties', () => { }); test('it displays the expected empty prompt content', async () => { - mockFetchMappings = jest.fn( + mockFetchMappings.mockImplementation( ({ // eslint-disable-next-line @typescript-eslint/no-shadow abortController, @@ -169,7 +180,7 @@ describe('IndexProperties', () => { }); test('it displays the expected empty prompt content', async () => { - mockFetchUnallowedValues = jest.fn( + mockFetchUnallowedValues.mockImplementation( ({ // eslint-disable-next-line @typescript-eslint/no-shadow abortController, @@ -204,7 +215,7 @@ describe('IndexProperties', () => { }); test('it displays the expected loading prompt content', async () => { - mockFetchMappings = jest.fn( + mockFetchMappings.mockImplementation( ({ abortController, patternOrIndexName, @@ -234,7 +245,7 @@ describe('IndexProperties', () => { }); test('it displays the expected loading prompt content', async () => { - mockFetchUnallowedValues = jest.fn( + mockFetchUnallowedValues.mockImplementation( ({ abortController, indexName, diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/index.test.tsx index 5f6711814a90420..17f7e830750afea 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/index.test.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/index.test.tsx @@ -25,6 +25,7 @@ describe('DataQualityPanel', () => { defaultBytesFormat={''} defaultNumberFormat={''} getGroupByFieldsOnClick={jest.fn()} + httpFetch={jest.fn()} ilmPhases={ilmPhases} lastChecked={''} openCreateCaseFlyout={jest.fn()} @@ -57,6 +58,7 @@ describe('DataQualityPanel', () => { defaultBytesFormat={''} defaultNumberFormat={''} getGroupByFieldsOnClick={jest.fn()} + httpFetch={jest.fn()} ilmPhases={ilmPhases} lastChecked={''} openCreateCaseFlyout={jest.fn()} diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/index.tsx index dde54d8e97f647c..758cad54f7caaeb 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/index.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import type { HttpHandler } from '@kbn/core-http-browser'; import numeral from '@elastic/numeral'; import type { FlameElementEvent, @@ -18,6 +19,7 @@ import type { import React, { useCallback } from 'react'; import { Body } from './data_quality_panel/body'; +import { DataQualityProvider } from './data_quality_panel/data_quality_context'; import { EMPTY_STAT } from './helpers'; interface Props { @@ -38,6 +40,7 @@ interface Props { groupByField0: string; groupByField1: string; }; + httpFetch: HttpHandler; ilmPhases: string[]; lastChecked: string; openCreateCaseFlyout: ({ @@ -59,6 +62,7 @@ const DataQualityPanelComponent: React.FC = ({ defaultBytesFormat, defaultNumberFormat, getGroupByFieldsOnClick, + httpFetch, ilmPhases, lastChecked, openCreateCaseFlyout, @@ -79,19 +83,21 @@ const DataQualityPanelComponent: React.FC = ({ ); return ( - + + + ); }; diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/mock/test_providers/test_providers.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/mock/test_providers/test_providers.tsx index 984e38cd0de5977..3aba964c6878326 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/mock/test_providers/test_providers.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/mock/test_providers/test_providers.tsx @@ -5,11 +5,14 @@ * 2.0. */ +import { httpServiceMock } from '@kbn/core-http-browser-mocks'; import { I18nProvider } from '@kbn/i18n-react'; import { euiDarkVars } from '@kbn/ui-theme'; import React from 'react'; import { ThemeProvider } from 'styled-components'; +import { DataQualityProvider } from '../../data_quality_panel/data_quality_context'; + interface Props { children: React.ReactNode; } @@ -17,11 +20,17 @@ interface Props { window.scrollTo = jest.fn(); /** A utility for wrapping children in the providers required to run tests */ -export const TestProvidersComponent: React.FC = ({ children }) => ( - - ({ eui: euiDarkVars, darkMode: true })}>{children} - -); +export const TestProvidersComponent: React.FC = ({ children }) => { + const http = httpServiceMock.createSetupContract({ basePath: '/test' }); + + return ( + + ({ eui: euiDarkVars, darkMode: true })}> + {children} + + + ); +}; TestProvidersComponent.displayName = 'TestProvidersComponent'; diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_ilm_explain/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_ilm_explain/index.test.tsx new file mode 100644 index 000000000000000..9cc7b86ba66b25d --- /dev/null +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_ilm_explain/index.test.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; + +import { DataQualityProvider } from '../data_quality_panel/data_quality_context'; +import { mockIlmExplain } from '../mock/ilm_explain/mock_ilm_explain'; +import { ERROR_LOADING_ILM_EXPLAIN } from '../translations'; +import { useIlmExplain } from '.'; + +const mockHttpFetch = jest.fn(); +const ContextWrapper: React.FC = ({ children }) => ( + {children} +); + +const pattern = 'packetbeat-*'; + +describe('useIlmExplain', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('happy path', () => { + beforeEach(() => { + mockHttpFetch.mockResolvedValue(mockIlmExplain); + }); + + test('it returns the expected ilmExplain map', async () => { + const { result, waitForNextUpdate } = renderHook(() => useIlmExplain(pattern), { + wrapper: ContextWrapper, + }); + await waitForNextUpdate(); + const { ilmExplain } = await result.current; + + expect(ilmExplain).toEqual(mockIlmExplain); + }); + + test('it returns loading: false, because the data has loaded', async () => { + const { result, waitForNextUpdate } = renderHook(() => useIlmExplain(pattern), { + wrapper: ContextWrapper, + }); + await waitForNextUpdate(); + const { loading } = await result.current; + + expect(loading).toBe(false); + }); + + test('it returns a null error, because no errors occurred', async () => { + const { result, waitForNextUpdate } = renderHook(() => useIlmExplain(pattern), { + wrapper: ContextWrapper, + }); + await waitForNextUpdate(); + const { error } = await result.current; + + expect(error).toBeNull(); + }); + }); + + describe('fetch rejects with an error', () => { + const errorMessage = 'simulated error'; + + beforeEach(() => { + mockHttpFetch.mockRejectedValue(new Error(errorMessage)); + }); + + test('it returns a null ilmExplain, because an error occurred', async () => { + const { result, waitForNextUpdate } = renderHook(() => useIlmExplain(pattern), { + wrapper: ContextWrapper, + }); + await waitForNextUpdate(); + const { ilmExplain } = await result.current; + + expect(ilmExplain).toBeNull(); + }); + + test('it returns loading: false, because data loading reached a terminal state', async () => { + const { result, waitForNextUpdate } = renderHook(() => useIlmExplain(pattern), { + wrapper: ContextWrapper, + }); + await waitForNextUpdate(); + const { loading } = await result.current; + + expect(loading).toBe(false); + }); + + test('it returns the expected error', async () => { + const { result, waitForNextUpdate } = renderHook(() => useIlmExplain(pattern), { + wrapper: ContextWrapper, + }); + await waitForNextUpdate(); + const { error } = await result.current; + + expect(error).toEqual(ERROR_LOADING_ILM_EXPLAIN(errorMessage)); + }); + }); +}); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_ilm_explain/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_ilm_explain/index.tsx index 72d75408301abcf..8028f575692494b 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_ilm_explain/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_ilm_explain/index.tsx @@ -8,6 +8,7 @@ import type { IlmExplainLifecycleLifecycleExplain } from '@elastic/elasticsearch/lib/api/types'; import { useEffect, useState } from 'react'; +import { useDataQualityContext } from '../data_quality_panel/data_quality_context'; import * as i18n from '../translations'; const ILM_EXPLAIN_ENDPOINT = '/internal/ecs_data_quality_dashboard/ilm_explain'; @@ -19,6 +20,7 @@ interface UseIlmExplain { } export const useIlmExplain = (pattern: string): UseIlmExplain => { + const { httpFetch } = useDataQualityContext(); const [ilmExplain, setIlmExplain] = useState { try { const encodedIndexName = encodeURIComponent(`${pattern}`); - const response = await fetch(`${ILM_EXPLAIN_ENDPOINT}/${encodedIndexName}`, { - method: 'GET', - signal: abortController.signal, - }); - - if (response.ok) { - const json = await response.json(); - - if (!abortController.signal.aborted) { - setIlmExplain(json); + const response = await httpFetch>( + `${ILM_EXPLAIN_ENDPOINT}/${encodedIndexName}`, + { + method: 'GET', + signal: abortController.signal, } - } else { - throw new Error(response.statusText); + ); + + if (!abortController.signal.aborted) { + setIlmExplain(response); } } catch (e) { if (!abortController.signal.aborted) { - setError(i18n.ERROR_LOADING_ILM_EXPLAIN(e)); + setError(i18n.ERROR_LOADING_ILM_EXPLAIN(e.message)); } } finally { if (!abortController.signal.aborted) { @@ -63,7 +62,7 @@ export const useIlmExplain = (pattern: string): UseIlmExplain => { return () => { abortController.abort(); }; - }, [pattern, setError]); + }, [httpFetch, pattern, setError]); return { ilmExplain, error, loading }; }; diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_mappings/helpers.test.ts b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_mappings/helpers.test.ts index e1865c31c85df65..b3a31228bb059a3 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_mappings/helpers.test.ts +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_mappings/helpers.test.ts @@ -9,26 +9,13 @@ import { fetchMappings } from './helpers'; import { mockMappingsResponse } from '../mock/mappings_response/mock_mappings_response'; describe('helpers', () => { - let originalFetch: typeof global['fetch']; - - beforeAll(() => { - originalFetch = global.fetch; - }); - - afterAll(() => { - global.fetch = originalFetch; - }); - describe('fetchMappings', () => { test('it returns the expected mappings', async () => { - const mockFetch = jest.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockMappingsResponse), - }); - global.fetch = mockFetch; + const mockFetch = jest.fn().mockResolvedValue(mockMappingsResponse); const result = await fetchMappings({ abortController: new AbortController(), + httpFetch: mockFetch, patternOrIndexName: 'auditbeat-custom-index-1', }); @@ -68,16 +55,14 @@ describe('helpers', () => { test('it throws the expected error when fetch fails', async () => { const error = 'simulated error'; - const mockFetch = jest.fn().mockResolvedValue({ - ok: false, - statusText: error, + const mockFetch = jest.fn().mockImplementation(() => { + throw new Error(error); }); - global.fetch = mockFetch; - await expect( fetchMappings({ abortController: new AbortController(), + httpFetch: mockFetch, patternOrIndexName: 'auditbeat-custom-index-1', }) ).rejects.toThrowError( diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_mappings/helpers.ts b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_mappings/helpers.ts index 57d7787bbfee991..8aab64729df2f5c 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_mappings/helpers.ts +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_mappings/helpers.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { HttpHandler } from '@kbn/core-http-browser'; import type { IndicesGetMappingIndexMappingRecord } from '@elastic/elasticsearch/lib/api/types'; import * as i18n from '../translations'; @@ -13,23 +14,24 @@ export const MAPPINGS_API_ROUTE = '/internal/ecs_data_quality_dashboard/mappings export async function fetchMappings({ abortController, + httpFetch, patternOrIndexName, }: { abortController: AbortController; + httpFetch: HttpHandler; patternOrIndexName: string; }): Promise> { const encodedIndexName = encodeURIComponent(`${patternOrIndexName}`); - const response = await fetch(`${MAPPINGS_API_ROUTE}/${encodedIndexName}`, { - method: 'GET', - signal: abortController.signal, - }); - - if (response.ok) { - return response.json(); + try { + return await httpFetch>( + `${MAPPINGS_API_ROUTE}/${encodedIndexName}`, + { + method: 'GET', + signal: abortController.signal, + } + ); + } catch (e) { + throw new Error(i18n.ERROR_LOADING_MAPPINGS({ details: e.message, patternOrIndexName })); } - - throw new Error( - i18n.ERROR_LOADING_MAPPINGS({ details: response.statusText, patternOrIndexName }) - ); } diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_mappings/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_mappings/index.test.tsx new file mode 100644 index 000000000000000..c2caba60b066d99 --- /dev/null +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_mappings/index.test.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; + +import { DataQualityProvider } from '../data_quality_panel/data_quality_context'; +import { mockMappingsResponse } from '../mock/mappings_response/mock_mappings_response'; +import { ERROR_LOADING_MAPPINGS } from '../translations'; +import { useMappings } from '.'; + +const mockHttpFetch = jest.fn(); +const ContextWrapper: React.FC = ({ children }) => ( + {children} +); + +const pattern = 'auditbeat-*'; + +describe('useMappings', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('happy path', () => { + beforeEach(() => { + mockHttpFetch.mockResolvedValue(mockMappingsResponse); + }); + + test('it returns the expected mappings', async () => { + const { result, waitForNextUpdate } = renderHook(() => useMappings(pattern), { + wrapper: ContextWrapper, + }); + await waitForNextUpdate(); + const { indexes } = await result.current; + + expect(indexes).toEqual(mockMappingsResponse); + }); + + test('it returns loading: false, because the data has loaded', async () => { + const { result, waitForNextUpdate } = renderHook(() => useMappings(pattern), { + wrapper: ContextWrapper, + }); + await waitForNextUpdate(); + const { loading } = await result.current; + + expect(loading).toBe(false); + }); + + test('it returns a null error, because no errors occurred', async () => { + const { result, waitForNextUpdate } = renderHook(() => useMappings(pattern), { + wrapper: ContextWrapper, + }); + await waitForNextUpdate(); + const { error } = await result.current; + + expect(error).toBeNull(); + }); + }); + + describe('fetch rejects with an error', () => { + const errorMessage = 'simulated error'; + + beforeEach(() => { + mockHttpFetch.mockRejectedValue(new Error(errorMessage)); + }); + + test('it returns null mappings, because an error occurred', async () => { + const { result, waitForNextUpdate } = renderHook(() => useMappings(pattern), { + wrapper: ContextWrapper, + }); + await waitForNextUpdate(); + const { indexes } = await result.current; + + expect(indexes).toBeNull(); + }); + + test('it returns loading: false, because data loading reached a terminal state', async () => { + const { result, waitForNextUpdate } = renderHook(() => useMappings(pattern), { + wrapper: ContextWrapper, + }); + await waitForNextUpdate(); + const { loading } = await result.current; + + expect(loading).toBe(false); + }); + + test('it returns the expected error', async () => { + const { result, waitForNextUpdate } = renderHook(() => useMappings(pattern), { + wrapper: ContextWrapper, + }); + await waitForNextUpdate(); + const { error } = await result.current; + + expect(error).toEqual( + ERROR_LOADING_MAPPINGS({ details: errorMessage, patternOrIndexName: pattern }) + ); + }); + }); +}); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_mappings/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_mappings/index.tsx index 09c47bb5532d771..dbf1a23dfc991d7 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_mappings/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_mappings/index.tsx @@ -8,8 +8,7 @@ import type { IndicesGetMappingIndexMappingRecord } from '@elastic/elasticsearch/lib/api/types'; import { useEffect, useState } from 'react'; -import * as i18n from '../translations'; - +import { useDataQualityContext } from '../data_quality_panel/data_quality_context'; import { fetchMappings } from './helpers'; interface UseMappings { @@ -23,6 +22,7 @@ export const useMappings = (patternOrIndexName: string): UseMappings => { string, IndicesGetMappingIndexMappingRecord > | null>(null); + const { httpFetch } = useDataQualityContext(); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); @@ -31,10 +31,14 @@ export const useMappings = (patternOrIndexName: string): UseMappings => { async function fetchData() { try { - setIndexes(await fetchMappings({ abortController, patternOrIndexName })); + const response = await fetchMappings({ abortController, httpFetch, patternOrIndexName }); + + if (!abortController.signal.aborted) { + setIndexes(response); + } } catch (e) { if (!abortController.signal.aborted) { - setError(i18n.ERROR_LOADING_MAPPINGS({ details: e, patternOrIndexName })); + setError(e.message); } } finally { if (!abortController.signal.aborted) { @@ -48,7 +52,7 @@ export const useMappings = (patternOrIndexName: string): UseMappings => { return () => { abortController.abort(); }; - }, [patternOrIndexName, setError]); + }, [httpFetch, patternOrIndexName, setError]); return { indexes, error, loading }; }; diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_stats/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_stats/index.test.tsx new file mode 100644 index 000000000000000..11f8720b039c847 --- /dev/null +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_stats/index.test.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; + +import { DataQualityProvider } from '../data_quality_panel/data_quality_context'; +import { mockStatsGreenIndex } from '../mock/stats/mock_stats_green_index'; +import { ERROR_LOADING_STATS } from '../translations'; +import { useStats } from '.'; + +const mockHttpFetch = jest.fn(); +const ContextWrapper: React.FC = ({ children }) => ( + {children} +); + +const pattern = 'auditbeat-*'; + +describe('useStats', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('happy path', () => { + beforeEach(() => { + mockHttpFetch.mockResolvedValue(mockStatsGreenIndex); + }); + + test('it returns the expected stats', async () => { + const { result, waitForNextUpdate } = renderHook(() => useStats(pattern), { + wrapper: ContextWrapper, + }); + await waitForNextUpdate(); + const { stats } = await result.current; + + expect(stats).toEqual(mockStatsGreenIndex); + }); + + test('it returns loading: false, because the data has loaded', async () => { + const { result, waitForNextUpdate } = renderHook(() => useStats(pattern), { + wrapper: ContextWrapper, + }); + await waitForNextUpdate(); + const { loading } = await result.current; + + expect(loading).toBe(false); + }); + + test('it returns a null error, because no errors occurred', async () => { + const { result, waitForNextUpdate } = renderHook(() => useStats(pattern), { + wrapper: ContextWrapper, + }); + await waitForNextUpdate(); + const { error } = await result.current; + + expect(error).toBeNull(); + }); + }); + + describe('fetch rejects with an error', () => { + const errorMessage = 'simulated error'; + + beforeEach(() => { + mockHttpFetch.mockRejectedValue(new Error(errorMessage)); + }); + + test('it returns null stats, because an error occurred', async () => { + const { result, waitForNextUpdate } = renderHook(() => useStats(pattern), { + wrapper: ContextWrapper, + }); + await waitForNextUpdate(); + const { stats } = await result.current; + + expect(stats).toBeNull(); + }); + + test('it returns loading: false, because data loading reached a terminal state', async () => { + const { result, waitForNextUpdate } = renderHook(() => useStats(pattern), { + wrapper: ContextWrapper, + }); + await waitForNextUpdate(); + const { loading } = await result.current; + + expect(loading).toBe(false); + }); + + test('it returns the expected error', async () => { + const { result, waitForNextUpdate } = renderHook(() => useStats(pattern), { + wrapper: ContextWrapper, + }); + await waitForNextUpdate(); + const { error } = await result.current; + + expect(error).toEqual(ERROR_LOADING_STATS(errorMessage)); + }); + }); +}); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_stats/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_stats/index.tsx index d2c39e70fb9c969..49b4d379baa57f5 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_stats/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_stats/index.tsx @@ -8,6 +8,7 @@ import type { IndicesStatsIndicesStats } from '@elastic/elasticsearch/lib/api/types'; import { useEffect, useState } from 'react'; +import { useDataQualityContext } from '../data_quality_panel/data_quality_context'; import * as i18n from '../translations'; const STATS_ENDPOINT = '/internal/ecs_data_quality_dashboard/stats'; @@ -19,6 +20,7 @@ interface UseStats { } export const useStats = (pattern: string): UseStats => { + const { httpFetch } = useDataQualityContext(); const [stats, setStats] = useState | null>(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); @@ -30,23 +32,20 @@ export const useStats = (pattern: string): UseStats => { try { const encodedIndexName = encodeURIComponent(`${pattern}`); - const response = await fetch(`${STATS_ENDPOINT}/${encodedIndexName}`, { - method: 'GET', - signal: abortController.signal, - }); - - if (response.ok) { - const json = await response.json(); - - if (!abortController.signal.aborted) { - setStats(json); + const response = await httpFetch>( + `${STATS_ENDPOINT}/${encodedIndexName}`, + { + method: 'GET', + signal: abortController.signal, } - } else { - throw new Error(response.statusText); + ); + + if (!abortController.signal.aborted) { + setStats(response); } } catch (e) { if (!abortController.signal.aborted) { - setError(i18n.ERROR_LOADING_STATS(e)); + setError(i18n.ERROR_LOADING_STATS(e.message)); } } finally { if (!abortController.signal.aborted) { @@ -60,7 +59,7 @@ export const useStats = (pattern: string): UseStats => { return () => { abortController.abort(); }; - }, [pattern, setError]); + }, [httpFetch, pattern, setError]); return { stats, error, loading }; }; diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_unallowed_values/helpers.test.ts b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_unallowed_values/helpers.test.ts index 2f80ba5e2cc7a55..7b0a77d9af564e6 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_unallowed_values/helpers.test.ts +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_unallowed_values/helpers.test.ts @@ -389,15 +389,12 @@ describe('helpers', () => { ]; test('it includes the expected content in the `fetch` request', async () => { - const mockFetch = jest.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockUnallowedValuesResponse), - }); - global.fetch = mockFetch; + const mockFetch = jest.fn().mockResolvedValue(mockUnallowedValuesResponse); const abortController = new AbortController(); await fetchUnallowedValues({ abortController, + httpFetch: mockFetch, indexName: 'auditbeat-custom-index-1', requestItems, }); @@ -406,7 +403,7 @@ describe('helpers', () => { '/internal/ecs_data_quality_dashboard/unallowed_field_values', { body: JSON.stringify(requestItems), - headers: { 'Content-Type': 'application/json', 'kbn-xsrf': 'xsrf' }, + headers: { 'Content-Type': 'application/json' }, method: 'POST', signal: abortController.signal, } @@ -414,14 +411,11 @@ describe('helpers', () => { }); test('it returns the expected unallowed values', async () => { - const mockFetch = jest.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockUnallowedValuesResponse), - }); - global.fetch = mockFetch; + const mockFetch = jest.fn().mockResolvedValue(mockUnallowedValuesResponse); const result = await fetchUnallowedValues({ abortController: new AbortController(), + httpFetch: mockFetch, indexName: 'auditbeat-custom-index-1', requestItems, }); @@ -483,15 +477,14 @@ describe('helpers', () => { test('it throws the expected error when fetch fails', async () => { const error = 'simulated error'; - const mockFetch = jest.fn().mockResolvedValue({ - ok: false, - statusText: error, + const mockFetch = jest.fn().mockImplementation(() => { + throw new Error(error); }); - global.fetch = mockFetch; await expect( fetchUnallowedValues({ abortController: new AbortController(), + httpFetch: mockFetch, indexName: 'auditbeat-custom-index-1', requestItems, }) diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_unallowed_values/helpers.ts b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_unallowed_values/helpers.ts index e1ee93b72b283b5..5a331e7e1b8dab7 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_unallowed_values/helpers.ts +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_unallowed_values/helpers.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { HttpHandler } from '@kbn/core-http-browser'; import * as i18n from '../translations'; import type { Bucket, @@ -65,28 +66,28 @@ export const getUnallowedValues = ({ export async function fetchUnallowedValues({ abortController, + httpFetch, indexName, requestItems, }: { abortController: AbortController; + httpFetch: HttpHandler; indexName: string; requestItems: UnallowedValueRequestItem[]; }): Promise { - const response = await fetch(UNALLOWED_VALUES_API_ROUTE, { - body: JSON.stringify(requestItems), - headers: { 'Content-Type': 'application/json', 'kbn-xsrf': 'xsrf' }, - method: 'POST', - signal: abortController.signal, - }); - - if (response.ok) { - return response.json(); + try { + return await httpFetch(UNALLOWED_VALUES_API_ROUTE, { + body: JSON.stringify(requestItems), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + signal: abortController.signal, + }); + } catch (e) { + throw new Error( + i18n.ERROR_LOADING_UNALLOWED_VALUES({ + details: e.message, + indexName, + }) + ); } - - throw new Error( - i18n.ERROR_LOADING_UNALLOWED_VALUES({ - details: response.statusText, - indexName, - }) - ); } diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_unallowed_values/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_unallowed_values/index.test.tsx new file mode 100644 index 000000000000000..d91ae8da0b37ce2 --- /dev/null +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_unallowed_values/index.test.tsx @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EcsFlat } from '@kbn/ecs'; +import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; + +import { getUnallowedValueRequestItems } from '../data_quality_panel/allowed_values/helpers'; +import { DataQualityProvider } from '../data_quality_panel/data_quality_context'; +import { mockUnallowedValuesResponse } from '../mock/unallowed_values/mock_unallowed_values'; +import { ERROR_LOADING_UNALLOWED_VALUES } from '../translations'; +import { EcsMetadata, UnallowedValueRequestItem } from '../types'; +import { useUnallowedValues } from '.'; + +const mockHttpFetch = jest.fn(); +const ContextWrapper: React.FC = ({ children }) => ( + {children} +); + +const ecsMetadata = EcsFlat as unknown as Record; +const indexName = 'auditbeat-custom-index-1'; +const requestItems = getUnallowedValueRequestItems({ + ecsMetadata, + indexName, +}); + +describe('useUnallowedValues', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when requestItems is empty', () => { + const emptyRequestItems: UnallowedValueRequestItem[] = []; + + test('it does NOT make an http request', async () => { + await renderHook( + () => + useUnallowedValues({ + indexName, + requestItems: emptyRequestItems, // <-- empty + }), + { + wrapper: ContextWrapper, + } + ); + + expect(mockHttpFetch).not.toBeCalled(); + }); + }); + + describe('happy path', () => { + beforeEach(() => { + mockHttpFetch.mockResolvedValue(mockUnallowedValuesResponse); + }); + + test('it returns the expected unallowed values', async () => { + const { result, waitForNextUpdate } = renderHook( + () => useUnallowedValues({ indexName, requestItems }), + { + wrapper: ContextWrapper, + } + ); + await waitForNextUpdate(); + const { unallowedValues } = await result.current; + + expect(unallowedValues).toEqual({ + 'event.category': [ + { count: 2, fieldName: 'an_invalid_category' }, + { count: 1, fieldName: 'theory' }, + ], + 'event.kind': [], + 'event.outcome': [], + 'event.type': [], + }); + }); + + test('it returns loading: false, because the data has loaded', async () => { + const { result, waitForNextUpdate } = renderHook( + () => useUnallowedValues({ indexName, requestItems }), + { + wrapper: ContextWrapper, + } + ); + await waitForNextUpdate(); + const { loading } = await result.current; + + expect(loading).toBe(false); + }); + + test('it returns a null error, because no errors occurred', async () => { + const { result, waitForNextUpdate } = renderHook( + () => useUnallowedValues({ indexName, requestItems }), + { + wrapper: ContextWrapper, + } + ); + await waitForNextUpdate(); + const { error } = await result.current; + + expect(error).toBeNull(); + }); + }); + + describe('fetch rejects with an error', () => { + const errorMessage = 'simulated error'; + + beforeEach(() => { + mockHttpFetch.mockRejectedValue(new Error(errorMessage)); + }); + + test('it returns null unallowed values, because an error occurred', async () => { + const { result, waitForNextUpdate } = renderHook( + () => useUnallowedValues({ indexName, requestItems }), + { + wrapper: ContextWrapper, + } + ); + await waitForNextUpdate(); + const { unallowedValues } = await result.current; + + expect(unallowedValues).toBeNull(); + }); + + test('it returns loading: false, because data loading reached a terminal state', async () => { + const { result, waitForNextUpdate } = renderHook( + () => useUnallowedValues({ indexName, requestItems }), + { + wrapper: ContextWrapper, + } + ); + await waitForNextUpdate(); + const { loading } = await result.current; + + expect(loading).toBe(false); + }); + + test('it returns the expected error', async () => { + const { result, waitForNextUpdate } = renderHook( + () => useUnallowedValues({ indexName, requestItems }), + { + wrapper: ContextWrapper, + } + ); + await waitForNextUpdate(); + const { error } = await result.current; + + expect(error).toEqual(ERROR_LOADING_UNALLOWED_VALUES({ details: errorMessage, indexName })); + }); + }); +}); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_unallowed_values/index.ts b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_unallowed_values/index.tsx similarity index 85% rename from x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_unallowed_values/index.ts rename to x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_unallowed_values/index.tsx index 744cc344cea122d..87a8b10cbc7f2df 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_unallowed_values/index.ts +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/use_unallowed_values/index.tsx @@ -7,6 +7,7 @@ import { useEffect, useState } from 'react'; +import { useDataQualityContext } from '../data_quality_panel/data_quality_context'; import { fetchUnallowedValues, getUnallowedValues } from './helpers'; import type { UnallowedValueCount, UnallowedValueRequestItem } from '../types'; @@ -27,6 +28,7 @@ export const useUnallowedValues = ({ string, UnallowedValueCount[] > | null>(null); + const { httpFetch } = useDataQualityContext(); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); @@ -39,14 +41,9 @@ export const useUnallowedValues = ({ async function fetchData() { try { - // if (indexName === '.ds-logs-endpoint.alerts-default-2023.01.17-000001') { - // throw new Error( - // 'simulated useUnallowedValues failure just for .ds-logs-endpoint.alerts-default-2023.01.17-000001' - // ); - // } - const searchResults = await fetchUnallowedValues({ abortController, + httpFetch, indexName, requestItems, }); @@ -61,7 +58,7 @@ export const useUnallowedValues = ({ } } catch (e) { if (!abortController.signal.aborted) { - setError(e); + setError(e.message); } } finally { if (!abortController.signal.aborted) { @@ -75,7 +72,7 @@ export const useUnallowedValues = ({ return () => { abortController.abort(); }; - }, [indexName, requestItems, setError]); + }, [httpFetch, indexName, requestItems, setError]); return { unallowedValues, error, loading }; }; diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/tsconfig.json b/x-pack/packages/security-solution/ecs_data_quality_dashboard/tsconfig.json index 8cd3ca4ed16ac15..8e6edbf56af820c 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/tsconfig.json +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/tsconfig.json @@ -20,5 +20,7 @@ "@kbn/i18n", "@kbn/i18n-react", "@kbn/ui-theme", + "@kbn/core-http-browser", + "@kbn/core-http-browser-mocks", ] } diff --git a/x-pack/plugins/security_solution/public/overview/pages/data_quality.test.tsx b/x-pack/plugins/security_solution/public/overview/pages/data_quality.test.tsx new file mode 100644 index 000000000000000..f74847723451a88 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/pages/data_quality.test.tsx @@ -0,0 +1,290 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; + +import { useKibana as mockUseKibana } from '../../common/lib/kibana/__mocks__'; +import { TestProviders } from '../../common/mock'; +import { DataQuality } from './data_quality'; +import { HOT, WARM, UNMANAGED } from './translations'; + +const mockedUseKibana = mockUseKibana(); +jest.mock('../../common/lib/kibana', () => { + const original = jest.requireActual('../../common/lib/kibana'); + + const mockKibanaServices = { + get: () => ({ + http: { fetch: jest.fn() }, + }), + }; + + return { + ...original, + KibanaServices: mockKibanaServices, + useGetUserCasesPermissions: () => ({ + all: false, + create: false, + read: true, + update: false, + delete: false, + push: false, + }), + useKibana: () => ({ + ...mockedUseKibana, + services: { + ...mockedUseKibana.services, + cases: { + api: { + getRelatedCases: jest.fn(), + }, + hooks: { + useCasesAddToNewCaseFlyout: jest.fn(), + }, + }, + }, + }), + useUiSetting$: () => ['0,0.[000]'], + }; +}); + +const defaultUseSourcererReturn = { + indexPattern: '', + indicesExist: true, + loading: false, + selectedPatterns: ['auditbeat-*', 'logs-*', 'packetbeat-*'], +}; +const mockUseSourcererDataView = jest.fn(() => defaultUseSourcererReturn); +jest.mock('../../common/containers/sourcerer', () => ({ + useSourcererDataView: () => mockUseSourcererDataView(), +})); + +const defaultUseSignalIndexReturn = { + loading: false, + signalIndexName: '.alerts-security.alerts-default', +}; +const mockUseSignalIndex = jest.fn(() => defaultUseSignalIndexReturn); +jest.mock('../../detections/containers/detection_engine/alerts/use_signal_index', () => ({ + useSignalIndex: () => mockUseSignalIndex(), +})); + +describe('DataQuality', () => { + const defaultIlmPhases = `${HOT}${WARM}${UNMANAGED}`; + + beforeEach(() => { + jest.resetAllMocks(); + + mockUseSourcererDataView.mockReturnValue(defaultUseSourcererReturn); + mockUseSignalIndex.mockReturnValue(defaultUseSignalIndexReturn); + }); + + describe('when indices exist, and loading is complete', () => { + beforeEach(async () => { + render( + + + + + + ); + + await waitFor(() => {}); + }); + + test('it renders the expected default ILM phases', () => { + expect(screen.getByTestId('selectIlmPhases')).toHaveTextContent(defaultIlmPhases); + }); + + test('it does NOT render the loading spinner', () => { + expect(screen.queryByTestId('ecsDataQualityDashboardLoader')).not.toBeInTheDocument(); + }); + + test('it renders data quality panel content', () => { + expect(screen.getByTestId('dataQualitySummary')).toBeInTheDocument(); + }); + + test('it does NOT render the landing page', () => { + expect(screen.queryByTestId('siem-landing-page')).not.toBeInTheDocument(); + }); + }); + + describe('when indices exist, but sourcerer is still loading', () => { + beforeEach(async () => { + mockUseSourcererDataView.mockReturnValue({ ...defaultUseSourcererReturn, loading: true }); + + render( + + + + + + ); + + await waitFor(() => {}); + }); + + test('it does NOT render the ILM phases selection', () => { + expect(screen.queryByTestId('selectIlmPhases')).not.toBeInTheDocument(); + }); + + test('it renders the loading spinner', () => { + expect(screen.getByTestId('ecsDataQualityDashboardLoader')).toBeInTheDocument(); + }); + + test('it does NOT render the data quality panel content', () => { + expect(screen.queryByTestId('dataQualitySummary')).not.toBeInTheDocument(); + }); + + test('it does NOT render the landing page', () => { + expect(screen.queryByTestId('siem-landing-page')).not.toBeInTheDocument(); + }); + }); + + describe('when indices exist, but the signal index name is still loading', () => { + beforeEach(async () => { + mockUseSignalIndex.mockReturnValue({ ...defaultUseSignalIndexReturn, loading: true }); + + render( + + + + + + ); + + await waitFor(() => {}); + }); + + test('it renders the expected default ILM phases', () => { + expect(screen.getByTestId('selectIlmPhases')).toHaveTextContent(defaultIlmPhases); + }); + + test('it renders the loading spinner', () => { + expect(screen.getByTestId('ecsDataQualityDashboardLoader')).toBeInTheDocument(); + }); + + test('it does NOT render the data quality panel content', () => { + expect(screen.queryByTestId('dataQualitySummary')).not.toBeInTheDocument(); + }); + + test('it does NOT render the landing page', () => { + expect(screen.queryByTestId('siem-landing-page')).not.toBeInTheDocument(); + }); + }); + + describe('when indices do NOT exist, and loading is complete', () => { + beforeEach(async () => { + mockUseSourcererDataView.mockReturnValue({ + ...defaultUseSourcererReturn, + indicesExist: false, + loading: false, + }); + mockUseSignalIndex.mockReturnValue({ ...defaultUseSignalIndexReturn, loading: false }); + + render( + + + + + + ); + + await waitFor(() => {}); + }); + + test('it does NOT render the ILM phases selection', () => { + expect(screen.queryByTestId('selectIlmPhases')).not.toBeInTheDocument(); + }); + + test('it does NOT render the loading spinner', () => { + expect(screen.queryByTestId('ecsDataQualityDashboardLoader')).not.toBeInTheDocument(); + }); + + test('it does NOT render the data quality panel content', () => { + expect(screen.queryByTestId('dataQualitySummary')).not.toBeInTheDocument(); + }); + + test('it renders the landing page', () => { + expect(screen.getByTestId('siem-landing-page')).toBeInTheDocument(); + }); + }); + + describe('when indices do NOT exist, but sourcerer is still loading', () => { + beforeEach(async () => { + mockUseSourcererDataView.mockReturnValue({ + ...defaultUseSourcererReturn, + indicesExist: false, + loading: true, + }); + mockUseSignalIndex.mockReturnValue({ ...defaultUseSignalIndexReturn, loading: false }); + + render( + + + + + + ); + + await waitFor(() => {}); + }); + + test('it does NOT render the ILM phases selection', () => { + expect(screen.queryByTestId('selectIlmPhases')).not.toBeInTheDocument(); + }); + + test('it renders the loading spinner', () => { + expect(screen.getByTestId('ecsDataQualityDashboardLoader')).toBeInTheDocument(); + }); + + test('it does NOT render the data quality panel content', () => { + expect(screen.queryByTestId('dataQualitySummary')).not.toBeInTheDocument(); + }); + + test('it does NOT render the landing page', () => { + expect(screen.queryByTestId('siem-landing-page')).not.toBeInTheDocument(); + }); + }); + + describe('when indices do NOT exist, but the signal index name is still loading', () => { + beforeEach(async () => { + mockUseSourcererDataView.mockReturnValue({ + ...defaultUseSourcererReturn, + indicesExist: false, + loading: false, + }); + mockUseSignalIndex.mockReturnValue({ ...defaultUseSignalIndexReturn, loading: true }); + + render( + + + + + + ); + + await waitFor(() => {}); + }); + + test('it does NOT render the ILM phases selection', () => { + expect(screen.queryByTestId('selectIlmPhases')).not.toBeInTheDocument(); + }); + + test('it does NOT render the loading spinner', () => { + expect(screen.queryByTestId('ecsDataQualityDashboardLoader')).not.toBeInTheDocument(); + }); + + test('it does NOT render the data quality panel content', () => { + expect(screen.queryByTestId('dataQualitySummary')).not.toBeInTheDocument(); + }); + + test('it renders the landing page', () => { + expect(screen.getByTestId('siem-landing-page')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/overview/pages/data_quality.tsx b/x-pack/plugins/security_solution/public/overview/pages/data_quality.tsx index 4f7df917152474d..04bad7c8188abd1 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/data_quality.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/data_quality.tsx @@ -39,6 +39,7 @@ import { SecuritySolutionPageWrapper } from '../../common/components/page_wrappe import { DEFAULT_BYTES_FORMAT, DEFAULT_NUMBER_FORMAT } from '../../../common/constants'; import { useSourcererDataView } from '../../common/containers/sourcerer'; import { + KibanaServices, useGetUserCasesPermissions, useKibana, useToasts, @@ -127,6 +128,7 @@ const renderOption = ( ); const DataQualityComponent: React.FC = () => { + const httpFetch = KibanaServices.get().http.fetch; const theme = useTheme(); const toasts = useToasts(); const addSuccessToast = useCallback( @@ -200,46 +202,50 @@ const DataQualityComponent: React.FC = () => { [createCaseFlyout] ); + if (isSourcererLoading) { + return ; + } + return ( <> {indicesExist ? ( - <> - - - - - - - - - - {isSourcererLoading || isSignalIndexNameLoading ? ( - - ) : ( - - )} - - + + + + + + + + + + {isSignalIndexNameLoading ? ( + + ) : ( + + )} + ) : ( )}