mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Security Solution] Implement concurrency control for Prebuilt Upgrade workflow (#203604)
**Resolves:** https://github.com/elastic/kibana/issues/200134 ## Summary This PR implements concurrency control to make sure user has the recent rule updates data in Rule Upgrade flyout. Any modifications saved in Rule Upgrade flyout are reset upon new `revision` or `version` detected. ## Details Concurrency control is important to provide better UX. Multiple users work in Kibana in parallel and new prebuilt rules package version can be released in any time. Attempts to upgrade a rule with outdated `revision` and/or `version` results in failed request. Users may experience multiple rule upgrade failure in that case causing a lot of confusion. More experienced users may guess to reload the page to continue. Typical reasons leading to `revision` and/or `version` change are the following - Current rule has been edited will bump rule's `revision`. For example the rule currently shown in Rule Upgrade flyout has been edited by someone else. - Prebuilt rules package got released will give provide rule assets with higher `version`. Rules having upgrades in the currently installed package and in a new one are affected. This PR mitigates the described issues by implementing concurrency control. It sets up `_review` API endpoint refetch interval to 5 minutes to fetch fresh data. In case a higher `revision` or `version` is detected for some rule this rule's resolved conflicts and customizations performed in Rule Upgrade flyout get cleared. ## Screenshots - `revision` change (refresh interval was reduced to 30 seconds to make the video shorter) https://github.com/user-attachments/assets/98d2a22f-9338-482a-a7b2-1e170b9642ce - `version` change (refresh interval was reduced to 1 minute to make the video shorter) https://github.com/user-attachments/assets/2b7c23f0-5a50-471e-aa7f-8d9b2aecc957 ## How to test locally There are two cases for testing - `revision` change - `version` change ### Test `revision` change Revision change means the rule has been edited. Use the following steps to test it - Ensure the `prebuiltRulesCustomizationEnabled` feature flag is enabled - Allow internal APIs via adding `server.restrictInternalApis: false` to `kibana.dev.yaml` - Clear Elasticsearch data - Run Elasticsearch and Kibana locally (do not open Kibana in a web browser) - Install an outdated version of the `security_detection_engine` Fleet package ```bash curl -X POST --user elastic:changeme -H 'Content-Type: application/json' -H 'kbn-xsrf: 123' -H "elastic-api-version: 2023-10-31" -d '{"force":true}' http://localhost:5601/kbn/api/fleet/epm/packages/security_detection_engine/8.14.1 ``` - Install prebuilt rules ```bash curl -X POST --user elastic:changeme -H 'Content-Type: application/json' -H 'kbn-xsrf: 123' -H "elastic-api-version: 1" -d '{"mode":"ALL_RULES"}' http://localhost:5601/kbn/internal/detection_engine/prebuilt_rules/installation/_perform ``` - Open `Detection Rules (SIEM)` Page -> `Rule Updates` - Open Rule upgrade flyout for some rule - Make changes to rule field(s) and save them (do not upgrade the rule) - Open the other web browser tab with Kibana - Navigate to the same rule's editing page - Change any field and save the changes - Return back to the first tab and wait for data to be refetched (data refresh interval is 5 minutes, wait for `_review` request in the Dev Tool's Network tab) - Make sure the changes you made for field(s) got reverted ### Test `version` change Version change means a new package version was released. Do the following to test it - Ensure the `prebuiltRulesCustomizationEnabled` feature flag is enabled - Allow internal APIs via adding `server.restrictInternalApis: false` to `kibana.dev.yaml` - Clear Elasticsearch data - Run Elasticsearch and Kibana locally (do not open Kibana in a web browser) - Set `xpack.securitySolution.prebuiltRulesPackageVersion: 8.15.2` in `kibana.dev.yaml` - Install an outdated version of the `security_detection_engine` Fleet package ```bash curl -X POST --user elastic:changeme -H 'Content-Type: application/json' -H 'kbn-xsrf: 123' -H "elastic-api-version: 2023-10-31" -d '{"force":true}' http://localhost:5601/kbn/api/fleet/epm/packages/security_detection_engine/8.14.1 ``` - Install prebuilt rules ```bash curl -X POST --user elastic:changeme -H 'Content-Type: application/json' -H 'kbn-xsrf: 123' -H "elastic-api-version: 1" -d '{"mode":"ALL_RULES"}' http://localhost:5601/kbn/internal/detection_engine/prebuilt_rules/installation/_perform ``` - Open `Detection Rules (SIEM)` Page -> `Rule Updates` - Open Rule upgrade flyout for a rule having updates in packages `v8.15.2` and `.8.17.1-beta.1` for example `Suspicious Web Browser Sensitive File Access` - Make changes to rule field(s) and save them (do not upgrade the rule) - Set `xpack.securitySolution.prebuiltRulesPackageVersion: 8.17.1-beta.1` in `kibana.dev.yaml` - Open the other web browser tab with Kibana - Navigate to Security Solution plugin to install the OR install the package `8.17.1-beta.1` via API request ```bash curl -X POST --user elastic:changeme -H 'Content-Type: application/json' -H 'kbn-xsrf: 123' -H "elastic-api-version: 2023-10-31" -d '{"force":true}' http://localhost:5601/kbn/api/fleet/epm/packages/security_detection_engine/8.17.1-beta.1 ``` - Return back to the first tab and wait for data to be refetched (data refresh interval is 5 minutes, wait for `_review` request in the Dev Tool's Network tab) - Make sure the changes you made for field(s) got the recent target rule values Alternatively you can spin up EPR locally and publish package updates with rule's version bumped.
This commit is contained in:
parent
2b5b44249f
commit
19292792aa
4 changed files with 553 additions and 6 deletions
|
@ -140,3 +140,37 @@ export const RULE_MODIFIED_BADGE_DESCRIPTION = i18n.translate(
|
|||
'The rule was edited after installation and field values differs from the values upon installation',
|
||||
}
|
||||
);
|
||||
|
||||
export const RULE_NEW_REVISION_DETECTED_WARNING = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.upgradeFlyout.ruleNewRevisionDetectedWarning',
|
||||
{
|
||||
defaultMessage: 'Installed rule changed',
|
||||
}
|
||||
);
|
||||
|
||||
export const RULE_NEW_REVISION_DETECTED_WARNING_DESCRIPTION = (ruleName: string) =>
|
||||
i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.upgradeFlyout.ruleNewVersionDetectedWarningDescription',
|
||||
{
|
||||
defaultMessage:
|
||||
'Someone edited the installed rule "{ruleName}". Upgrade resolved conflicts were reset.',
|
||||
values: { ruleName },
|
||||
}
|
||||
);
|
||||
|
||||
export const RULE_NEW_VERSION_DETECTED_WARNING = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.upgradeFlyout.ruleNewRevisionDetectedWarning',
|
||||
{
|
||||
defaultMessage: 'New prebuilt rules package was installed',
|
||||
}
|
||||
);
|
||||
|
||||
export const RULE_NEW_VERSION_DETECTED_WARNING_DESCRIPTION = (ruleName: string) =>
|
||||
i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.upgradeFlyout.ruleNewRevisionDetectedWarningDescription',
|
||||
{
|
||||
defaultMessage:
|
||||
'Newer prebuilt rules package were installed in background. It contains a newer rule version for "{ruleName}". Upgrade resolved conflicts were reset.',
|
||||
values: { ruleName },
|
||||
}
|
||||
);
|
||||
|
|
|
@ -39,6 +39,8 @@ import { UpgradeFlyoutSubHeader } from './upgrade_flyout_subheader';
|
|||
import * as ruleDetailsI18n from '../../../../rule_management/components/rule_details/translations';
|
||||
import * as i18n from './translations';
|
||||
|
||||
const REVIEW_PREBUILT_RULES_UPGRADE_REFRESH_INTERVAL = 5 * 60 * 1000;
|
||||
|
||||
export interface UpgradePrebuiltRulesTableState {
|
||||
/**
|
||||
* Rule upgrade state after applying `filterOptions`
|
||||
|
@ -110,6 +112,13 @@ interface UpgradePrebuiltRulesTableContextProviderProps {
|
|||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides necessary data and actions for Rules Upgrade table.
|
||||
*
|
||||
* It periodically re-fetches prebuilt rules upgrade review data to detect possible cases of:
|
||||
* - editing prebuilt rules (revision change)
|
||||
* - releasing a new prebuilt rules package (version change)
|
||||
*/
|
||||
export const UpgradePrebuiltRulesTableContextProvider = ({
|
||||
children,
|
||||
}: UpgradePrebuiltRulesTableContextProviderProps) => {
|
||||
|
@ -135,7 +144,7 @@ export const UpgradePrebuiltRulesTableContextProvider = ({
|
|||
isLoading,
|
||||
isRefetching,
|
||||
} = usePrebuiltRulesUpgradeReview({
|
||||
refetchInterval: false, // Disable automatic refetching since request is expensive
|
||||
refetchInterval: REVIEW_PREBUILT_RULES_UPGRADE_REFRESH_INTERVAL,
|
||||
keepPreviousData: true, // Use this option so that the state doesn't jump between "success" and "loading" on page change
|
||||
});
|
||||
const { rulesUpgradeState, setRuleFieldResolvedValue } =
|
||||
|
|
|
@ -0,0 +1,436 @@
|
|||
/*
|
||||
* 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 { FieldUpgradeStateEnum } from '../../../../rule_management/model/prebuilt_rule_upgrade';
|
||||
import type { RuleResponse } from '../../../../../../common/api/detection_engine';
|
||||
import { useAppToasts } from '../../../../../common/hooks/use_app_toasts';
|
||||
import {
|
||||
type RuleUpgradeInfoForReview,
|
||||
ThreeWayDiffConflict,
|
||||
ThreeWayDiffOutcome,
|
||||
ThreeWayMergeOutcome,
|
||||
} from '../../../../../../common/api/detection_engine';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { usePrebuiltRulesUpgradeState } from './use_prebuilt_rules_upgrade_state';
|
||||
|
||||
jest.mock('../../../../rule_management/hooks/use_is_prebuilt_rules_customization_enabled', () => ({
|
||||
useIsPrebuiltRulesCustomizationEnabled: jest.fn(() => true),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../../common/hooks/use_app_toasts', () => ({
|
||||
useAppToasts: jest.fn().mockReturnValue({
|
||||
addWarning: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('usePrebuiltRulesUpgradeState', () => {
|
||||
it('returns rule upgrade state', () => {
|
||||
const ruleUpgradeInfosMock: RuleUpgradeInfoForReview[] = [createRuleUpgradeInfoMock()];
|
||||
|
||||
const {
|
||||
result: {
|
||||
current: { rulesUpgradeState },
|
||||
},
|
||||
} = renderHook(usePrebuiltRulesUpgradeState, {
|
||||
initialProps: ruleUpgradeInfosMock,
|
||||
});
|
||||
|
||||
expect(rulesUpgradeState).toEqual({
|
||||
'test-rule-id-1': expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
describe('fields upgrade state', () => {
|
||||
it('returns empty state when there are no fields to upgrade', () => {
|
||||
const ruleUpgradeInfosMock: RuleUpgradeInfoForReview[] = [createRuleUpgradeInfoMock()];
|
||||
|
||||
const { result } = renderHook(usePrebuiltRulesUpgradeState, {
|
||||
initialProps: ruleUpgradeInfosMock,
|
||||
});
|
||||
|
||||
expect(result.current.rulesUpgradeState).toEqual({
|
||||
'test-rule-id-1': expect.objectContaining({
|
||||
fieldsUpgradeState: {},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns NO CONFLICT fields', () => {
|
||||
const ruleUpgradeInfosMock: RuleUpgradeInfoForReview[] = [
|
||||
createRuleUpgradeInfoMock({
|
||||
diff: {
|
||||
num_fields_with_updates: 1,
|
||||
num_fields_with_conflicts: 0,
|
||||
num_fields_with_non_solvable_conflicts: 0,
|
||||
fields: {
|
||||
name: {
|
||||
base_version: 'base',
|
||||
current_version: 'base',
|
||||
target_version: 'target',
|
||||
merged_version: 'target',
|
||||
diff_outcome: ThreeWayDiffOutcome.StockValueCanUpdate,
|
||||
merge_outcome: ThreeWayMergeOutcome.Target,
|
||||
has_base_version: true,
|
||||
has_update: true,
|
||||
conflict: ThreeWayDiffConflict.NONE,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const { result } = renderHook(usePrebuiltRulesUpgradeState, {
|
||||
initialProps: ruleUpgradeInfosMock,
|
||||
});
|
||||
|
||||
expect(result.current.rulesUpgradeState).toEqual({
|
||||
'test-rule-id-1': expect.objectContaining({
|
||||
fieldsUpgradeState: {
|
||||
name: { state: FieldUpgradeStateEnum.NoConflict },
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns SOLVABLE CONFLICT fields', () => {
|
||||
const ruleUpgradeInfosMock: RuleUpgradeInfoForReview[] = [
|
||||
createRuleUpgradeInfoMock({
|
||||
diff: {
|
||||
num_fields_with_updates: 1,
|
||||
num_fields_with_conflicts: 1,
|
||||
num_fields_with_non_solvable_conflicts: 0,
|
||||
fields: {
|
||||
name: {
|
||||
base_version: 'base',
|
||||
current_version: 'current',
|
||||
target_version: 'target',
|
||||
merged_version: 'target',
|
||||
diff_outcome: ThreeWayDiffOutcome.StockValueCanUpdate,
|
||||
merge_outcome: ThreeWayMergeOutcome.Merged,
|
||||
has_base_version: true,
|
||||
has_update: true,
|
||||
conflict: ThreeWayDiffConflict.SOLVABLE,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const { result } = renderHook(usePrebuiltRulesUpgradeState, {
|
||||
initialProps: ruleUpgradeInfosMock,
|
||||
});
|
||||
|
||||
expect(result.current.rulesUpgradeState).toEqual({
|
||||
'test-rule-id-1': expect.objectContaining({
|
||||
fieldsUpgradeState: {
|
||||
name: { state: FieldUpgradeStateEnum.SolvableConflict },
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns NON SOLVABLE CONFLICT fields', () => {
|
||||
const ruleUpgradeInfosMock: RuleUpgradeInfoForReview[] = [
|
||||
createRuleUpgradeInfoMock({
|
||||
diff: {
|
||||
num_fields_with_updates: 1,
|
||||
num_fields_with_conflicts: 1,
|
||||
num_fields_with_non_solvable_conflicts: 1,
|
||||
fields: {
|
||||
name: {
|
||||
base_version: 'base',
|
||||
current_version: 'current',
|
||||
target_version: 'target',
|
||||
merged_version: 'target',
|
||||
diff_outcome: ThreeWayDiffOutcome.StockValueCanUpdate,
|
||||
merge_outcome: ThreeWayMergeOutcome.Merged,
|
||||
has_base_version: true,
|
||||
has_update: true,
|
||||
conflict: ThreeWayDiffConflict.NON_SOLVABLE,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const { result } = renderHook(usePrebuiltRulesUpgradeState, {
|
||||
initialProps: ruleUpgradeInfosMock,
|
||||
});
|
||||
|
||||
expect(result.current.rulesUpgradeState).toEqual({
|
||||
'test-rule-id-1': expect.objectContaining({
|
||||
fieldsUpgradeState: {
|
||||
name: { state: FieldUpgradeStateEnum.NonSolvableConflict },
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns ACCEPTED fields after resolving a conflict', () => {
|
||||
const ruleUpgradeInfosMock: RuleUpgradeInfoForReview[] = [
|
||||
createRuleUpgradeInfoMock({
|
||||
rule_id: 'test-rule-id-1',
|
||||
diff: {
|
||||
num_fields_with_updates: 1,
|
||||
num_fields_with_conflicts: 1,
|
||||
num_fields_with_non_solvable_conflicts: 1,
|
||||
fields: {
|
||||
name: {
|
||||
base_version: 'base',
|
||||
current_version: 'current',
|
||||
target_version: 'target',
|
||||
merged_version: 'target',
|
||||
diff_outcome: ThreeWayDiffOutcome.StockValueCanUpdate,
|
||||
merge_outcome: ThreeWayMergeOutcome.Merged,
|
||||
has_base_version: true,
|
||||
has_update: true,
|
||||
conflict: ThreeWayDiffConflict.NON_SOLVABLE,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const { result } = renderHook(usePrebuiltRulesUpgradeState, {
|
||||
initialProps: ruleUpgradeInfosMock,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setRuleFieldResolvedValue({
|
||||
ruleId: 'test-rule-id-1',
|
||||
fieldName: 'name',
|
||||
resolvedValue: 'resolved',
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.rulesUpgradeState).toEqual({
|
||||
'test-rule-id-1': expect.objectContaining({
|
||||
fieldsUpgradeState: {
|
||||
name: { state: FieldUpgradeStateEnum.Accepted, resolvedValue: 'resolved' },
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Test handling revision and version changes
|
||||
// - user edited a rule (revision change)
|
||||
// - a new prebuilt rules package got released (version change)
|
||||
describe('concurrency control', () => {
|
||||
describe('revision change', () => {
|
||||
const createMock = ({ revision }: { revision: number }) => [
|
||||
createRuleUpgradeInfoMock({
|
||||
rule_id: 'test-rule-id-1',
|
||||
revision,
|
||||
current_rule: createRuleResponseMock({
|
||||
name: 'current',
|
||||
revision,
|
||||
}),
|
||||
diff: {
|
||||
num_fields_with_updates: 1,
|
||||
num_fields_with_conflicts: 1,
|
||||
num_fields_with_non_solvable_conflicts: 1,
|
||||
fields: {
|
||||
name: {
|
||||
base_version: 'base',
|
||||
current_version: 'current',
|
||||
target_version: 'target',
|
||||
merged_version: 'target',
|
||||
diff_outcome: ThreeWayDiffOutcome.StockValueCanUpdate,
|
||||
merge_outcome: ThreeWayMergeOutcome.Merged,
|
||||
has_base_version: true,
|
||||
has_update: true,
|
||||
conflict: ThreeWayDiffConflict.NON_SOLVABLE,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
it('invalidates resolved conflicts', () => {
|
||||
const { result, rerender } = renderHook(usePrebuiltRulesUpgradeState, {
|
||||
initialProps: createMock({ revision: 1 }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setRuleFieldResolvedValue({
|
||||
ruleId: 'test-rule-id-1',
|
||||
fieldName: 'name',
|
||||
resolvedValue: 'resolved',
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.rulesUpgradeState).toEqual({
|
||||
'test-rule-id-1': expect.objectContaining({
|
||||
fieldsUpgradeState: {
|
||||
name: { state: FieldUpgradeStateEnum.Accepted, resolvedValue: 'resolved' },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
rerender(createMock({ revision: 2 }));
|
||||
|
||||
expect(result.current.rulesUpgradeState).toEqual({
|
||||
'test-rule-id-1': expect.objectContaining({
|
||||
fieldsUpgradeState: {
|
||||
name: { state: FieldUpgradeStateEnum.NonSolvableConflict },
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a notification', () => {
|
||||
const addWarningMock = jest.fn();
|
||||
(useAppToasts as jest.Mock).mockImplementation(() => ({
|
||||
addWarning: addWarningMock,
|
||||
}));
|
||||
|
||||
const { result, rerender } = renderHook(usePrebuiltRulesUpgradeState, {
|
||||
initialProps: createMock({ revision: 1 }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setRuleFieldResolvedValue({
|
||||
ruleId: 'test-rule-id-1',
|
||||
fieldName: 'name',
|
||||
resolvedValue: 'resolved',
|
||||
});
|
||||
});
|
||||
|
||||
rerender(createMock({ revision: 2 }));
|
||||
|
||||
expect(addWarningMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('version change', () => {
|
||||
const createMock = ({ version }: { version: number }) => [
|
||||
createRuleUpgradeInfoMock({
|
||||
rule_id: 'test-rule-id-1',
|
||||
revision: 1,
|
||||
target_rule: createRuleResponseMock({
|
||||
name: 'target',
|
||||
version,
|
||||
}),
|
||||
diff: {
|
||||
num_fields_with_updates: 1,
|
||||
num_fields_with_conflicts: 1,
|
||||
num_fields_with_non_solvable_conflicts: 1,
|
||||
fields: {
|
||||
name: {
|
||||
base_version: 'base',
|
||||
current_version: 'current',
|
||||
target_version: 'target',
|
||||
merged_version: 'target',
|
||||
diff_outcome: ThreeWayDiffOutcome.StockValueCanUpdate,
|
||||
merge_outcome: ThreeWayMergeOutcome.Merged,
|
||||
has_base_version: true,
|
||||
has_update: true,
|
||||
conflict: ThreeWayDiffConflict.NON_SOLVABLE,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
it('invalidates resolved conflicts upon version change', () => {
|
||||
const addWarningMock = jest.fn();
|
||||
(useAppToasts as jest.Mock).mockImplementation(() => ({
|
||||
addWarning: addWarningMock,
|
||||
}));
|
||||
|
||||
const { result, rerender } = renderHook(usePrebuiltRulesUpgradeState, {
|
||||
initialProps: createMock({ version: 1 }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setRuleFieldResolvedValue({
|
||||
ruleId: 'test-rule-id-1',
|
||||
fieldName: 'name',
|
||||
resolvedValue: 'resolved',
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.rulesUpgradeState).toEqual({
|
||||
'test-rule-id-1': expect.objectContaining({
|
||||
fieldsUpgradeState: {
|
||||
name: { state: FieldUpgradeStateEnum.Accepted, resolvedValue: 'resolved' },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
rerender(createMock({ version: 2 }));
|
||||
|
||||
expect(result.current.rulesUpgradeState).toEqual({
|
||||
'test-rule-id-1': expect.objectContaining({
|
||||
fieldsUpgradeState: {
|
||||
name: { state: FieldUpgradeStateEnum.NonSolvableConflict },
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a notification', () => {
|
||||
const addWarningMock = jest.fn();
|
||||
(useAppToasts as jest.Mock).mockImplementation(() => ({
|
||||
addWarning: addWarningMock,
|
||||
}));
|
||||
|
||||
const { result, rerender } = renderHook(usePrebuiltRulesUpgradeState, {
|
||||
initialProps: createMock({ version: 1 }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setRuleFieldResolvedValue({
|
||||
ruleId: 'test-rule-id-1',
|
||||
fieldName: 'name',
|
||||
resolvedValue: 'resolved',
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.rulesUpgradeState).toEqual({
|
||||
'test-rule-id-1': expect.objectContaining({
|
||||
fieldsUpgradeState: {
|
||||
name: { state: FieldUpgradeStateEnum.Accepted, resolvedValue: 'resolved' },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
rerender(createMock({ version: 2 }));
|
||||
|
||||
expect(addWarningMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createRuleUpgradeInfoMock(
|
||||
rewrites?: Partial<RuleUpgradeInfoForReview>
|
||||
): RuleUpgradeInfoForReview {
|
||||
return {
|
||||
id: 'test-id-1',
|
||||
rule_id: 'test-rule-id-1',
|
||||
current_rule: createRuleResponseMock(),
|
||||
target_rule: createRuleResponseMock(),
|
||||
diff: {
|
||||
num_fields_with_updates: 0,
|
||||
num_fields_with_conflicts: 0,
|
||||
num_fields_with_non_solvable_conflicts: 0,
|
||||
fields: {},
|
||||
},
|
||||
revision: 1,
|
||||
...rewrites,
|
||||
};
|
||||
}
|
||||
|
||||
function createRuleResponseMock(rewrites?: Partial<RuleResponse>): RuleResponse {
|
||||
return {
|
||||
version: 1,
|
||||
revision: 1,
|
||||
...rewrites,
|
||||
} as RuleResponse;
|
||||
}
|
|
@ -5,7 +5,8 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState, useRef, useEffect } from 'react';
|
||||
import { useAppToasts } from '../../../../../common/hooks/use_app_toasts';
|
||||
import { useIsPrebuiltRulesCustomizationEnabled } from '../../../../rule_management/hooks/use_is_prebuilt_rules_customization_enabled';
|
||||
import type {
|
||||
RulesUpgradeState,
|
||||
|
@ -23,10 +24,18 @@ import {
|
|||
ThreeWayDiffOutcome,
|
||||
} from '../../../../../../common/api/detection_engine';
|
||||
import { assertUnreachable } from '../../../../../../common/utility_types';
|
||||
import * as i18n from './translations';
|
||||
|
||||
type RuleResolvedConflicts = Partial<DiffableAllFields>;
|
||||
type RulesResolvedConflicts = Record<RuleSignatureId, RuleResolvedConflicts>;
|
||||
|
||||
interface RuleConcurrencyControl {
|
||||
version: number;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
type RulesConcurrencyControl = Record<RuleSignatureId, RuleConcurrencyControl>;
|
||||
|
||||
interface UseRulesUpgradeStateResult {
|
||||
rulesUpgradeState: RulesUpgradeState;
|
||||
setRuleFieldResolvedValue: SetRuleFieldResolvedValueFn;
|
||||
|
@ -36,11 +45,22 @@ export function usePrebuiltRulesUpgradeState(
|
|||
ruleUpgradeInfos: RuleUpgradeInfoForReview[]
|
||||
): UseRulesUpgradeStateResult {
|
||||
const isPrebuiltRulesCustomizationEnabled = useIsPrebuiltRulesCustomizationEnabled();
|
||||
const [rulesResolvedConflicts, setRulesResolvedConflicts] = useState<RulesResolvedConflicts>({});
|
||||
const [rulesResolvedValues, setRulesResolvedValues] = useState<RulesResolvedConflicts>({});
|
||||
const resetRuleResolvedValues = useCallback(
|
||||
(ruleId: RuleSignatureId) => {
|
||||
setRulesResolvedValues((prevRulesResolvedConflicts) => ({
|
||||
...prevRulesResolvedConflicts,
|
||||
[ruleId]: {},
|
||||
}));
|
||||
},
|
||||
[setRulesResolvedValues]
|
||||
);
|
||||
const concurrencyControl = useRef<RulesConcurrencyControl>({});
|
||||
const { addWarning } = useAppToasts();
|
||||
|
||||
const setRuleFieldResolvedValue = useCallback(
|
||||
(...[params]: Parameters<SetRuleFieldResolvedValueFn>) => {
|
||||
setRulesResolvedConflicts((prevRulesResolvedConflicts) => ({
|
||||
setRulesResolvedValues((prevRulesResolvedConflicts) => ({
|
||||
...prevRulesResolvedConflicts,
|
||||
[params.ruleId]: {
|
||||
...(prevRulesResolvedConflicts[params.ruleId] ?? {}),
|
||||
|
@ -51,13 +71,61 @@ export function usePrebuiltRulesUpgradeState(
|
|||
[]
|
||||
);
|
||||
|
||||
// Implements concurrency control.
|
||||
// Rule may be edited or a new prebuilt rules package version gets released.
|
||||
// In any case current rule's `revision` or target rule's version
|
||||
// will have higher values.
|
||||
// Reset resolved conflicts in case of revision`s or version`s mismatch.
|
||||
useEffect(() => {
|
||||
for (const {
|
||||
rule_id: ruleId,
|
||||
current_rule: { revision: nextRevision, name },
|
||||
target_rule: { version: nextVersion },
|
||||
} of ruleUpgradeInfos) {
|
||||
const cc = concurrencyControl.current[ruleId];
|
||||
const hasNewerRevision = cc ? nextRevision > cc.revision : false;
|
||||
const hasNewerVersion = cc ? nextVersion > cc.version : false;
|
||||
const hasResolvedValues = Object.keys(rulesResolvedValues[ruleId] ?? {}).length > 0;
|
||||
|
||||
if (hasNewerRevision && hasResolvedValues) {
|
||||
addWarning({
|
||||
title: i18n.RULE_NEW_REVISION_DETECTED_WARNING,
|
||||
text: i18n.RULE_NEW_REVISION_DETECTED_WARNING_DESCRIPTION(name),
|
||||
});
|
||||
}
|
||||
|
||||
if (hasNewerVersion && hasResolvedValues) {
|
||||
addWarning({
|
||||
title: i18n.RULE_NEW_VERSION_DETECTED_WARNING,
|
||||
text: i18n.RULE_NEW_VERSION_DETECTED_WARNING_DESCRIPTION(name),
|
||||
});
|
||||
}
|
||||
|
||||
if ((hasNewerRevision || hasNewerVersion) && hasResolvedValues) {
|
||||
resetRuleResolvedValues(ruleId);
|
||||
}
|
||||
|
||||
concurrencyControl.current[ruleId] = {
|
||||
version: nextVersion,
|
||||
revision: nextRevision,
|
||||
};
|
||||
}
|
||||
}, [
|
||||
ruleUpgradeInfos,
|
||||
concurrencyControl,
|
||||
rulesResolvedValues,
|
||||
setRulesResolvedValues,
|
||||
resetRuleResolvedValues,
|
||||
addWarning,
|
||||
]);
|
||||
|
||||
const rulesUpgradeState = useMemo(() => {
|
||||
const state: RulesUpgradeState = {};
|
||||
|
||||
for (const ruleUpgradeInfo of ruleUpgradeInfos) {
|
||||
const fieldsUpgradeState = calcFieldsState(
|
||||
ruleUpgradeInfo.diff.fields,
|
||||
rulesResolvedConflicts[ruleUpgradeInfo.rule_id] ?? {}
|
||||
rulesResolvedValues[ruleUpgradeInfo.rule_id] ?? {}
|
||||
);
|
||||
|
||||
const hasRuleTypeChange = Boolean(ruleUpgradeInfo.diff.fields.type);
|
||||
|
@ -77,7 +145,7 @@ export function usePrebuiltRulesUpgradeState(
|
|||
}
|
||||
|
||||
return state;
|
||||
}, [ruleUpgradeInfos, rulesResolvedConflicts, isPrebuiltRulesCustomizationEnabled]);
|
||||
}, [ruleUpgradeInfos, rulesResolvedValues, isPrebuiltRulesCustomizationEnabled]);
|
||||
|
||||
return {
|
||||
rulesUpgradeState,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue