Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import { render } from '@testing-library/react';
import { TestProviders } from '../../mock';
import { AlertCountByRuleByStatus } from './alert_count_by_rule_by_status';
import { COLUMN_HEADER_COUNT, COLUMN_HEADER_RULE_NAME } from './translations';
import type { UseAlertCountByRuleByStatus } from './use_alert_count_by_rule_by_status';
import type {
UseAlertCountByRuleByStatus,
UseAlertCountByRuleByStatusProps,
} from './use_alert_count_by_rule_by_status';
import type { EntityStoreRecord } from '../../../flyout/entity_details/shared/hooks/use_entity_from_store';

type UseAlertCountByRuleByStatusReturn = ReturnType<UseAlertCountByRuleByStatus>;
const defaultUseAlertCountByRuleByStatusReturn: UseAlertCountByRuleByStatusReturn = {
Expand All @@ -21,7 +25,9 @@ const defaultUseAlertCountByRuleByStatusReturn: UseAlertCountByRuleByStatusRetur
updatedAt: Date.now(),
};

const mockUseAlertCountByRuleByStatus = jest.fn(() => defaultUseAlertCountByRuleByStatusReturn);
const mockUseAlertCountByRuleByStatus = jest.fn(
(_props: UseAlertCountByRuleByStatusProps) => defaultUseAlertCountByRuleByStatusReturn
);
const mockUseAlertCountByRuleByStatusReturn = (
overrides: Partial<UseAlertCountByRuleByStatusReturn>
) => {
Expand All @@ -32,19 +38,32 @@ const mockUseAlertCountByRuleByStatusReturn = (
};

jest.mock('./use_alert_count_by_rule_by_status', () => ({
useAlertCountByRuleByStatus: () => mockUseAlertCountByRuleByStatus(),
useAlertCountByRuleByStatus: (props: UseAlertCountByRuleByStatusProps) =>
mockUseAlertCountByRuleByStatus(props),
}));

const renderComponent = () =>
jest.mock('@kbn/entity-store/public', () => ({
FF_ENABLE_ENTITY_STORE_V2: 'securitySolution:entityStoreEnableV2',
useEntityStoreEuidApi: jest.fn(() => undefined),
}));

jest.mock('../../lib/kibana/kibana_react', () => {
const actual = jest.requireActual('../../lib/kibana/kibana_react');
return { ...actual, useUiSetting: jest.fn(() => false) };
});

jest.mock('../../hooks/timeline/use_investigate_in_timeline', () => ({
useInvestigateInTimeline: jest.fn(() => ({ investigateInTimeline: jest.fn() })),
}));

const entityFilter = { field: 'host.hostname', value: 'some_host_name' };

const renderComponent = (
overrides: Partial<React.ComponentProps<typeof AlertCountByRuleByStatus>> = {}
) =>
render(
<TestProviders>
<AlertCountByRuleByStatus
entityFilter={{
field: 'host.hostname',
value: 'some_host_name',
}}
signalIndexName={''}
/>
<AlertCountByRuleByStatus entityFilter={entityFilter} signalIndexName={''} {...overrides} />
</TestProviders>
);

Expand Down Expand Up @@ -83,6 +102,36 @@ describe('AlertCountByRuleByStatus', () => {
expect(queryByTestId(COLUMN_HEADER_RULE_NAME)).toHaveTextContent('Test Name');
expect(queryByTestId(COLUMN_HEADER_COUNT)).toHaveTextContent('100');
});

it('should pass resolved identityFields from entityFilter to the hook', () => {
renderComponent();

expect(mockUseAlertCountByRuleByStatus).toHaveBeenCalledWith(
expect.objectContaining({
identityFields: { 'host.hostname': 'some_host_name' },
})
);
});

it('should prefer identityFields prop over entityFilter when both are provided', () => {
const identityFields = { 'host.id': 'host-uuid-123', 'entity.id': 'entity-abc' };
renderComponent({ identityFields });

expect(mockUseAlertCountByRuleByStatus).toHaveBeenCalledWith(
expect.objectContaining({
identityFields,
})
);
});

it('should pass entityRecord and entityType to the hook if defined', () => {
const entityRecord = { 'host.name': ['some_host_name'] } as unknown as EntityStoreRecord;
renderComponent({ entityRecord, entityType: 'host' });

expect(mockUseAlertCountByRuleByStatus).toHaveBeenCalledWith(
expect.objectContaining({ entityRecord, entityType: 'host' })
);
});
});

const mockItem = [
Expand Down
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Comment thread
ymao1 marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ import React, { useCallback, useMemo } from 'react';
import type { EuiBasicTableColumn } from '@elastic/eui';
import { EuiBasicTable, EuiEmptyPrompt, EuiLink, EuiPanel, EuiToolTip } from '@elastic/eui';
import { euiStyled } from '@kbn/kibana-react-plugin/common';

import type { EntityType } from '@kbn/entity-store/public';
import { FF_ENABLE_ENTITY_STORE_V2, useEntityStoreEuidApi } from '@kbn/entity-store/public';
import type { EntityStoreRecord } from '../../../flyout/entity_details/shared/hooks/use_entity_from_store';
import type { ESBoolQuery } from '../../../../common/typed_json';
import type { Status } from '../../../../common/api/detection_engine';
import { SecurityPageName } from '../../../../common/constants';
import type { Filter } from '../../../overview/components/detection_response/hooks/use_navigate_to_timeline';
import { useNavigateToTimeline } from '../../../overview/components/detection_response/hooks/use_navigate_to_timeline';
import {
SIGNAL_RULE_NAME_FIELD_NAME,
SIGNAL_STATUS_FIELD_NAME,
Expand All @@ -33,27 +33,32 @@ import { MultiSelectPopover } from './components';
import * as i18n from './translations';
import type { AlertCountByRuleByStatusItem } from './use_alert_count_by_rule_by_status';
import { useAlertCountByRuleByStatus } from './use_alert_count_by_rule_by_status';
import { useUiSetting } from '../../lib/kibana/kibana_react';
import { useInvestigateInTimeline } from '../../hooks/timeline/use_investigate_in_timeline';

interface EntityFilter {
field: string;
value: string;
entityType?: string;
}
interface AlertCountByStatusProps {
entityFilter: EntityFilter;
entityFilter?: EntityFilter;
/**
* When set (e.g. host/user details from entity resolution), preferred over legacy `entityFilter.field`.
* Same semantics as `AlertsByStatus` `identityFields`.
*/
identityFields?: Record<string, string> | null;
additionalFilters?: ESBoolQuery[];
signalIndexName: string | null;
entityType?: string;
entityRecord?: EntityStoreRecord | null;
}

interface StatusSelection {
[fieldName: string]: Status[];
}

const DEFAULT_STATUSES: Status[] = ['open'];

type GetTableColumns = (
openRuleInTimelineWithAdditionalFields: (ruleName: string) => void
) => Array<EuiBasicTableColumn<AlertCountByRuleByStatusItem>>;
Expand All @@ -72,36 +77,45 @@ const StyledEuiPanel = euiStyled(EuiPanel)`

export const AlertCountByRuleByStatus = React.memo(
({
entityFilter,
identityFields,
signalIndexName,
additionalFilters,
signalIndexName,
identityFields,
entityFilter,
entityType,
entityRecord,
}: AlertCountByStatusProps) => {
const { field, value, entityType } = entityFilter;

const entityTypeCacheKey = entityType ?? 'generic';
const entityStoreV2Enabled = useUiSetting<boolean>(FF_ENABLE_ENTITY_STORE_V2, false);
const euidApi = useEntityStoreEuidApi();
const entityIdentifiersResolved = useMemo(
() => resolveEntityIdentifiers(identityFields, entityFilter),
[identityFields, entityFilter]
);

const entityFiltersForTimeline: Filter[] = useMemo(() => {
if (entityIdentifiersResolved != null && Object.keys(entityIdentifiersResolved).length > 0) {
return Object.entries(entityIdentifiersResolved).map(([entityField, entityValue]) => ({
field: entityField,
value: entityValue,
}));
const euidEntityKqlFilter = useMemo((): string => {
let kqlFilter: string | null | undefined = '';
if (!entityStoreV2Enabled || !euidApi?.euid || !entityRecord || !entityType) {
kqlFilter = entityIdentifiersResolved
? Object.entries(entityIdentifiersResolved)
.map(([field, value]) => `${field}: "${value}"`)
.join(' AND ')
: null;
} else {
kqlFilter = euidApi.euid.kql.getEuidFilterBasedOnDocument(
entityType as EntityType,
entityRecord
);
}
return [{ field, value }];
}, [entityIdentifiersResolved, field, value]);
return kqlFilter && kqlFilter.length > 0 ? kqlFilter : '';
}, [euidApi?.euid, entityType, entityRecord, entityIdentifiersResolved, entityStoreV2Enabled]);

const queryId = `${ALERT_COUNT_BY_RULE_BY_STATUS}-by-${field}`;
const queryId = `${ALERT_COUNT_BY_RULE_BY_STATUS}-by-${entityType}`;
const { toggleStatus, setToggleStatus } = useQueryToggle(queryId);

const { openTimelineWithFilters } = useNavigateToTimeline();
const { investigateInTimeline } = useInvestigateInTimeline();

const [selectedStatusesByField, setSelectedStatusesByField] = useLocalStorage<StatusSelection>({
defaultValue: {
[field]: ['open'],
[entityTypeCacheKey]: DEFAULT_STATUSES,
},
key: LOCAL_STORAGE_KEY,
isInvalidDefault: (valueFromStorage) => {
Expand All @@ -111,40 +125,44 @@ export const AlertCountByRuleByStatus = React.memo(

const columns = useMemo(() => {
return getTableColumns((ruleName: string) => {
const timelineFilters: Filter[][] = [];

for (const status of selectedStatusesByField[field]) {
timelineFilters.push([
...entityFiltersForTimeline,
{ field: SIGNAL_RULE_NAME_FIELD_NAME, value: ruleName },
{
field: SIGNAL_STATUS_FIELD_NAME,
value: status,
},
]);
if (!euidEntityKqlFilter || euidEntityKqlFilter.length === 0) {
return;
}
openTimelineWithFilters(timelineFilters);

const timelineFilters: string[] = [];

for (const status of selectedStatusesByField[entityTypeCacheKey] || DEFAULT_STATUSES) {
timelineFilters.push(
`${euidEntityKqlFilter} AND ${SIGNAL_RULE_NAME_FIELD_NAME}: "${ruleName}" AND ${SIGNAL_STATUS_FIELD_NAME}: "${status}"`
);
}
investigateInTimeline({
keepDataView: true,
query: {
language: 'kuery',
query: timelineFilters.map((filter) => `(${filter})`).join(' OR '),
},
});
});
}, [entityFiltersForTimeline, field, openTimelineWithFilters, selectedStatusesByField]);
}, [entityTypeCacheKey, euidEntityKqlFilter, investigateInTimeline, selectedStatusesByField]);

const updateSelection = useCallback(
(selection: Status[]) => {
setSelectedStatusesByField({
...selectedStatusesByField,
[field]: selection,
[entityTypeCacheKey]: selection,
});
},
[field, selectedStatusesByField, setSelectedStatusesByField]
[entityTypeCacheKey, selectedStatusesByField, setSelectedStatusesByField]
);

const { items, isLoading, updatedAt } = useAlertCountByRuleByStatus({
additionalFilters,
identityFields: entityIdentifiersResolved,
field,
value,
identityFields: entityIdentifiersResolved ?? identityFields ?? {},
entityType,
entityRecord,
queryId,
statuses: selectedStatusesByField[field] as Status[],
statuses: (selectedStatusesByField[entityTypeCacheKey] || DEFAULT_STATUSES) as Status[],
skip: !toggleStatus,
signalIndexName,
});
Expand All @@ -164,7 +182,7 @@ export const AlertCountByRuleByStatus = React.memo(
<MultiSelectPopover
title={i18n.Status}
allItems={STATUSES}
selectedItems={selectedStatusesByField[field] || ['open']}
selectedItems={selectedStatusesByField[entityTypeCacheKey] || DEFAULT_STATUSES}
onSelectedItemsChange={(selectedItems) =>
updateSelection(selectedItems as Status[])
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,7 @@ export const mockQuery = () => ({
from: '2020-07-07T08:20:18.966Z',
to: '2020-07-08T08:20:18.966Z',
statuses: ['open'],
field: 'test_field',
value: 'test_value',
entityFilters: [],
}),
indexName: 'signalIndexName',
skip: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
buildRuleAlertsByEntityQuery,
useAlertCountByRuleByStatus,
} from './use_alert_count_by_rule_by_status';
import { buildEntityIdentifierTermFilters } from '../../../overview/components/detection_response/alerts_by_status/use_alerts_by_status';

const dateNow = new Date('2022-04-15T12:00:00.000Z').valueOf();
const mockDateNow = jest.fn().mockReturnValue(dateNow);
Expand Down Expand Up @@ -50,6 +51,11 @@ jest.mock('../../lib/kibana', () => ({
useUiSetting: jest.fn(() => false),
}));

jest.mock('@kbn/entity-store/public', () => ({
FF_ENABLE_ENTITY_STORE_V2: 'securitySolution:entityStoreEnableV2',
useEntityStoreEuidApi: jest.fn(() => undefined),
}));

const from = '2020-07-07T08:20:18.966Z';
const to = '2020-07-08T08:20:18.966Z';

Expand All @@ -72,8 +78,6 @@ const renderUseAlertCountByRuleByStatus = (
renderHook(() =>
useAlertCountByRuleByStatus({
skip: false,
field: 'test_field',
value: 'test_value',
statuses: ['open'],
queryId: 'queryId',
signalIndexName: 'signalIndexName',
Expand Down Expand Up @@ -156,9 +160,9 @@ describe('useAlertCountByRuleByStatus', () => {
from,
to,
statuses: ['open'],
field: 'test_field',
value: 'test_value',
identityFields: { 'host.id': 'host-uuid', 'host.name': 'hostname' },
entityFilters: [
buildEntityIdentifierTermFilters({ 'host.id': 'host-uuid', 'host.name': 'hostname' }),
],
}),
})
);
Expand Down
Loading
Loading