[8.18] [Security Solution] Add UI incentivizers to upgrade prebuilt rules (#211862) (#213232)

# Backport

This will backport the following commits from `main` to `8.18`:
- [[Security Solution] Add UI incentivizers to upgrade prebuilt rules
(#211862)](https://github.com/elastic/kibana/pull/211862)

<!--- Backport version: 9.6.6 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT [{"author":{"name":"Davis
Plumlee","email":"56367316+dplumlee@users.noreply.github.com"},"sourceCommit":{"committedDate":"2025-03-05T12:14:31Z","message":"[Security
Solution] Add UI incentivizers to upgrade prebuilt rules (#211862)\n\n##
Summary\n\nPartially addresses
https://github.com/elastic/kibana/issues/210358\n\nAdds all callouts and
logic to incentivize users to upgrade their rules asap. These
include:\n\n- [x] Showing a callout on the Rule Management page\n- [x]
Showing a callout on the Rule Details page\n - [x] Letting users open
the Rule Upgrade flyout from the Rule Details page\n- [x] Showing a
callout on the Rule Editing page\n- [x] Showing a callout in the Rule
Upgrade flyout if rule has missing base version\n\nThis PR also adds
related updates to the rule diff algorithms in order to facilitate an
easier upgrade experience when rules have missing base versions. These
include:\n\n- [x] When the rule has a missing base version and is NOT
marked as customized:\n - [x] We should return all the target fields
from the diff algorithm as NO_CONFLICT\n- [x] When the rule has a
missing base version and is marked as customized:\n - [x] We should
attempt to merge all non-functional mergeable fields (any field that
doesn't have consequences with how the rule runs e.g. tags) and return
them as `SOLVABLE_CONFLICT`.\n - **NOTE**: When base versions are
missing and the rule is customized, we attempt to merge all mergable,
non-functional rule fields. These include all fields covered by the
scalar diff array (`tags`, `references`, `new_terms_fields`,
`threat_index`). We typically also consider multi-line string fields as
mergeable but without three versions of the string, we are currently
unable to merge the strings together, so we just return target
version.\n - [x] We should pick the target version for all functional
mergeable fields (e.g. `index`) and non-mergeable fields and return them
as `SOLVABLE_CONFLICT`.\n\n\n### Screenshots\n\n\n**Callout on Rule
details page w/ flyout button**\n![Screenshot 2025-03-03 at 3 58
17 PM](https://github.com/user-attachments/assets/77117cad-fd8c-4b37-8ef7-f66d77f373b8)\n\n---\n\n**Upgrade
flyout now accessible from rule details page**\n![Screenshot 2025-03-03
at 3 58
25 PM](https://github.com/user-attachments/assets/f78e10fe-0767-44ab-a9c9-a5ae616b8b0e)\n\n---\n\n**Callout
on rule editing page**\n![Screenshot 2025-03-03 at 3 58
38 PM](https://github.com/user-attachments/assets/be68420f-a612-4e3d-9139-ad65a3d8b9fc)\n\n---\n\n**Dismissible
callout on rule management page**\n![Screenshot 2025-03-03 at 3 57
52 PM](https://github.com/user-attachments/assets/5227a4d1-474a-44d2-b0bb-fc020e584e8e)\n\n---\n\n**Callout
in rule upgrade flyout when rule has missing base
version**\n![Screenshot 2025-03-03 at 3 58
04 PM](https://github.com/user-attachments/assets/3c1a23fa-f1f0-4301-b392-4c91097a9cb9)\n\n###
Checklist\n\nCheck the PR satisfies following conditions. \n\nReviewers
should verify this PR satisfies this list as well.\n\n- [x] Any text
added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)\n-
[x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common
scenarios","sha":"461787bea6a48cc2c55514843adedc9ca5bb5032","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["bug","release_note:skip","impact:high","v9.0.0","Team:Detections
and Resp","Team: SecuritySolution","Team:Detection Rule
Management","Feature:Prebuilt Detection
Rules","backport:version","v8.18.0","v9.1.0","v8.19.0"],"title":"[Security
Solution] Add UI incentivizers to upgrade prebuilt
rules","number":211862,"url":"https://github.com/elastic/kibana/pull/211862","mergeCommit":{"message":"[Security
Solution] Add UI incentivizers to upgrade prebuilt rules (#211862)\n\n##
Summary\n\nPartially addresses
https://github.com/elastic/kibana/issues/210358\n\nAdds all callouts and
logic to incentivize users to upgrade their rules asap. These
include:\n\n- [x] Showing a callout on the Rule Management page\n- [x]
Showing a callout on the Rule Details page\n - [x] Letting users open
the Rule Upgrade flyout from the Rule Details page\n- [x] Showing a
callout on the Rule Editing page\n- [x] Showing a callout in the Rule
Upgrade flyout if rule has missing base version\n\nThis PR also adds
related updates to the rule diff algorithms in order to facilitate an
easier upgrade experience when rules have missing base versions. These
include:\n\n- [x] When the rule has a missing base version and is NOT
marked as customized:\n - [x] We should return all the target fields
from the diff algorithm as NO_CONFLICT\n- [x] When the rule has a
missing base version and is marked as customized:\n - [x] We should
attempt to merge all non-functional mergeable fields (any field that
doesn't have consequences with how the rule runs e.g. tags) and return
them as `SOLVABLE_CONFLICT`.\n - **NOTE**: When base versions are
missing and the rule is customized, we attempt to merge all mergable,
non-functional rule fields. These include all fields covered by the
scalar diff array (`tags`, `references`, `new_terms_fields`,
`threat_index`). We typically also consider multi-line string fields as
mergeable but without three versions of the string, we are currently
unable to merge the strings together, so we just return target
version.\n - [x] We should pick the target version for all functional
mergeable fields (e.g. `index`) and non-mergeable fields and return them
as `SOLVABLE_CONFLICT`.\n\n\n### Screenshots\n\n\n**Callout on Rule
details page w/ flyout button**\n![Screenshot 2025-03-03 at 3 58
17 PM](https://github.com/user-attachments/assets/77117cad-fd8c-4b37-8ef7-f66d77f373b8)\n\n---\n\n**Upgrade
flyout now accessible from rule details page**\n![Screenshot 2025-03-03
at 3 58
25 PM](https://github.com/user-attachments/assets/f78e10fe-0767-44ab-a9c9-a5ae616b8b0e)\n\n---\n\n**Callout
on rule editing page**\n![Screenshot 2025-03-03 at 3 58
38 PM](https://github.com/user-attachments/assets/be68420f-a612-4e3d-9139-ad65a3d8b9fc)\n\n---\n\n**Dismissible
callout on rule management page**\n![Screenshot 2025-03-03 at 3 57
52 PM](https://github.com/user-attachments/assets/5227a4d1-474a-44d2-b0bb-fc020e584e8e)\n\n---\n\n**Callout
in rule upgrade flyout when rule has missing base
version**\n![Screenshot 2025-03-03 at 3 58
04 PM](https://github.com/user-attachments/assets/3c1a23fa-f1f0-4301-b392-4c91097a9cb9)\n\n###
Checklist\n\nCheck the PR satisfies following conditions. \n\nReviewers
should verify this PR satisfies this list as well.\n\n- [x] Any text
added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)\n-
[x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common
scenarios","sha":"461787bea6a48cc2c55514843adedc9ca5bb5032"}},"sourceBranch":"main","suggestedTargetBranches":["9.0","8.18","8.x"],"targetPullRequestStates":[{"branch":"9.0","label":"v9.0.0","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"8.18","label":"v8.18.0","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/211862","number":211862,"mergeCommit":{"message":"[Security
Solution] Add UI incentivizers to upgrade prebuilt rules (#211862)\n\n##
Summary\n\nPartially addresses
https://github.com/elastic/kibana/issues/210358\n\nAdds all callouts and
logic to incentivize users to upgrade their rules asap. These
include:\n\n- [x] Showing a callout on the Rule Management page\n- [x]
Showing a callout on the Rule Details page\n - [x] Letting users open
the Rule Upgrade flyout from the Rule Details page\n- [x] Showing a
callout on the Rule Editing page\n- [x] Showing a callout in the Rule
Upgrade flyout if rule has missing base version\n\nThis PR also adds
related updates to the rule diff algorithms in order to facilitate an
easier upgrade experience when rules have missing base versions. These
include:\n\n- [x] When the rule has a missing base version and is NOT
marked as customized:\n - [x] We should return all the target fields
from the diff algorithm as NO_CONFLICT\n- [x] When the rule has a
missing base version and is marked as customized:\n - [x] We should
attempt to merge all non-functional mergeable fields (any field that
doesn't have consequences with how the rule runs e.g. tags) and return
them as `SOLVABLE_CONFLICT`.\n - **NOTE**: When base versions are
missing and the rule is customized, we attempt to merge all mergable,
non-functional rule fields. These include all fields covered by the
scalar diff array (`tags`, `references`, `new_terms_fields`,
`threat_index`). We typically also consider multi-line string fields as
mergeable but without three versions of the string, we are currently
unable to merge the strings together, so we just return target
version.\n - [x] We should pick the target version for all functional
mergeable fields (e.g. `index`) and non-mergeable fields and return them
as `SOLVABLE_CONFLICT`.\n\n\n### Screenshots\n\n\n**Callout on Rule
details page w/ flyout button**\n![Screenshot 2025-03-03 at 3 58
17 PM](https://github.com/user-attachments/assets/77117cad-fd8c-4b37-8ef7-f66d77f373b8)\n\n---\n\n**Upgrade
flyout now accessible from rule details page**\n![Screenshot 2025-03-03
at 3 58
25 PM](https://github.com/user-attachments/assets/f78e10fe-0767-44ab-a9c9-a5ae616b8b0e)\n\n---\n\n**Callout
on rule editing page**\n![Screenshot 2025-03-03 at 3 58
38 PM](https://github.com/user-attachments/assets/be68420f-a612-4e3d-9139-ad65a3d8b9fc)\n\n---\n\n**Dismissible
callout on rule management page**\n![Screenshot 2025-03-03 at 3 57
52 PM](https://github.com/user-attachments/assets/5227a4d1-474a-44d2-b0bb-fc020e584e8e)\n\n---\n\n**Callout
in rule upgrade flyout when rule has missing base
version**\n![Screenshot 2025-03-03 at 3 58
04 PM](https://github.com/user-attachments/assets/3c1a23fa-f1f0-4301-b392-4c91097a9cb9)\n\n###
Checklist\n\nCheck the PR satisfies following conditions. \n\nReviewers
should verify this PR satisfies this list as well.\n\n- [x] Any text
added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)\n-
[x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common
scenarios","sha":"461787bea6a48cc2c55514843adedc9ca5bb5032"}},{"branch":"8.x","label":"v8.19.0","branchLabelMappingKey":"^v8.19.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

Co-authored-by: Davis Plumlee <56367316+dplumlee@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2025-03-06 01:23:31 +11:00 committed by GitHub
parent a2d89b0a90
commit 894b47b6f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 2029 additions and 1040 deletions

View file

@ -145,5 +145,6 @@ export interface ThreeWayDiff<TValue> {
* Given the three versions of a value, calculates a three-way diff for it.
*/
export type ThreeWayDiffAlgorithm<TValue> = (
versions: ThreeVersionsOf<TValue>
versions: ThreeVersionsOf<TValue>,
isRuleCustomized: boolean
) => ThreeWayDiff<TValue>;

View file

@ -10,7 +10,7 @@ import { SortOrder, type RuleObjectId, type RuleSignatureId, type RuleTagArray }
import type { PartialRuleDiff } from '../model';
import type { RuleResponse, RuleVersion } from '../../model/rule_schema';
import { FindRulesSortField } from '../../rule_management';
import { PrebuiltRulesFilter } from '../common/prebuilt_rules_filter';
import { ReviewPrebuiltRuleUpgradeFilter } from '../common/review_prebuilt_rules_upgrade_filter';
export type ReviewRuleUpgradeSort = z.infer<typeof ReviewRuleUpgradeSort>;
export const ReviewRuleUpgradeSort = z.object({
@ -27,7 +27,7 @@ export const ReviewRuleUpgradeSort = z.object({
export type ReviewRuleUpgradeRequestBody = z.infer<typeof ReviewRuleUpgradeRequestBody>;
export const ReviewRuleUpgradeRequestBody = z
.object({
filter: PrebuiltRulesFilter.optional(),
filter: ReviewPrebuiltRuleUpgradeFilter.optional(),
sort: ReviewRuleUpgradeSort.optional(),
page: z.coerce.number().int().min(1).optional().default(1),
@ -89,4 +89,5 @@ export interface RuleUpgradeInfoForReview {
target_rule: RuleResponse;
diff: PartialRuleDiff;
revision: number;
has_base_version: boolean;
}

View file

@ -6,7 +6,7 @@
*/
import { ecsFieldMap } from '@kbn/alerts-as-data-utils';
import type { RequiredField, RequiredFieldInput } from '../../api/detection_engine';
import type { RequiredField, RequiredFieldInput, RuleResponse } from '../../api/detection_engine';
/*
Computes the boolean "ecs" property value for each required field based on the ECS field map.
@ -23,3 +23,6 @@ export const addEcsToRequiredFields = (requiredFields?: RequiredFieldInput[]): R
ecs: isEcsField,
};
});
export const isRuleCustomized = (rule: RuleResponse) =>
rule.rule_source.type === 'external' && rule.rule_source.is_customized === true;

View file

@ -11,6 +11,7 @@ import {
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiLink,
EuiResizableContainer,
EuiSpacer,
EuiTab,
@ -75,6 +76,7 @@ import { usePrebuiltRulesCustomizationStatus } from '../../../rule_management/lo
import { PrebuiltRulesCustomizationDisabledReason } from '../../../../../common/detection_engine/prebuilt_rules/prebuilt_rule_customization_status';
import { ALERT_SUPPRESSION_FIELDS_FIELD_NAME } from '../../../rule_creation/components/alert_suppression_edit';
import { usePrebuiltRuleCustomizationUpsellingMessage } from '../../../rule_management/logic/prebuilt_rules/use_prebuilt_rule_customization_upselling_message';
import { useRuleUpdateCallout } from '../../../rule_management/hooks/use_rule_update_callout';
const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => {
const { addSuccess } = useAppToasts();
@ -509,6 +511,16 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => {
[navigateToApp, ruleId]
);
const upgradeCallout = useRuleUpdateCallout({
rule,
message: ruleI18n.HAS_RULE_UPDATE_EDITING_CALLOUT_MESSAGE,
actionButton: (
<EuiLink onClick={goToDetailsRule} data-test-subj="ruleEditingUpdateRuleCalloutButton">
{ruleI18n.HAS_RULE_UPDATE_EDITING_CALLOUT_BUTTON}
</EuiLink>
),
});
if (
redirectToDetections(
isSignalIndexExists,
@ -550,6 +562,7 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => {
setIsRulePreviewVisible={setIsRulePreviewVisible}
togglePanel={togglePanel}
/>
{upgradeCallout}
{invalidSteps.length > 0 && (
<EuiCallOut title={i18n.SORRY_ERRORS} color="danger" iconType="warning">
<FormattedMessage

View file

@ -148,6 +148,7 @@ import { useManualRuleRunConfirmation } from '../../../rule_gaps/components/manu
import { useLegacyUrlRedirect } from './use_redirect_legacy_url';
import { RuleDetailTabs, useRuleDetailsTabs } from './use_rule_details_tabs';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { useRuleUpdateCallout } from '../../../rule_management/hooks/use_rule_update_callout';
const RULE_EXCEPTION_LIST_TYPES = [
ExceptionListTypeEnum.DETECTION,
@ -254,7 +255,7 @@ const RuleDetailsPageComponent: React.FC<DetectionEngineComponentProps> = ({
const { pollForSignalIndex } = useSignalHelpers();
const [rule, setRule] = useState<RuleResponse | null>(null);
const isLoading = ruleLoading && rule == null;
const isLoading = useMemo(() => ruleLoading && rule == null, [rule, ruleLoading]);
const { starting: isStartingJobs, startMlJobs } = useStartMlJobs();
const startMlJobsIfNeeded = useCallback(async () => {
@ -394,6 +395,12 @@ const RuleDetailsPageComponent: React.FC<DetectionEngineComponentProps> = ({
const lastExecutionDate = lastExecution?.date ?? '';
const lastExecutionMessage = lastExecution?.message ?? '';
const upgradeCallout = useRuleUpdateCallout({
rule,
message: ruleI18n.HAS_RULE_UPDATE_DETAILS_CALLOUT_MESSAGE,
onUpgrade: refreshRule,
});
const ruleStatusInfo = useMemo(() => {
return (
<>
@ -556,6 +563,7 @@ const RuleDetailsPageComponent: React.FC<DetectionEngineComponentProps> = ({
<>
<NeedAdminForUpdateRulesCallOut />
<MissingPrivilegesCallOut />
{upgradeCallout}
{isBulkDuplicateConfirmationVisible && (
<BulkActionDuplicateExceptionsConfirmation
onCancel={cancelRuleDuplication}

View file

@ -0,0 +1,71 @@
/*
* 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 { EuiCallOut, EuiSpacer, EuiLink } from '@elastic/eui';
import React, { useMemo } from 'react';
import type { RuleResponse } from '../../../../../common/api/detection_engine';
import * as i18n from './translations';
import { usePrebuiltRulesUpgrade } from '../../hooks/use_prebuilt_rules_upgrade';
interface RuleUpdateCalloutProps {
rule: RuleResponse;
message: string;
actionButton?: JSX.Element;
onUpgrade?: () => void;
}
const RuleUpdateCalloutComponent = ({
rule,
message,
actionButton,
onUpgrade,
}: RuleUpdateCalloutProps): JSX.Element | null => {
const { upgradeReviewResponse, rulePreviewFlyout, openRulePreview } = usePrebuiltRulesUpgrade({
pagination: {
page: 1, // we only want to fetch one result
perPage: 1,
},
filter: { rule_ids: [rule.id] },
onUpgrade,
});
const isRuleUpgradeable = useMemo(
() => upgradeReviewResponse !== undefined && upgradeReviewResponse.total > 0,
[upgradeReviewResponse]
);
const updateCallToActionButton = useMemo(() => {
if (actionButton) {
return actionButton;
}
return (
<EuiLink
onClick={() => openRulePreview(rule.rule_id)}
data-test-subj="ruleDetailsUpdateRuleCalloutButton"
>
{i18n.HAS_RULE_UPDATE_CALLOUT_BUTTON}
</EuiLink>
);
}, [actionButton, openRulePreview, rule.rule_id]);
if (!isRuleUpgradeable) {
return null;
}
return (
<>
<EuiCallOut title={i18n.HAS_RULE_UPDATE_CALLOUT_TITLE} color="primary" iconType="gear">
<p>{message}</p>
{updateCallToActionButton}
</EuiCallOut>
<EuiSpacer size="l" />
{rulePreviewFlyout}
</>
);
};
export const RuleUpdateCallout = React.memo(RuleUpdateCalloutComponent);

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiCallOut } from '@elastic/eui';
import * as i18n from './translations';
export const RuleHasMissingBaseVersionCallout = () => (
<EuiCallOut color="warning" size="s">
<p>{i18n.RULE_BASE_VERSION_IS_MISSING_DESCRIPTION}</p>
</EuiCallOut>
);

View file

@ -18,6 +18,7 @@ import { RuleUpgradeInfoBar } from './rule_upgrade_info_bar';
import { RuleUpgradeCallout } from './rule_upgrade_callout';
import { FieldUpgrade } from './field_upgrade';
import { FieldUpgradeContextProvider } from './field_upgrade_context';
import { RuleHasMissingBaseVersionCallout } from './missing_base_version_callout';
interface RuleUpgradeProps {
ruleUpgradeState: RuleUpgradeState;
@ -45,6 +46,12 @@ export const RuleUpgrade = memo(function RuleUpgrade({
targetVersionNumber={ruleUpgradeState.target_rule.version}
/>
<EuiSpacer size="s" />
{!ruleUpgradeState.has_base_version && (
<>
<RuleHasMissingBaseVersionCallout />
<EuiSpacer size="s" />
</>
)}
<RuleUpgradeCallout
numOfSolvableConflicts={numOfSolvableConflicts}
numOfNonSolvableConflicts={numOfNonSolvableConflicts}

View file

@ -23,66 +23,72 @@ export function RuleUpgradeCallout({
}: RuleUpgradeCalloutProps): JSX.Element {
if (numOfNonSolvableConflicts > 0) {
return (
<EuiCallOut
title={
<>
<strong>{i18n.UPGRADE_STATUS}</strong>
&nbsp;
<ActionRequiredBadge />
&nbsp;
{i18n.RULE_HAS_CONFLICTS(numOfNonSolvableConflicts + numOfSolvableConflicts)}
</>
}
color="danger"
size="s"
>
<span>{i18n.RULE_HAS_HARD_CONFLICTS_DESCRIPTION}</span>
<ul>
<li>{i18n.RULE_HAS_HARD_CONFLICTS_KEEP_YOUR_CHANGES}</li>
<li>{i18n.RULE_HAS_HARD_CONFLICTS_ACCEPT_ELASTIC_UPDATE}</li>
<li>{i18n.RULE_HAS_HARD_CONFLICTS_EDIT_FINAL_VERSION}</li>
</ul>
</EuiCallOut>
<>
<EuiCallOut
title={
<>
<strong>{i18n.UPGRADE_STATUS}</strong>
&nbsp;
<ActionRequiredBadge />
&nbsp;
{i18n.RULE_HAS_CONFLICTS(numOfNonSolvableConflicts + numOfSolvableConflicts)}
</>
}
color="danger"
size="s"
>
<span>{i18n.RULE_HAS_HARD_CONFLICTS_DESCRIPTION}</span>
<ul>
<li>{i18n.RULE_HAS_HARD_CONFLICTS_KEEP_YOUR_CHANGES}</li>
<li>{i18n.RULE_HAS_HARD_CONFLICTS_ACCEPT_ELASTIC_UPDATE}</li>
<li>{i18n.RULE_HAS_HARD_CONFLICTS_EDIT_FINAL_VERSION}</li>
</ul>
</EuiCallOut>
</>
);
}
if (numOfSolvableConflicts > 0) {
return (
<>
<EuiCallOut
title={
<>
<strong>{i18n.UPGRADE_STATUS}</strong>
&nbsp;
<ReviewRequiredBadge />
&nbsp;
{i18n.RULE_HAS_CONFLICTS(numOfSolvableConflicts)}
</>
}
color="warning"
size="s"
>
<span>{i18n.RULE_HAS_SOFT_CONFLICTS_DESCRIPTION}</span>
<ul>
<li>{i18n.RULE_HAS_SOFT_CONFLICTS_ACCEPT_SUGGESTED_UPDATE}</li>
<li>{i18n.RULE_HAS_SOFT_CONFLICTS_EDIT_FINAL_VERSION}</li>
</ul>
</EuiCallOut>
</>
);
}
return (
<>
<EuiCallOut
title={
<>
<strong>{i18n.UPGRADE_STATUS}</strong>
&nbsp;
<ReviewRequiredBadge />
&nbsp;
{i18n.RULE_HAS_CONFLICTS(numOfSolvableConflicts)}
<ReadyForUpgradeBadge />
</>
}
color="warning"
color="success"
size="s"
>
<span>{i18n.RULE_HAS_SOFT_CONFLICTS_DESCRIPTION}</span>
<ul>
<li>{i18n.RULE_HAS_SOFT_CONFLICTS_ACCEPT_SUGGESTED_UPDATE}</li>
<li>{i18n.RULE_HAS_SOFT_CONFLICTS_EDIT_FINAL_VERSION}</li>
</ul>
<p>{i18n.RULE_IS_READY_FOR_UPGRADE_DESCRIPTION}</p>
</EuiCallOut>
);
}
return (
<EuiCallOut
title={
<>
<strong>{i18n.UPGRADE_STATUS}</strong>
&nbsp;
<ReadyForUpgradeBadge />
</>
}
color="success"
size="s"
>
<p>{i18n.RULE_IS_READY_FOR_UPGRADE_DESCRIPTION}</p>
</EuiCallOut>
</>
);
}

View file

@ -158,3 +158,11 @@ export const FIELD_MODIFIED_BADGE_DESCRIPTION = i18n.translate(
'This field value differs from the one provided in the original version of the rule.',
}
);
export const RULE_BASE_VERSION_IS_MISSING_DESCRIPTION = i18n.translate(
'xpack.securitySolution.detectionEngine.upgradeFlyout.baseVersionMissingDescription',
{
defaultMessage:
"The original, unedited version of this Elastic rule couldn't be found. This sometimes happens when a rule hasn't been updated in a while. You can still update this rule, but will only have access to its current version and the incoming Elastic update. Updating Elastic rules more often can help you avoid this in the future. We encourage you to review this update carefully and ensure your changes are not accidentally overwritten.",
}
);

View file

@ -461,3 +461,17 @@ export const LUCENE_LANGUAGE_LABEL = i18n.translate(
defaultMessage: 'Lucene',
}
);
export const HAS_RULE_UPDATE_CALLOUT_TITLE = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleDetails.updateCalloutTitle',
{
defaultMessage: 'Elastic rule update available',
}
);
export const HAS_RULE_UPDATE_CALLOUT_BUTTON = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleDetailsUpdate.calloutButton',
{
defaultMessage: 'Review update',
}
);

View file

@ -0,0 +1,423 @@
/*
* 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 React, { useCallback, useMemo, useState } from 'react';
import { EuiButton, EuiToolTip } from '@elastic/eui';
import type { ReviewPrebuiltRuleUpgradeFilter } from '../../../../common/api/detection_engine/prebuilt_rules/common/review_prebuilt_rules_upgrade_filter';
import { FieldUpgradeStateEnum, type RuleUpgradeState } from '../model/prebuilt_rule_upgrade';
import { PerFieldRuleDiffTab } from '../components/rule_details/per_field_rule_diff_tab';
import { PrebuiltRulesCustomizationDisabledReason } from '../../../../common/detection_engine/prebuilt_rules/prebuilt_rule_customization_status';
import { useIsUpgradingSecurityPackages } from '../logic/use_upgrade_security_packages';
import { usePrebuiltRulesCustomizationStatus } from '../logic/prebuilt_rules/use_prebuilt_rules_customization_status';
import { usePerformUpgradeRules } from '../logic/prebuilt_rules/use_perform_rule_upgrade';
import { usePrebuiltRulesUpgradeReview } from '../logic/prebuilt_rules/use_prebuilt_rules_upgrade_review';
import type {
FindRulesSortField,
RuleFieldsToUpgrade,
RuleResponse,
RuleSignatureId,
RuleUpgradeSpecifier,
} from '../../../../common/api/detection_engine';
import { usePrebuiltRulesUpgradeState } from '../../rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_prebuilt_rules_upgrade_state';
import { useOutdatedMlJobsUpgradeModal } from '../../rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_ml_jobs_upgrade_modal';
import { useUpgradeWithConflictsModal } from '../../rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_upgrade_with_conflicts_modal';
import * as ruleDetailsI18n from '../components/rule_details/translations';
import * as i18n from '../../rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/translations';
import { UpgradeFlyoutSubHeader } from '../../rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/upgrade_flyout_subheader';
import { CustomizationDisabledCallout } from '../../rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/customization_disabled_callout';
import { RuleUpgradeTab } from '../components/rule_details/three_way_diff';
import { TabContentPadding } from '../../../siem_migrations/rules/components/rule_details_flyout';
import { RuleTypeChangeCallout } from '../../rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/rule_type_change_callout';
import { RuleDiffTab } from '../components/rule_details/rule_diff_tab';
import { useRulePreviewFlyout } from '../../rule_management_ui/components/rules_table/use_rule_preview_flyout';
import type { UpgradePrebuiltRulesSortingOptions } from '../../rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/upgrade_prebuilt_rules_table_context';
import { RULES_TABLE_INITIAL_PAGE_SIZE } from '../../rule_management_ui/components/rules_table/constants';
const REVIEW_PREBUILT_RULES_UPGRADE_REFRESH_INTERVAL = 5 * 60 * 1000;
export const PREBUILT_RULE_UPDATE_FLYOUT_ANCHOR = 'updatePrebuiltRulePreview';
export interface UsePrebuiltRulesUpgradeParams {
pagination?: {
page: number;
perPage: number;
};
sort?: { order: UpgradePrebuiltRulesSortingOptions['order']; field: FindRulesSortField };
filter: ReviewPrebuiltRuleUpgradeFilter;
onUpgrade?: () => void;
}
export function usePrebuiltRulesUpgrade({
pagination = { page: 1, perPage: RULES_TABLE_INITIAL_PAGE_SIZE },
sort,
filter,
onUpgrade,
}: UsePrebuiltRulesUpgradeParams) {
const { isRulesCustomizationEnabled, customizationDisabledReason } =
usePrebuiltRulesCustomizationStatus();
const isUpgradingSecurityPackages = useIsUpgradingSecurityPackages();
const [loadingRules, setLoadingRules] = useState<RuleSignatureId[]>([]);
const {
data: upgradeReviewResponse,
refetch,
dataUpdatedAt,
isFetched,
isLoading,
isFetching,
isRefetching,
} = usePrebuiltRulesUpgradeReview(
{
page: pagination.page,
per_page: pagination.perPage,
sort,
filter,
},
{
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 upgradeableRules = useMemo(
() => upgradeReviewResponse?.rules ?? [],
[upgradeReviewResponse]
);
const { rulesUpgradeState, setRuleFieldResolvedValue } =
usePrebuiltRulesUpgradeState(upgradeableRules);
const ruleUpgradeStates = useMemo(() => Object.values(rulesUpgradeState), [rulesUpgradeState]);
const {
modal: confirmLegacyMlJobsUpgradeModal,
confirmLegacyMLJobs,
isLoading: areMlJobsLoading,
} = useOutdatedMlJobsUpgradeModal();
const { modal: upgradeConflictsModal, confirmConflictsUpgrade } = useUpgradeWithConflictsModal();
const { mutateAsync: upgradeRulesRequest } = usePerformUpgradeRules();
const upgradeRulesToResolved = useCallback(
async (ruleIds: RuleSignatureId[]) => {
const conflictRuleIdsSet = new Set(
ruleIds.filter(
(ruleId) =>
rulesUpgradeState[ruleId].diff.num_fields_with_conflicts > 0 &&
rulesUpgradeState[ruleId].hasUnresolvedConflicts
)
);
const upgradingRuleIds = ruleIds.filter((ruleId) => !conflictRuleIdsSet.has(ruleId));
const ruleUpgradeSpecifiers: RuleUpgradeSpecifier[] = upgradingRuleIds.map((ruleId) => ({
rule_id: ruleId,
version: rulesUpgradeState[ruleId].target_rule.version,
revision: rulesUpgradeState[ruleId].revision,
fields: constructRuleFieldsToUpgrade(rulesUpgradeState[ruleId]),
}));
setLoadingRules((prev) => [...prev, ...upgradingRuleIds]);
try {
// Handle MLJobs modal
if (!(await confirmLegacyMLJobs())) {
return;
}
if (conflictRuleIdsSet.size > 0 && !(await confirmConflictsUpgrade())) {
return;
}
await upgradeRulesRequest({
mode: 'SPECIFIC_RULES',
pick_version: 'MERGED',
rules: ruleUpgradeSpecifiers,
});
} catch {
// Error is handled by the mutation's onError callback, so no need to do anything here
} finally {
const upgradedRuleIdsSet = new Set(upgradingRuleIds);
if (onUpgrade) {
onUpgrade();
}
setLoadingRules((prev) => prev.filter((id) => !upgradedRuleIdsSet.has(id)));
}
},
[
rulesUpgradeState,
confirmLegacyMLJobs,
confirmConflictsUpgrade,
upgradeRulesRequest,
onUpgrade,
]
);
const upgradeRulesToTarget = useCallback(
async (ruleIds: RuleSignatureId[]) => {
const ruleUpgradeSpecifiers: RuleUpgradeSpecifier[] = ruleIds.map((ruleId) => ({
rule_id: ruleId,
version: rulesUpgradeState[ruleId].target_rule.version,
revision: rulesUpgradeState[ruleId].revision,
}));
setLoadingRules((prev) => [...prev, ...ruleIds]);
try {
// Handle MLJobs modal
if (!(await confirmLegacyMLJobs())) {
return;
}
await upgradeRulesRequest({
mode: 'SPECIFIC_RULES',
pick_version: 'TARGET',
rules: ruleUpgradeSpecifiers,
});
} catch {
// Error is handled by the mutation's onError callback, so no need to do anything here
} finally {
const upgradedRuleIdsSet = new Set(ruleIds);
if (onUpgrade) {
onUpgrade();
}
setLoadingRules((prev) => prev.filter((id) => !upgradedRuleIdsSet.has(id)));
}
},
[confirmLegacyMLJobs, onUpgrade, rulesUpgradeState, upgradeRulesRequest]
);
const upgradeRules = useCallback(
async (ruleIds: RuleSignatureId[]) => {
if (isRulesCustomizationEnabled) {
await upgradeRulesToResolved(ruleIds);
} else {
await upgradeRulesToTarget(ruleIds);
}
},
[isRulesCustomizationEnabled, upgradeRulesToResolved, upgradeRulesToTarget]
);
const upgradeAllRules = useCallback(async () => {
setLoadingRules((prev) => [...prev, ...upgradeableRules.map((rule) => rule.rule_id)]);
try {
// Handle MLJobs modal
if (!(await confirmLegacyMLJobs())) {
return;
}
const dryRunResults = await upgradeRulesRequest({
mode: 'ALL_RULES',
pick_version: isRulesCustomizationEnabled ? 'MERGED' : 'TARGET',
filter,
dry_run: true,
on_conflict: 'SKIP',
});
const hasConflicts = dryRunResults.results.skipped.some(
(skippedRule) => skippedRule.reason === 'CONFLICT'
);
if (hasConflicts && !(await confirmConflictsUpgrade())) {
return;
}
await upgradeRulesRequest({
mode: 'ALL_RULES',
pick_version: isRulesCustomizationEnabled ? 'MERGED' : 'TARGET',
filter,
on_conflict: 'SKIP',
});
} catch {
// Error is handled by the mutation's onError callback, so no need to do anything here
} finally {
setLoadingRules([]);
}
}, [
upgradeableRules,
confirmLegacyMLJobs,
upgradeRulesRequest,
isRulesCustomizationEnabled,
filter,
confirmConflictsUpgrade,
]);
const subHeaderFactory = useCallback(
(rule: RuleResponse) =>
rulesUpgradeState[rule.rule_id] ? (
<UpgradeFlyoutSubHeader ruleUpgradeState={rulesUpgradeState[rule.rule_id]} />
) : null,
[rulesUpgradeState]
);
const ruleActionsFactory = useCallback(
(rule: RuleResponse, closeRulePreview: () => void, isEditingRule: boolean) => {
const ruleUpgradeState = rulesUpgradeState[rule.rule_id];
if (!ruleUpgradeState) {
return null;
}
const hasRuleTypeChange = ruleUpgradeState.diff.fields.type?.has_update ?? false;
return (
<EuiButton
disabled={
loadingRules.includes(rule.rule_id) ||
isRefetching ||
isUpgradingSecurityPackages ||
(ruleUpgradeState.hasUnresolvedConflicts && !hasRuleTypeChange) ||
isEditingRule
}
onClick={() => {
if (hasRuleTypeChange || isRulesCustomizationEnabled === false) {
// If there is a rule type change, we can't resolve conflicts, only accept the target rule
upgradeRulesToTarget([rule.rule_id]);
} else {
upgradeRulesToResolved([rule.rule_id]);
}
closeRulePreview();
}}
fill
data-test-subj="updatePrebuiltRuleFromFlyoutButton"
>
{i18n.UPDATE_BUTTON_LABEL}
</EuiButton>
);
},
[
rulesUpgradeState,
loadingRules,
isRefetching,
isUpgradingSecurityPackages,
isRulesCustomizationEnabled,
upgradeRulesToTarget,
upgradeRulesToResolved,
]
);
const extraTabsFactory = useCallback(
(rule: RuleResponse) => {
const ruleUpgradeState = rulesUpgradeState[rule.rule_id];
if (!ruleUpgradeState) {
return [];
}
const hasRuleTypeChange = ruleUpgradeState.diff.fields.type?.has_update ?? false;
const hasCustomizations =
ruleUpgradeState.current_rule.rule_source.type === 'external' &&
ruleUpgradeState.current_rule.rule_source.is_customized;
let headerCallout = null;
if (
hasCustomizations &&
customizationDisabledReason === PrebuiltRulesCustomizationDisabledReason.License
) {
headerCallout = <CustomizationDisabledCallout />;
} else if (hasRuleTypeChange && isRulesCustomizationEnabled) {
headerCallout = <RuleTypeChangeCallout hasCustomizations={hasCustomizations} />;
}
let updateTabContent = (
<PerFieldRuleDiffTab header={headerCallout} ruleDiff={ruleUpgradeState.diff} />
);
// Show the resolver tab only if rule customization is enabled and there
// is no rule type change. In case of rule type change users can't resolve
// conflicts, only accept the target rule.
if (isRulesCustomizationEnabled && !hasRuleTypeChange) {
updateTabContent = (
<RuleUpgradeTab
ruleUpgradeState={ruleUpgradeState}
setRuleFieldResolvedValue={setRuleFieldResolvedValue}
/>
);
}
const updatesTab = {
id: 'updates',
name: (
<EuiToolTip position="top" content={i18n.UPDATE_FLYOUT_PER_FIELD_TOOLTIP_DESCRIPTION}>
<>{ruleDetailsI18n.UPDATES_TAB_LABEL}</>
</EuiToolTip>
),
content: <TabContentPadding>{updateTabContent}</TabContentPadding>,
};
const jsonViewTab = {
id: 'jsonViewUpdates',
name: (
<EuiToolTip position="top" content={i18n.UPDATE_FLYOUT_JSON_VIEW_TOOLTIP_DESCRIPTION}>
<>{ruleDetailsI18n.JSON_VIEW_UPDATES_TAB_LABEL}</>
</EuiToolTip>
),
content: (
<div>
<RuleDiffTab
oldRule={ruleUpgradeState.current_rule}
newRule={ruleUpgradeState.target_rule}
/>
</div>
),
};
return [updatesTab, jsonViewTab];
},
[
rulesUpgradeState,
customizationDisabledReason,
isRulesCustomizationEnabled,
setRuleFieldResolvedValue,
]
);
const { rulePreviewFlyout, openRulePreview } = useRulePreviewFlyout({
rules: ruleUpgradeStates.map(({ target_rule: targetRule }) => targetRule),
subHeaderFactory,
ruleActionsFactory,
extraTabsFactory,
flyoutProps: {
id: PREBUILT_RULE_UPDATE_FLYOUT_ANCHOR,
dataTestSubj: PREBUILT_RULE_UPDATE_FLYOUT_ANCHOR,
},
});
return {
ruleUpgradeStates,
upgradeReviewResponse,
isFetched,
isLoading: isLoading || areMlJobsLoading,
isFetching,
isRefetching,
isUpgradingSecurityPackages,
loadingRules,
lastUpdated: dataUpdatedAt,
rulePreviewFlyout,
confirmLegacyMlJobsUpgradeModal,
upgradeConflictsModal,
openRulePreview,
reFetchRules: refetch,
upgradeRules,
upgradeAllRules,
};
}
function constructRuleFieldsToUpgrade(ruleUpgradeState: RuleUpgradeState): RuleFieldsToUpgrade {
const ruleFieldsToUpgrade: Record<string, unknown> = {};
for (const [fieldName, fieldUpgradeState] of Object.entries(
ruleUpgradeState.fieldsUpgradeState
)) {
if (fieldUpgradeState.state === FieldUpgradeStateEnum.Accepted) {
ruleFieldsToUpgrade[fieldName] = {
pick_version: 'RESOLVED',
resolved_value: fieldUpgradeState.resolvedValue,
};
}
}
return ruleFieldsToUpgrade;
}

View file

@ -0,0 +1,32 @@
/*
* 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 React from 'react';
import type { RuleResponse } from '../../../../common/api/detection_engine';
import { RuleUpdateCallout } from '../components/rule_details/rule_update_callout';
interface UseRuleUpdateCalloutProps {
rule: RuleResponse | null;
message: string;
actionButton?: JSX.Element;
onUpgrade?: () => void;
}
export const useRuleUpdateCallout = ({
rule,
message,
actionButton,
onUpgrade,
}: UseRuleUpdateCalloutProps): JSX.Element | null =>
!rule || rule.rule_source.type !== 'external' ? null : (
<RuleUpdateCallout
actionButton={actionButton}
message={message}
rule={rule}
onUpgrade={onUpgrade}
/>
);

View file

@ -17,4 +17,8 @@ export interface RuleUpgradeState extends RuleUpgradeInfoForReview {
* Indicates whether there are conflicts blocking rule upgrading.
*/
hasUnresolvedConflicts: boolean;
/**
* Indicates whether there are non-solvable conflicts blocking rule upgrading.
*/
hasNonSolvableUnresolvedConflicts: boolean;
}

View file

@ -34,7 +34,7 @@ type OnClick = () => void;
export const getUpdateRulesCalloutTitle = (onClick: OnClick) => (
<FormattedMessage
id="xpack.securitySolution.detectionEngine.rules.updatePrebuiltRulesCalloutTitle"
defaultMessage="Updates available for installed rules. Review and update in&nbsp;{link}."
defaultMessage="Some Elastic rules have updates available. Update them to ensure you get the best detection experience. Review and update in&nbsp;{link}."
values={{
link: (
<EuiLink

View file

@ -18,15 +18,23 @@ import {
} from '../mini_callout/translations';
import { AllRulesTabs } from '../rules_table/rules_table_toolbar';
export const RuleUpdateCallouts = () => {
interface RuleUpdateCalloutsProps {
shouldShowNewRulesCallout?: boolean;
shouldShowUpdateRulesCallout?: boolean;
}
export const RuleUpdateCallouts = ({
shouldShowNewRulesCallout = false,
shouldShowUpdateRulesCallout = false,
}: RuleUpdateCalloutsProps) => {
const { data: prebuiltRulesStatus } = usePrebuiltRulesStatus();
const rulesToInstallCount = prebuiltRulesStatus?.stats.num_prebuilt_rules_to_install ?? 0;
const rulesToUpgradeCount = prebuiltRulesStatus?.stats.num_prebuilt_rules_to_upgrade ?? 0;
// Check against rulesInstalledCount since we don't want to show banners if we're showing the empty prompt
const shouldDisplayNewRulesCallout = rulesToInstallCount > 0;
const shouldDisplayUpdateRulesCallout = rulesToUpgradeCount > 0;
const shouldDisplayNewRulesCallout = shouldShowNewRulesCallout && rulesToInstallCount > 0;
const shouldDisplayUpdateRulesCallout = shouldShowUpdateRulesCallout && rulesToUpgradeCount > 0;
const getSecuritySolutionLinkProps = useGetSecuritySolutionLinkProps();
const { href } = getSecuritySolutionLinkProps({

View file

@ -78,7 +78,7 @@ export const AddPrebuiltRulesTable = React.memo(() => {
) : (
<>
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<EuiFlexItem grow={false} css={{ alignSelf: 'start' }}>
<RulesChangelogLink />
</EuiFlexItem>
<EuiFlexItem grow={false}>

View file

@ -26,8 +26,8 @@ import React, { useEffect, useMemo } from 'react';
import { NEW_FEATURES_TOUR_STORAGE_KEYS } from '../../../../../../common/constants';
import { useKibana } from '../../../../../common/lib/kibana';
import { useIsElementMounted } from '../rules_table/guided_onboarding/use_is_element_mounted';
import { PREBUILT_RULE_UPDATE_FLYOUT_ANCHOR } from '../upgrade_prebuilt_rules_table/upgrade_prebuilt_rules_table_context';
import * as i18n from './translations';
import { PREBUILT_RULE_UPDATE_FLYOUT_ANCHOR } from '../../../../rule_management/hooks/use_prebuilt_rules_upgrade';
export interface RulesFeatureTourContextType {
steps: EuiTourStepProps[];

View file

@ -97,7 +97,7 @@ export const UpgradePrebuiltRulesTable = React.memo(() => {
) : (
<>
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<EuiFlexItem grow={false} css={{ alignSelf: 'start' }}>
<RulesChangelogLink />
</EuiFlexItem>
<EuiFlexItem grow={false}>

View file

@ -5,46 +5,29 @@
* 2.0.
*/
import { EuiButton, EuiToolTip } from '@elastic/eui';
import type { Dispatch, SetStateAction } from 'react';
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
import { PrebuiltRulesCustomizationDisabledReason } from '../../../../../../common/detection_engine/prebuilt_rules/prebuilt_rule_customization_status';
import React, { createContext, useContext, useMemo, useState } from 'react';
import type {
FindRulesSortField,
PrebuiltRulesFilter,
RuleFieldsToUpgrade,
RuleUpgradeSpecifier,
SortOrder,
} from '../../../../../../common/api/detection_engine';
import { usePrebuiltRulesCustomizationStatus } from '../../../../rule_management/logic/prebuilt_rules/use_prebuilt_rules_customization_status';
import type { RuleUpgradeState } from '../../../../rule_management/model/prebuilt_rule_upgrade';
import { RuleUpgradeTab } from '../../../../rule_management/components/rule_details/three_way_diff';
import { PerFieldRuleDiffTab } from '../../../../rule_management/components/rule_details/per_field_rule_diff_tab';
import { useIsUpgradingSecurityPackages } from '../../../../rule_management/logic/use_upgrade_security_packages';
import type {
RuleResponse,
RuleSignatureId,
} from '../../../../../../common/api/detection_engine/model/rule_schema';
import type { RuleSignatureId } from '../../../../../../common/api/detection_engine/model/rule_schema';
import { invariant } from '../../../../../../common/utils/invariant';
import { TabContentPadding } from '../../../../rule_management/components/rule_details/rule_details_flyout';
import { usePerformUpgradeRules } from '../../../../rule_management/logic/prebuilt_rules/use_perform_rule_upgrade';
import { usePrebuiltRulesUpgradeReview } from '../../../../rule_management/logic/prebuilt_rules/use_prebuilt_rules_upgrade_review';
import { RuleDiffTab } from '../../../../rule_management/components/rule_details/rule_diff_tab';
import { FieldUpgradeStateEnum } from '../../../../rule_management/model/prebuilt_rule_upgrade/field_upgrade_state_enum';
import { useRulePreviewFlyout } from '../use_rule_preview_flyout';
import { usePrebuiltRulesUpgradeState } from './use_prebuilt_rules_upgrade_state';
import { useOutdatedMlJobsUpgradeModal } from './use_ml_jobs_upgrade_modal';
import { useUpgradeWithConflictsModal } from './use_upgrade_with_conflicts_modal';
import { RuleTypeChangeCallout } from './rule_type_change_callout';
import { UpgradeFlyoutSubHeader } from './upgrade_flyout_subheader';
import * as ruleDetailsI18n from '../../../../rule_management/components/rule_details/translations';
import * as i18n from './translations';
import { CustomizationDisabledCallout } from './customization_disabled_callout';
import { RULES_TABLE_INITIAL_PAGE_SIZE } from '../constants';
import type { PaginationOptions } from '../../../../rule_management/logic';
import { usePrebuiltRulesStatus } from '../../../../rule_management/logic/prebuilt_rules/use_prebuilt_rules_status';
import { usePrebuiltRulesUpgrade } from '../../../../rule_management/hooks/use_prebuilt_rules_upgrade';
const REVIEW_PREBUILT_RULES_UPGRADE_REFRESH_INTERVAL = 5 * 60 * 1000;
export interface UpgradePrebuiltRulesSortingOptions {
field:
| 'current_rule.name'
| 'current_rule.risk_score'
| 'current_rule.severity'
| 'current_rule.last_updated';
order: SortOrder;
}
export interface UpgradePrebuiltRulesSortingOptions {
field:
@ -111,8 +94,6 @@ export interface UpgradePrebuiltRulesTableState {
sortingOptions: UpgradePrebuiltRulesSortingOptions;
}
export const PREBUILT_RULE_UPDATE_FLYOUT_ANCHOR = 'updatePrebuiltRulePreview';
export interface UpgradePrebuiltRulesTableActions {
reFetchRules: () => void;
upgradeRules: (ruleIds: RuleSignatureId[]) => void;
@ -146,9 +127,6 @@ interface UpgradePrebuiltRulesTableContextProviderProps {
export const UpgradePrebuiltRulesTableContextProvider = ({
children,
}: UpgradePrebuiltRulesTableContextProviderProps) => {
const { isRulesCustomizationEnabled, customizationDisabledReason } =
usePrebuiltRulesCustomizationStatus();
// Use the data from the prebuilt rules status API to determine if there are
// rules to upgrade because it returns information about all rules without filters
const { data: prebuiltRulesStatusResponse } = usePrebuiltRulesStatus();
@ -156,9 +134,7 @@ export const UpgradePrebuiltRulesTableContextProvider = ({
(prebuiltRulesStatusResponse?.stats.num_prebuilt_rules_to_upgrade ?? 0) > 0;
const tags = prebuiltRulesStatusResponse?.aggregated_fields?.upgradeable_rules.tags;
const [loadingRules, setLoadingRules] = useState<RuleSignatureId[]>([]);
const [filterOptions, setFilterOptions] = useState<PrebuiltRulesFilter>({});
const isUpgradingSecurityPackages = useIsUpgradingSecurityPackages();
const [pagination, setPagination] = useState({
page: 1,
perPage: RULES_TABLE_INITIAL_PAGE_SIZE,
@ -182,319 +158,34 @@ export const UpgradePrebuiltRulesTableContextProvider = ({
);
const {
data: upgradeReviewResponse,
refetch,
dataUpdatedAt,
ruleUpgradeStates,
upgradeReviewResponse,
isFetched,
isLoading,
isFetching,
isRefetching,
} = usePrebuiltRulesUpgradeReview(
{
page: pagination.page,
per_page: pagination.perPage,
sort: {
field: findRulesSortField,
order: sortingOptions.order,
},
filter: filterOptions,
},
{
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 upgradeableRules = useMemo(
() => upgradeReviewResponse?.rules ?? [],
[upgradeReviewResponse]
);
const { rulesUpgradeState, setRuleFieldResolvedValue } =
usePrebuiltRulesUpgradeState(upgradeableRules);
const ruleUpgradeStates = useMemo(() => Object.values(rulesUpgradeState), [rulesUpgradeState]);
const {
modal: confirmLegacyMlJobsUpgradeModal,
confirmLegacyMLJobs,
isLoading: areMlJobsLoading,
} = useOutdatedMlJobsUpgradeModal();
const { modal: upgradeConflictsModal, confirmConflictsUpgrade } = useUpgradeWithConflictsModal();
const { mutateAsync: upgradeRulesRequest } = usePerformUpgradeRules();
const upgradeRulesToResolved = useCallback(
async (ruleIds: RuleSignatureId[]) => {
const conflictRuleIdsSet = new Set(
ruleIds.filter(
(ruleId) =>
rulesUpgradeState[ruleId].diff.num_fields_with_conflicts > 0 &&
rulesUpgradeState[ruleId].hasUnresolvedConflicts
)
);
const upgradingRuleIds = ruleIds.filter((ruleId) => !conflictRuleIdsSet.has(ruleId));
const ruleUpgradeSpecifiers: RuleUpgradeSpecifier[] = upgradingRuleIds.map((ruleId) => ({
rule_id: ruleId,
version: rulesUpgradeState[ruleId].target_rule.version,
revision: rulesUpgradeState[ruleId].revision,
fields: constructRuleFieldsToUpgrade(rulesUpgradeState[ruleId]),
}));
setLoadingRules((prev) => [...prev, ...upgradingRuleIds]);
try {
// Handle MLJobs modal
if (!(await confirmLegacyMLJobs())) {
return;
}
if (conflictRuleIdsSet.size > 0 && !(await confirmConflictsUpgrade())) {
return;
}
await upgradeRulesRequest({
mode: 'SPECIFIC_RULES',
pick_version: 'MERGED',
rules: ruleUpgradeSpecifiers,
});
} catch {
// Error is handled by the mutation's onError callback, so no need to do anything here
} finally {
const upgradedRuleIdsSet = new Set(upgradingRuleIds);
setLoadingRules((prev) => prev.filter((id) => !upgradedRuleIdsSet.has(id)));
}
},
[confirmLegacyMLJobs, confirmConflictsUpgrade, rulesUpgradeState, upgradeRulesRequest]
);
const upgradeRulesToTarget = useCallback(
async (ruleIds: RuleSignatureId[]) => {
const ruleUpgradeSpecifiers: RuleUpgradeSpecifier[] = ruleIds.map((ruleId) => ({
rule_id: ruleId,
version: rulesUpgradeState[ruleId].target_rule.version,
revision: rulesUpgradeState[ruleId].revision,
}));
setLoadingRules((prev) => [...prev, ...ruleIds]);
try {
// Handle MLJobs modal
if (!(await confirmLegacyMLJobs())) {
return;
}
await upgradeRulesRequest({
mode: 'SPECIFIC_RULES',
pick_version: 'TARGET',
rules: ruleUpgradeSpecifiers,
});
} catch {
// Error is handled by the mutation's onError callback, so no need to do anything here
} finally {
const upgradedRuleIdsSet = new Set(ruleIds);
setLoadingRules((prev) => prev.filter((id) => !upgradedRuleIdsSet.has(id)));
}
},
[confirmLegacyMLJobs, rulesUpgradeState, upgradeRulesRequest]
);
const upgradeRules = useCallback(
async (ruleIds: RuleSignatureId[]) => {
if (isRulesCustomizationEnabled) {
await upgradeRulesToResolved(ruleIds);
} else {
await upgradeRulesToTarget(ruleIds);
}
},
[isRulesCustomizationEnabled, upgradeRulesToResolved, upgradeRulesToTarget]
);
const upgradeAllRules = useCallback(async () => {
setLoadingRules((prev) => [...prev, ...upgradeableRules.map((rule) => rule.rule_id)]);
try {
// Handle MLJobs modal
if (!(await confirmLegacyMLJobs())) {
return;
}
const dryRunResults = await upgradeRulesRequest({
mode: 'ALL_RULES',
pick_version: isRulesCustomizationEnabled ? 'MERGED' : 'TARGET',
filter: filterOptions,
dry_run: true,
on_conflict: 'SKIP',
});
const hasConflicts = dryRunResults.results.skipped.some(
(skippedRule) => skippedRule.reason === 'CONFLICT'
);
if (hasConflicts && !(await confirmConflictsUpgrade())) {
return;
}
await upgradeRulesRequest({
mode: 'ALL_RULES',
pick_version: isRulesCustomizationEnabled ? 'MERGED' : 'TARGET',
filter: filterOptions,
on_conflict: 'SKIP',
});
} catch {
// Error is handled by the mutation's onError callback, so no need to do anything here
} finally {
setLoadingRules([]);
}
}, [
upgradeableRules,
confirmLegacyMLJobs,
upgradeRulesRequest,
isRulesCustomizationEnabled,
filterOptions,
confirmConflictsUpgrade,
]);
const subHeaderFactory = useCallback(
(rule: RuleResponse) =>
rulesUpgradeState[rule.rule_id] ? (
<UpgradeFlyoutSubHeader ruleUpgradeState={rulesUpgradeState[rule.rule_id]} />
) : null,
[rulesUpgradeState]
);
const ruleActionsFactory = useCallback(
(rule: RuleResponse, closeRulePreview: () => void, isEditingRule: boolean) => {
const ruleUpgradeState = rulesUpgradeState[rule.rule_id];
if (!ruleUpgradeState) {
return null;
}
const hasRuleTypeChange = ruleUpgradeState.diff.fields.type?.has_update ?? false;
return (
<EuiButton
disabled={
loadingRules.includes(rule.rule_id) ||
isRefetching ||
isUpgradingSecurityPackages ||
(ruleUpgradeState.hasUnresolvedConflicts && !hasRuleTypeChange) ||
isEditingRule
}
onClick={() => {
if (hasRuleTypeChange || isRulesCustomizationEnabled === false) {
// If there is a rule type change, we can't resolve conflicts, only accept the target rule
upgradeRulesToTarget([rule.rule_id]);
} else {
upgradeRulesToResolved([rule.rule_id]);
}
closeRulePreview();
}}
fill
data-test-subj="updatePrebuiltRuleFromFlyoutButton"
>
{i18n.UPDATE_BUTTON_LABEL}
</EuiButton>
);
},
[
rulesUpgradeState,
loadingRules,
isRefetching,
isUpgradingSecurityPackages,
isRulesCustomizationEnabled,
upgradeRulesToTarget,
upgradeRulesToResolved,
]
);
const extraTabsFactory = useCallback(
(rule: RuleResponse) => {
const ruleUpgradeState = rulesUpgradeState[rule.rule_id];
if (!ruleUpgradeState) {
return [];
}
const hasRuleTypeChange = ruleUpgradeState.diff.fields.type?.has_update ?? false;
const hasCustomizations =
ruleUpgradeState.current_rule.rule_source.type === 'external' &&
ruleUpgradeState.current_rule.rule_source.is_customized;
let headerCallout = null;
if (
hasCustomizations &&
customizationDisabledReason === PrebuiltRulesCustomizationDisabledReason.License
) {
headerCallout = <CustomizationDisabledCallout />;
} else if (hasRuleTypeChange && isRulesCustomizationEnabled) {
headerCallout = <RuleTypeChangeCallout hasCustomizations={hasCustomizations} />;
}
let updateTabContent = (
<PerFieldRuleDiffTab header={headerCallout} ruleDiff={ruleUpgradeState.diff} />
);
// Show the resolver tab only if rule customization is enabled and there
// is no rule type change. In case of rule type change users can't resolve
// conflicts, only accept the target rule.
if (isRulesCustomizationEnabled && !hasRuleTypeChange) {
updateTabContent = (
<RuleUpgradeTab
ruleUpgradeState={ruleUpgradeState}
setRuleFieldResolvedValue={setRuleFieldResolvedValue}
/>
);
}
const updatesTab = {
id: 'updates',
name: (
<EuiToolTip position="top" content={i18n.UPDATE_FLYOUT_PER_FIELD_TOOLTIP_DESCRIPTION}>
<>{ruleDetailsI18n.UPDATES_TAB_LABEL}</>
</EuiToolTip>
),
content: <TabContentPadding>{updateTabContent}</TabContentPadding>,
};
const jsonViewTab = {
id: 'jsonViewUpdates',
name: (
<EuiToolTip position="top" content={i18n.UPDATE_FLYOUT_JSON_VIEW_TOOLTIP_DESCRIPTION}>
<>{ruleDetailsI18n.JSON_VIEW_UPDATES_TAB_LABEL}</>
</EuiToolTip>
),
content: (
<div>
<RuleDiffTab
oldRule={ruleUpgradeState.current_rule}
newRule={ruleUpgradeState.target_rule}
/>
</div>
),
};
return [updatesTab, jsonViewTab];
},
[
rulesUpgradeState,
customizationDisabledReason,
isRulesCustomizationEnabled,
setRuleFieldResolvedValue,
]
);
const { rulePreviewFlyout, openRulePreview } = useRulePreviewFlyout({
rules: ruleUpgradeStates.map(({ target_rule: targetRule }) => targetRule),
subHeaderFactory,
ruleActionsFactory,
extraTabsFactory,
flyoutProps: {
id: PREBUILT_RULE_UPDATE_FLYOUT_ANCHOR,
dataTestSubj: PREBUILT_RULE_UPDATE_FLYOUT_ANCHOR,
isUpgradingSecurityPackages,
loadingRules,
lastUpdated,
rulePreviewFlyout,
confirmLegacyMlJobsUpgradeModal,
upgradeConflictsModal,
openRulePreview,
reFetchRules,
upgradeRules,
upgradeAllRules,
} = usePrebuiltRulesUpgrade({
pagination,
sort: {
field: findRulesSortField,
order: sortingOptions.order,
},
filter: filterOptions,
});
const actions = useMemo<UpgradePrebuiltRulesTableActions>(
() => ({
reFetchRules: refetch,
reFetchRules,
upgradeRules,
upgradeAllRules,
setFilterOptions,
@ -502,7 +193,7 @@ export const UpgradePrebuiltRulesTableContextProvider = ({
setPagination,
setSortingOptions,
}),
[refetch, upgradeRules, upgradeAllRules, openRulePreview]
[reFetchRules, upgradeRules, upgradeAllRules, openRulePreview]
);
const providerValue = useMemo<UpgradePrebuiltRulesContextType>(
@ -513,12 +204,12 @@ export const UpgradePrebuiltRulesTableContextProvider = ({
filterOptions,
tags: tags ?? [],
isFetched,
isLoading: isLoading || areMlJobsLoading,
isLoading,
isFetching,
isRefetching,
isUpgradingSecurityPackages,
loadingRules,
lastUpdated: dataUpdatedAt,
lastUpdated,
pagination: {
...pagination,
total: upgradeReviewResponse?.total ?? 0,
@ -534,12 +225,11 @@ export const UpgradePrebuiltRulesTableContextProvider = ({
tags,
isFetched,
isLoading,
areMlJobsLoading,
isFetching,
isRefetching,
isUpgradingSecurityPackages,
loadingRules,
dataUpdatedAt,
lastUpdated,
pagination,
upgradeReviewResponse?.total,
sortingOptions,
@ -568,20 +258,3 @@ export const useUpgradePrebuiltRulesTableContext = (): UpgradePrebuiltRulesConte
return rulesTableContext;
};
function constructRuleFieldsToUpgrade(ruleUpgradeState: RuleUpgradeState): RuleFieldsToUpgrade {
const ruleFieldsToUpgrade: Record<string, unknown> = {};
for (const [fieldName, fieldUpgradeState] of Object.entries(
ruleUpgradeState.fieldsUpgradeState
)) {
if (fieldUpgradeState.state === FieldUpgradeStateEnum.Accepted) {
ruleFieldsToUpgrade[fieldName] = {
pick_version: 'RESOLVED',
resolved_value: fieldUpgradeState.resolvedValue,
};
}
}
return ruleFieldsToUpgrade;
}

View file

@ -429,6 +429,7 @@ function createRuleUpgradeInfoMock(
num_fields_with_non_solvable_conflicts: 0,
fields: {},
},
has_base_version: true,
version: 1,
revision: 1,
...rewrites,

View file

@ -134,6 +134,9 @@ export function usePrebuiltRulesUpgradeState(
fieldState === FieldUpgradeStateEnum.SolvableConflict ||
fieldState === FieldUpgradeStateEnum.NonSolvableConflict
);
const hasNonSolvableFieldConflicts = Object.values(fieldsUpgradeState).some(
({ state: fieldState }) => fieldState === FieldUpgradeStateEnum.NonSolvableConflict
);
state[ruleUpgradeInfo.rule_id] = {
...ruleUpgradeInfo,
@ -141,6 +144,9 @@ export function usePrebuiltRulesUpgradeState(
hasUnresolvedConflicts: isRulesCustomizationEnabled
? hasRuleTypeChange || hasFieldConflicts
: false,
hasNonSolvableUnresolvedConflicts: isRulesCustomizationEnabled
? hasRuleTypeChange || hasNonSolvableFieldConflicts
: false,
};
}

View file

@ -144,6 +144,7 @@ const MODIFIED_COLUMN: TableColumn = {
const createUpgradeButtonColumn = (
upgradeRules: UpgradePrebuiltRulesTableActions['upgradeRules'],
openRulePreview: UpgradePrebuiltRulesTableActions['openRulePreview'],
loadingRules: RuleSignatureId[],
isDisabled: boolean,
isPrebuiltRulesCustomizationEnabled: boolean
@ -154,7 +155,7 @@ const createUpgradeButtonColumn = (
const isRuleUpgrading = loadingRules.includes(ruleId);
const isDisabledByConflicts =
isPrebuiltRulesCustomizationEnabled && record.hasUnresolvedConflicts;
const isUpgradeButtonDisabled = isRuleUpgrading || isDisabled || isDisabledByConflicts;
const isUpgradeButtonDisabled = isRuleUpgrading || isDisabled;
const spinner = (
<EuiLoadingSpinner
size="s"
@ -162,21 +163,30 @@ const createUpgradeButtonColumn = (
/>
);
const tooltipContent = isDisabledByConflicts
? i18n.UPDATE_RULE_BUTTON_TOOLTIP_CONFLICTS
: undefined;
if (isDisabledByConflicts) {
return (
<EuiToolTip content={i18n.UPDATE_RULE_BUTTON_TOOLTIP_CONFLICTS}>
<EuiButtonEmpty
size="s"
disabled={isUpgradeButtonDisabled}
onClick={() => openRulePreview(ruleId)}
data-test-subj={`reviewSinglePrebuiltRuleButton-${ruleId}`}
>
{isRuleUpgrading ? spinner : i18n.REVIEW_RULE_BUTTON}
</EuiButtonEmpty>
</EuiToolTip>
);
}
return (
<EuiToolTip content={tooltipContent}>
<EuiButtonEmpty
size="s"
disabled={isUpgradeButtonDisabled}
onClick={() => upgradeRules([ruleId])}
data-test-subj={`upgradeSinglePrebuiltRuleButton-${ruleId}`}
>
{isRuleUpgrading ? spinner : i18n.UPDATE_RULE_BUTTON}
</EuiButtonEmpty>
</EuiToolTip>
<EuiButtonEmpty
size="s"
disabled={isUpgradeButtonDisabled}
onClick={() => upgradeRules([ruleId])}
data-test-subj={`upgradeSinglePrebuiltRuleButton-${ruleId}`}
>
{isRuleUpgrading ? spinner : i18n.UPDATE_RULE_BUTTON}
</EuiButtonEmpty>
);
},
width: '10%',
@ -189,7 +199,7 @@ export const useUpgradePrebuiltRulesTableColumns = (): TableColumn[] => {
const [showRelatedIntegrations] = useUiSetting$<boolean>(SHOW_RELATED_INTEGRATIONS_SETTING);
const {
state: { loadingRules, isRefetching, isUpgradingSecurityPackages },
actions: { upgradeRules },
actions: { upgradeRules, openRulePreview },
} = useUpgradePrebuiltRulesTableContext();
const isDisabled = isRefetching || isUpgradingSecurityPackages;
@ -234,6 +244,7 @@ export const useUpgradePrebuiltRulesTableColumns = (): TableColumn[] => {
? [
createUpgradeButtonColumn(
upgradeRules,
openRulePreview,
loadingRules,
isDisabled,
isRulesCustomizationEnabled
@ -246,6 +257,7 @@ export const useUpgradePrebuiltRulesTableColumns = (): TableColumn[] => {
showRelatedIntegrations,
hasCRUDPermissions,
upgradeRules,
openRulePreview,
loadingRules,
isDisabled,
isRulesCustomizationEnabled,

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiToolTip, EuiSpacer } from '@elastic/eui';
import { MaintenanceWindowCallout } from '@kbn/alerts-ui-shared';
import React, { useCallback } from 'react';
import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common';
@ -35,6 +35,7 @@ import { AllRules } from '../../components/rules_table';
import { RulesTableContextProvider } from '../../components/rules_table/rules_table/rules_table_context';
import { useInvalidateFetchCoverageOverviewQuery } from '../../../rule_management/api/hooks/use_fetch_coverage_overview_query';
import { HeaderPage } from '../../../../common/components/header_page';
import { RuleUpdateCallouts } from '../../components/rule_update_callouts/rule_update_callouts';
const RulesPageComponent: React.FC = () => {
const [isImportModalVisible, showImportModal, hideImportModal] = useBoolState();
@ -168,6 +169,8 @@ const RulesPageComponent: React.FC = () => {
</EuiFlexItem>
</EuiFlexGroup>
</HeaderPage>
<RuleUpdateCallouts shouldShowUpdateRulesCallout={true} />
<EuiSpacer size="s" />
<MaintenanceWindowCallout
kibanaServices={kibanaServices}
categories={[DEFAULT_APP_CATEGORIES.security.id]}

View file

@ -1091,6 +1091,28 @@ export const CLEAR_RULES_TABLE_FILTERS = i18n.translate(
}
);
export const HAS_RULE_UPDATE_DETAILS_CALLOUT_MESSAGE = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleDetailsUpgrade.calloutMessage',
{
defaultMessage: 'Review the update to see the latest improvements, then update your rule.',
}
);
export const HAS_RULE_UPDATE_EDITING_CALLOUT_MESSAGE = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleDetailsUpgrade.calloutMessage',
{
defaultMessage:
'Before editing this rule, we strongly recommend that you update it to ensure you get the latest improvements.',
}
);
export const HAS_RULE_UPDATE_EDITING_CALLOUT_BUTTON = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleEditingUpdate.calloutButton',
{
defaultMessage: 'Return to details',
}
);
/**
* Bulk Export
*/
@ -1433,6 +1455,13 @@ export const UPDATE_RULE_BUTTON = i18n.translate(
}
);
export const REVIEW_RULE_BUTTON = i18n.translate(
'xpack.securitySolution.addRules.reviewRuleButton',
{
defaultMessage: 'Review rule',
}
);
export const UPDATE_RULE_BUTTON_TOOLTIP_CONFLICTS = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.upgradeRules.button.conflicts',
{

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { pickBy } from 'lodash';
import { isRuleCustomized } from '../../../../../../common/detection_engine/rule_management/utils';
import { withSecuritySpanSync } from '../../../../../utils/with_security_span';
import type { PromisePoolError } from '../../../../../utils/promise_pool';
import {
@ -58,17 +59,22 @@ export const createModifiedPrebuiltRuleAssets = ({
assertPickVersionIsTarget({ ruleId, requestBody });
}
const calculatedRuleDiff = calculateRuleFieldsDiff({
base_version: upgradeableRule.base
? convertRuleToDiffable(
convertPrebuiltRuleAssetToRuleResponse(upgradeableRule.base)
)
: MissingVersion,
current_version: convertRuleToDiffable(upgradeableRule.current),
target_version: convertRuleToDiffable(
convertPrebuiltRuleAssetToRuleResponse(upgradeableRule.target)
),
}) as AllFieldsDiff;
const isCustomized = isRuleCustomized(current);
const calculatedRuleDiff = calculateRuleFieldsDiff(
{
base_version: upgradeableRule.base
? convertRuleToDiffable(
convertPrebuiltRuleAssetToRuleResponse(upgradeableRule.base)
)
: MissingVersion,
current_version: convertRuleToDiffable(upgradeableRule.current),
target_version: convertRuleToDiffable(
convertPrebuiltRuleAssetToRuleResponse(upgradeableRule.target)
),
},
isCustomized
) as AllFieldsDiff;
if (mode === 'ALL_RULES' && globalPickVersion === 'MERGED') {
const fieldsWithConflicts = Object.keys(getFieldsDiffConflicts(calculatedRuleDiff));

View file

@ -23,6 +23,7 @@ export const calculateRuleUpgradeInfo = (
const { ruleDiff, ruleVersions } = result;
const installedCurrentVersion = ruleVersions.input.current;
const targetVersion = ruleVersions.input.target;
const baseVersion = ruleVersions.input.base;
invariant(installedCurrentVersion != null, 'installedCurrentVersion not found');
invariant(targetVersion != null, 'targetVersion not found');
@ -43,6 +44,7 @@ export const calculateRuleUpgradeInfo = (
version: installedCurrentVersion.version,
current_rule: installedCurrentVersion,
target_rule: targetRule,
has_base_version: baseVersion !== undefined,
diff: {
fields: pickBy<ThreeWayDiff<unknown>>(
ruleDiff.fields,

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { isRuleCustomized } from '../../../../../../common/detection_engine/rule_management/utils';
import type {
DiffableRule,
FullRuleDiff,
@ -66,9 +67,8 @@ export const calculateRuleDiff = (args: RuleVersions): CalculateRuleDiffResult =
const { base, current, target } = args;
invariant(current != null, 'current version is required');
const diffableCurrentVersion = convertRuleToDiffable(
convertPrebuiltRuleAssetToRuleResponse(current)
);
const diffableCurrentVersion = convertRuleToDiffable(current);
const isCustomized = isRuleCustomized(current);
invariant(target != null, 'target version is required');
const diffableTargetVersion = convertRuleToDiffable(
@ -80,11 +80,14 @@ export const calculateRuleDiff = (args: RuleVersions): CalculateRuleDiffResult =
? convertRuleToDiffable(convertPrebuiltRuleAssetToRuleResponse(base))
: undefined;
const fieldsDiff = calculateRuleFieldsDiff({
base_version: diffableBaseVersion || MissingVersion,
current_version: diffableCurrentVersion,
target_version: diffableTargetVersion,
});
const fieldsDiff = calculateRuleFieldsDiff(
{
base_version: diffableBaseVersion || MissingVersion,
current_version: diffableCurrentVersion,
target_version: diffableTargetVersion,
},
isCustomized
);
const {
numberFieldsWithUpdates,

View file

@ -36,7 +36,7 @@ describe('dataSourceDiffAlgorithm', () => {
},
};
const result = dataSourceDiffAlgorithm(mockVersions);
const result = dataSourceDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -55,7 +55,7 @@ describe('dataSourceDiffAlgorithm', () => {
target_version: { type: DataSourceType.data_view, data_view_id: '123' },
};
const result = dataSourceDiffAlgorithm(mockVersions);
const result = dataSourceDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -82,7 +82,7 @@ describe('dataSourceDiffAlgorithm', () => {
},
};
const result = dataSourceDiffAlgorithm(mockVersions);
const result = dataSourceDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -110,7 +110,7 @@ describe('dataSourceDiffAlgorithm', () => {
},
};
const result = dataSourceDiffAlgorithm(mockVersions);
const result = dataSourceDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -135,7 +135,7 @@ describe('dataSourceDiffAlgorithm', () => {
},
};
const result = dataSourceDiffAlgorithm(mockVersions);
const result = dataSourceDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -159,7 +159,7 @@ describe('dataSourceDiffAlgorithm', () => {
},
};
const result = dataSourceDiffAlgorithm(mockVersions);
const result = dataSourceDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -178,7 +178,7 @@ describe('dataSourceDiffAlgorithm', () => {
target_version: { type: DataSourceType.data_view, data_view_id: '456' },
};
const result = dataSourceDiffAlgorithm(mockVersions);
const result = dataSourceDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -208,7 +208,7 @@ describe('dataSourceDiffAlgorithm', () => {
},
};
const result = dataSourceDiffAlgorithm(mockVersions);
const result = dataSourceDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -227,7 +227,7 @@ describe('dataSourceDiffAlgorithm', () => {
target_version: { type: DataSourceType.data_view, data_view_id: '456' },
};
const result = dataSourceDiffAlgorithm(mockVersions);
const result = dataSourceDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -262,7 +262,7 @@ describe('dataSourceDiffAlgorithm', () => {
index_patterns: ['one', 'four', 'five'],
};
const result = dataSourceDiffAlgorithm(mockVersions);
const result = dataSourceDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -281,7 +281,7 @@ describe('dataSourceDiffAlgorithm', () => {
target_version: { type: DataSourceType.data_view, data_view_id: '789' },
};
const result = dataSourceDiffAlgorithm(mockVersions);
const result = dataSourceDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -311,7 +311,7 @@ describe('dataSourceDiffAlgorithm', () => {
index_patterns: ['one', 'three', 'four', 'two', 'five'],
};
const result = dataSourceDiffAlgorithm(mockVersions);
const result = dataSourceDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -338,7 +338,7 @@ describe('dataSourceDiffAlgorithm', () => {
data_view_id: '123',
};
const result = dataSourceDiffAlgorithm(mockVersions);
const result = dataSourceDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -360,7 +360,7 @@ describe('dataSourceDiffAlgorithm', () => {
target_version: { type: DataSourceType.data_view, data_view_id: '789' },
};
const result = dataSourceDiffAlgorithm(mockVersions);
const result = dataSourceDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -385,7 +385,7 @@ describe('dataSourceDiffAlgorithm', () => {
target_version: { type: DataSourceType.data_view, data_view_id: '789' },
};
const result = dataSourceDiffAlgorithm(mockVersions);
const result = dataSourceDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -407,7 +407,7 @@ describe('dataSourceDiffAlgorithm', () => {
},
};
const result = dataSourceDiffAlgorithm(mockVersions);
const result = dataSourceDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -421,81 +421,162 @@ describe('dataSourceDiffAlgorithm', () => {
});
describe('if base_version is missing', () => {
it('returns current_version as merged output if current_version and target_version are the same - scenario -AA', () => {
const mockVersions: ThreeVersionsOf<RuleDataSource | undefined> = {
base_version: MissingVersion,
current_version: {
type: DataSourceType.index_patterns,
index_patterns: ['one', 'three', 'four'],
},
target_version: {
type: DataSourceType.index_patterns,
index_patterns: ['one', 'three', 'four'],
},
};
const result = dataSourceDiffAlgorithm(mockVersions);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.current_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Current,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
describe('returns target_version as merged output if current_version and target_version are different - scenario -AB', () => {
it('if versions are different types', () => {
describe('if current_version and target_version are the same - scenario -AA', () => {
it('returns NONE conflict if rule is NOT customized', () => {
const mockVersions: ThreeVersionsOf<RuleDataSource | undefined> = {
base_version: MissingVersion,
current_version: { type: DataSourceType.data_view, data_view_id: '456' },
current_version: {
type: DataSourceType.index_patterns,
index_patterns: ['one', 'three', 'four'],
},
target_version: {
type: DataSourceType.index_patterns,
index_patterns: ['one', 'three', 'four'],
},
};
const result = dataSourceDiffAlgorithm(mockVersions);
const result = dataSourceDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.SOLVABLE,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
it('if current version is undefined', () => {
it('returns NONE conflict if rule is customized', () => {
const mockVersions: ThreeVersionsOf<RuleDataSource | undefined> = {
base_version: MissingVersion,
current_version: undefined,
current_version: {
type: DataSourceType.index_patterns,
index_patterns: ['one', 'three', 'four'],
},
target_version: {
type: DataSourceType.index_patterns,
index_patterns: ['one', 'three', 'four'],
},
};
const result = dataSourceDiffAlgorithm(mockVersions);
const result = dataSourceDiffAlgorithm(mockVersions, true);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.SOLVABLE,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
});
describe('if current_version and target_version are different - scenario -AB', () => {
describe('returns NONE conflict if rule is NOT customized', () => {
it('if versions are different types', () => {
const mockVersions: ThreeVersionsOf<RuleDataSource | undefined> = {
base_version: MissingVersion,
current_version: { type: DataSourceType.data_view, data_view_id: '456' },
target_version: {
type: DataSourceType.index_patterns,
index_patterns: ['one', 'three', 'four'],
},
};
const result = dataSourceDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
it('if current version is undefined', () => {
const mockVersions: ThreeVersionsOf<RuleDataSource | undefined> = {
base_version: MissingVersion,
current_version: undefined,
target_version: {
type: DataSourceType.index_patterns,
index_patterns: ['one', 'three', 'four'],
},
};
const result = dataSourceDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
});
describe('returns SOLVABLE conflict if rule is customized', () => {
it('if versions are different types', () => {
const mockVersions: ThreeVersionsOf<RuleDataSource | undefined> = {
base_version: MissingVersion,
current_version: { type: DataSourceType.data_view, data_view_id: '456' },
target_version: {
type: DataSourceType.index_patterns,
index_patterns: ['one', 'three', 'four'],
},
};
const result = dataSourceDiffAlgorithm(mockVersions, true);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.SOLVABLE,
})
);
});
it('if current version is undefined', () => {
const mockVersions: ThreeVersionsOf<RuleDataSource | undefined> = {
base_version: MissingVersion,
current_version: undefined,
target_version: {
type: DataSourceType.index_patterns,
index_patterns: ['one', 'three', 'four'],
},
};
const result = dataSourceDiffAlgorithm(mockVersions, true);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.SOLVABLE,
})
);
});
});
});
});
});

View file

@ -27,7 +27,8 @@ import { getDedupedDataSourceVersion, mergeDedupedArrays } from './helpers';
* Takes a type of `RuleDataSource | undefined` because the data source can be index patterns, a data view id, or neither in some cases
*/
export const dataSourceDiffAlgorithm = (
versions: ThreeVersionsOf<RuleDataSource | undefined>
versions: ThreeVersionsOf<RuleDataSource | undefined>,
isRuleCustomized: boolean
): ThreeWayDiff<RuleDataSource | undefined> => {
const {
base_version: baseVersion,
@ -46,6 +47,7 @@ export const dataSourceDiffAlgorithm = (
currentVersion,
targetVersion,
diffOutcome,
isRuleCustomized,
});
return {
@ -73,6 +75,7 @@ interface MergeArgs {
currentVersion: RuleDataSource | undefined;
targetVersion: RuleDataSource | undefined;
diffOutcome: ThreeWayDiffOutcome;
isRuleCustomized: boolean;
}
const mergeVersions = ({
@ -80,6 +83,7 @@ const mergeVersions = ({
currentVersion,
targetVersion,
diffOutcome,
isRuleCustomized,
}: MergeArgs): MergeResult => {
const dedupedBaseVersion = baseVersion ? getDedupedDataSourceVersion(baseVersion) : baseVersion;
const dedupedCurrentVersion = currentVersion
@ -90,9 +94,6 @@ const mergeVersions = ({
: targetVersion;
switch (diffOutcome) {
// Scenario -AA is treated as scenario AAA:
// https://github.com/elastic/kibana/pull/184889#discussion_r1636421293
case ThreeWayDiffOutcome.MissingBaseNoUpdate:
case ThreeWayDiffOutcome.StockValueNoUpdate:
case ThreeWayDiffOutcome.CustomizedValueNoUpdate:
case ThreeWayDiffOutcome.CustomizedValueSameUpdate:
@ -140,14 +141,26 @@ const mergeVersions = ({
};
}
// Scenario -AB is treated as scenario ABC, but marked as
// SOLVABLE, and returns the target version as the merged version
// https://github.com/elastic/kibana/pull/184889#discussion_r1636421293
// Missing base versions always return target version
// Scenario -AA is treated as AAA
// https://github.com/elastic/kibana/issues/210358#issuecomment-2654492854
case ThreeWayDiffOutcome.MissingBaseNoUpdate: {
return {
conflict: ThreeWayDiffConflict.NONE,
mergedVersion: dedupedTargetVersion,
mergeOutcome: ThreeWayMergeOutcome.Target,
};
}
// Missing base versions always return target version
// If the rule is customized, we return a SOLVABLE conflict
// Otherwise we treat scenario -AB as AAB
// https://github.com/elastic/kibana/issues/210358#issuecomment-2654492854
case ThreeWayDiffOutcome.MissingBaseCanUpdate: {
return {
mergedVersion: targetVersion,
conflict: isRuleCustomized ? ThreeWayDiffConflict.SOLVABLE : ThreeWayDiffConflict.NONE,
mergedVersion: dedupedTargetVersion,
mergeOutcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.SOLVABLE,
};
}
default:

View file

@ -37,7 +37,7 @@ describe('eqlQueryDiffAlgorithm', () => {
},
};
const result = eqlQueryDiffAlgorithm(mockVersions);
const result = eqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -68,7 +68,7 @@ describe('eqlQueryDiffAlgorithm', () => {
},
};
const result = eqlQueryDiffAlgorithm(mockVersions);
const result = eqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -99,7 +99,7 @@ describe('eqlQueryDiffAlgorithm', () => {
},
};
const result = eqlQueryDiffAlgorithm(mockVersions);
const result = eqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -130,7 +130,7 @@ describe('eqlQueryDiffAlgorithm', () => {
},
};
const result = eqlQueryDiffAlgorithm(mockVersions);
const result = eqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -162,7 +162,7 @@ describe('eqlQueryDiffAlgorithm', () => {
},
};
const result = eqlQueryDiffAlgorithm(mockVersions);
const result = eqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -193,7 +193,7 @@ describe('eqlQueryDiffAlgorithm', () => {
},
};
const result = eqlQueryDiffAlgorithm(mockVersions);
const result = eqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -207,62 +207,124 @@ describe('eqlQueryDiffAlgorithm', () => {
});
describe('if base_version is missing', () => {
it('returns current_version as merged output if current_version and target_version are the same - scenario -AA', () => {
const mockVersions: ThreeVersionsOf<RuleEqlQuery> = {
base_version: MissingVersion,
current_version: {
query: 'query where true',
language: 'eql',
filters: [],
},
target_version: {
query: 'query where true',
language: 'eql',
filters: [],
},
};
describe('if current_version and target_version are the same - scenario -AA', () => {
it('returns NONE conflict if rule is NOT customized', () => {
const mockVersions: ThreeVersionsOf<RuleEqlQuery> = {
base_version: MissingVersion,
current_version: {
query: 'query where true',
language: 'eql',
filters: [],
},
target_version: {
query: 'query where true',
language: 'eql',
filters: [],
},
};
const result = eqlQueryDiffAlgorithm(mockVersions);
const result = eqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.current_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Current,
conflict: ThreeWayDiffConflict.NONE,
})
);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
it('returns NONE conflict if rule is customized', () => {
const mockVersions: ThreeVersionsOf<RuleEqlQuery> = {
base_version: MissingVersion,
current_version: {
query: 'query where true',
language: 'eql',
filters: [],
},
target_version: {
query: 'query where true',
language: 'eql',
filters: [],
},
};
const result = eqlQueryDiffAlgorithm(mockVersions, true);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
});
it('returns target_version as merged output if current_version and target_version are different - scenario -AB', () => {
const mockVersions: ThreeVersionsOf<RuleEqlQuery> = {
base_version: MissingVersion,
current_version: {
query: 'query where true',
language: 'eql',
filters: [],
},
target_version: {
query: 'query where false',
language: 'eql',
filters: [],
},
};
describe('if current_version and target_version are different - scenario -AB', () => {
it('returns NONE conflict if rule is NOT customized', () => {
const mockVersions: ThreeVersionsOf<RuleEqlQuery> = {
base_version: MissingVersion,
current_version: {
query: 'query where true',
language: 'eql',
filters: [],
},
target_version: {
query: 'query where false',
language: 'eql',
filters: [],
},
};
const result = eqlQueryDiffAlgorithm(mockVersions);
const result = eqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.SOLVABLE,
})
);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
it('returns SOLVABLE conflict if rule is customized', () => {
const mockVersions: ThreeVersionsOf<RuleEqlQuery> = {
base_version: MissingVersion,
current_version: {
query: 'query where true',
language: 'eql',
filters: [],
},
target_version: {
query: 'query where false',
language: 'eql',
filters: [],
},
};
const result = eqlQueryDiffAlgorithm(mockVersions, true);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.SOLVABLE,
})
);
});
});
});
});

View file

@ -14,5 +14,7 @@ import { simpleDiffAlgorithm } from './simple_diff_algorithm';
/**
* Diff algorithm for eql query types
*/
export const eqlQueryDiffAlgorithm = (versions: ThreeVersionsOf<RuleEqlQuery>) =>
simpleDiffAlgorithm<RuleEqlQuery>(versions);
export const eqlQueryDiffAlgorithm = (
versions: ThreeVersionsOf<RuleEqlQuery>,
isRuleCustomized: boolean
) => simpleDiffAlgorithm<RuleEqlQuery>(versions, isRuleCustomized);

View file

@ -34,7 +34,7 @@ describe('esqlQueryDiffAlgorithm', () => {
},
};
const result = esqlQueryDiffAlgorithm(mockVersions);
const result = esqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -62,7 +62,7 @@ describe('esqlQueryDiffAlgorithm', () => {
},
};
const result = esqlQueryDiffAlgorithm(mockVersions);
const result = esqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -90,7 +90,7 @@ describe('esqlQueryDiffAlgorithm', () => {
},
};
const result = esqlQueryDiffAlgorithm(mockVersions);
const result = esqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -118,7 +118,7 @@ describe('esqlQueryDiffAlgorithm', () => {
},
};
const result = esqlQueryDiffAlgorithm(mockVersions);
const result = esqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -147,7 +147,7 @@ describe('esqlQueryDiffAlgorithm', () => {
},
};
const result = esqlQueryDiffAlgorithm(mockVersions);
const result = esqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -161,58 +161,116 @@ describe('esqlQueryDiffAlgorithm', () => {
});
describe('if base_version is missing', () => {
it('returns current_version as merged output if current_version and target_version are the same - scenario -AA', () => {
const mockVersions: ThreeVersionsOf<RuleEsqlQuery> = {
base_version: MissingVersion,
current_version: {
query: 'query where true',
language: 'esql',
},
target_version: {
query: 'query where true',
language: 'esql',
},
};
describe('if current_version and target_version are the same - scenario -AA', () => {
it('returns NONE conflict if rule is NOT customized', () => {
const mockVersions: ThreeVersionsOf<RuleEsqlQuery> = {
base_version: MissingVersion,
current_version: {
query: 'query where true',
language: 'esql',
},
target_version: {
query: 'query where true',
language: 'esql',
},
};
const result = esqlQueryDiffAlgorithm(mockVersions);
const result = esqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.current_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Current,
conflict: ThreeWayDiffConflict.NONE,
})
);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
it('returns NONE conflict if rule is customized', () => {
const mockVersions: ThreeVersionsOf<RuleEsqlQuery> = {
base_version: MissingVersion,
current_version: {
query: 'query where true',
language: 'esql',
},
target_version: {
query: 'query where true',
language: 'esql',
},
};
const result = esqlQueryDiffAlgorithm(mockVersions, true);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
});
it('returns target_version as merged output if current_version and target_version are different - scenario -AB', () => {
const mockVersions: ThreeVersionsOf<RuleEsqlQuery> = {
base_version: MissingVersion,
current_version: {
query: 'query where true',
language: 'esql',
},
target_version: {
query: 'query where false',
language: 'esql',
},
};
describe('if current_version and target_version are different - scenario -AB', () => {
it('returns NONE conflict if rule is NOT customized', () => {
const mockVersions: ThreeVersionsOf<RuleEsqlQuery> = {
base_version: MissingVersion,
current_version: {
query: 'query where true',
language: 'esql',
},
target_version: {
query: 'query where false',
language: 'esql',
},
};
const result = esqlQueryDiffAlgorithm(mockVersions);
const result = esqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.SOLVABLE,
})
);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
it('returns SOLVABLE conflict if rule is customized', () => {
const mockVersions: ThreeVersionsOf<RuleEsqlQuery> = {
base_version: MissingVersion,
current_version: {
query: 'query where true',
language: 'esql',
},
target_version: {
query: 'query where false',
language: 'esql',
},
};
const result = esqlQueryDiffAlgorithm(mockVersions, true);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.SOLVABLE,
})
);
});
});
});
});

View file

@ -14,5 +14,7 @@ import { simpleDiffAlgorithm } from './simple_diff_algorithm';
/**
* Diff algorithm for esql query types
*/
export const esqlQueryDiffAlgorithm = (versions: ThreeVersionsOf<RuleEsqlQuery>) =>
simpleDiffAlgorithm<RuleEsqlQuery>(versions);
export const esqlQueryDiffAlgorithm = (
versions: ThreeVersionsOf<RuleEsqlQuery>,
isRuleCustomized: boolean
) => simpleDiffAlgorithm<RuleEsqlQuery>(versions, isRuleCustomized);

View file

@ -43,7 +43,7 @@ describe('kqlQueryDiffAlgorithm', () => {
},
};
const result = kqlQueryDiffAlgorithm(mockVersions);
const result = kqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -71,7 +71,7 @@ describe('kqlQueryDiffAlgorithm', () => {
},
};
const result = kqlQueryDiffAlgorithm(mockVersions);
const result = kqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -103,7 +103,7 @@ describe('kqlQueryDiffAlgorithm', () => {
},
};
const result = kqlQueryDiffAlgorithm(mockVersions);
const result = kqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -137,7 +137,7 @@ describe('kqlQueryDiffAlgorithm', () => {
},
};
const result = kqlQueryDiffAlgorithm(mockVersions);
const result = kqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -171,7 +171,7 @@ describe('kqlQueryDiffAlgorithm', () => {
},
};
const result = kqlQueryDiffAlgorithm(mockVersions);
const result = kqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -205,7 +205,7 @@ describe('kqlQueryDiffAlgorithm', () => {
},
};
const result = kqlQueryDiffAlgorithm(mockVersions);
const result = kqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -237,7 +237,7 @@ describe('kqlQueryDiffAlgorithm', () => {
},
};
const result = kqlQueryDiffAlgorithm(mockVersions);
const result = kqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -271,7 +271,7 @@ describe('kqlQueryDiffAlgorithm', () => {
},
};
const result = kqlQueryDiffAlgorithm(mockVersions);
const result = kqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -302,7 +302,7 @@ describe('kqlQueryDiffAlgorithm', () => {
},
};
const result = kqlQueryDiffAlgorithm(mockVersions);
const result = kqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -338,7 +338,7 @@ describe('kqlQueryDiffAlgorithm', () => {
},
};
const result = kqlQueryDiffAlgorithm(mockVersions);
const result = kqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -372,7 +372,7 @@ describe('kqlQueryDiffAlgorithm', () => {
},
};
const result = kqlQueryDiffAlgorithm(mockVersions);
const result = kqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -406,7 +406,7 @@ describe('kqlQueryDiffAlgorithm', () => {
},
};
const result = kqlQueryDiffAlgorithm(mockVersions);
const result = kqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -422,124 +422,250 @@ describe('kqlQueryDiffAlgorithm', () => {
describe('if base_version is missing', () => {
describe('if current_version and target_version are the same - scenario -AA', () => {
it('returns current_version as merged output if all versions are inline query types', () => {
const mockVersions: ThreeVersionsOf<RuleKqlQuery> = {
base_version: MissingVersion,
current_version: {
type: KqlQueryType.inline_query,
query: 'query string = true',
language: KqlQueryLanguageEnum.kuery,
filters: [],
},
target_version: {
type: KqlQueryType.inline_query,
query: 'query string = true',
language: KqlQueryLanguageEnum.kuery,
filters: [],
},
};
describe('if rule is NOT customized', () => {
it('returns current_version as merged output if all versions are inline query types', () => {
const mockVersions: ThreeVersionsOf<RuleKqlQuery> = {
base_version: MissingVersion,
current_version: {
type: KqlQueryType.inline_query,
query: 'query string = true',
language: KqlQueryLanguageEnum.kuery,
filters: [],
},
target_version: {
type: KqlQueryType.inline_query,
query: 'query string = true',
language: KqlQueryLanguageEnum.kuery,
filters: [],
},
};
const result = kqlQueryDiffAlgorithm(mockVersions);
const result = kqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.current_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Current,
conflict: ThreeWayDiffConflict.NONE,
})
);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
it('returns current_version as merged output if all versions are saved query types', () => {
const mockVersions: ThreeVersionsOf<RuleKqlQuery> = {
base_version: MissingVersion,
current_version: {
type: KqlQueryType.saved_query,
saved_query_id: 'saved-query-id',
},
target_version: {
type: KqlQueryType.saved_query,
saved_query_id: 'saved-query-id',
},
};
const result = kqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
});
it('returns current_version as merged output if all versions are saved query types', () => {
const mockVersions: ThreeVersionsOf<RuleKqlQuery> = {
base_version: MissingVersion,
current_version: {
type: KqlQueryType.saved_query,
saved_query_id: 'saved-query-id',
},
target_version: {
type: KqlQueryType.saved_query,
saved_query_id: 'saved-query-id',
},
};
describe('if rule is customized', () => {
it('returns current_version as merged output if all versions are inline query types', () => {
const mockVersions: ThreeVersionsOf<RuleKqlQuery> = {
base_version: MissingVersion,
current_version: {
type: KqlQueryType.inline_query,
query: 'query string = true',
language: KqlQueryLanguageEnum.kuery,
filters: [],
},
target_version: {
type: KqlQueryType.inline_query,
query: 'query string = true',
language: KqlQueryLanguageEnum.kuery,
filters: [],
},
};
const result = kqlQueryDiffAlgorithm(mockVersions);
const result = kqlQueryDiffAlgorithm(mockVersions, true);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.current_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Current,
conflict: ThreeWayDiffConflict.NONE,
})
);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
it('returns current_version as merged output if all versions are saved query types', () => {
const mockVersions: ThreeVersionsOf<RuleKqlQuery> = {
base_version: MissingVersion,
current_version: {
type: KqlQueryType.saved_query,
saved_query_id: 'saved-query-id',
},
target_version: {
type: KqlQueryType.saved_query,
saved_query_id: 'saved-query-id',
},
};
const result = kqlQueryDiffAlgorithm(mockVersions, true);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
});
});
describe('if current_version and target_version are different - scenario -AB', () => {
it('returns target_version as merged output if current and target versions have the same types', () => {
const mockVersions: ThreeVersionsOf<RuleKqlQuery> = {
base_version: MissingVersion,
current_version: {
type: KqlQueryType.inline_query,
query: 'query string = true',
language: KqlQueryLanguageEnum.kuery,
filters: [],
},
target_version: {
type: KqlQueryType.inline_query,
query: 'query string = false',
language: KqlQueryLanguageEnum.kuery,
filters: [],
},
};
describe('if rule is NOT customized', () => {
it('returns NONE conflict if current and target versions have the same types', () => {
const mockVersions: ThreeVersionsOf<RuleKqlQuery> = {
base_version: MissingVersion,
current_version: {
type: KqlQueryType.inline_query,
query: 'query string = true',
language: KqlQueryLanguageEnum.kuery,
filters: [],
},
target_version: {
type: KqlQueryType.inline_query,
query: 'query string = false',
language: KqlQueryLanguageEnum.kuery,
filters: [],
},
};
const result = kqlQueryDiffAlgorithm(mockVersions);
const result = kqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.SOLVABLE,
})
);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
it('returns NONE conflict if current and target versions have different types', () => {
const mockVersions: ThreeVersionsOf<RuleKqlQuery> = {
base_version: MissingVersion,
current_version: {
type: KqlQueryType.saved_query,
saved_query_id: 'saved-query-id-2',
},
target_version: {
type: KqlQueryType.inline_query,
query: 'query string = false',
language: KqlQueryLanguageEnum.kuery,
filters: [],
},
};
const result = kqlQueryDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
});
it('returns target_version as merged output if current and target versions have different types', () => {
const mockVersions: ThreeVersionsOf<RuleKqlQuery> = {
base_version: MissingVersion,
current_version: {
type: KqlQueryType.saved_query,
saved_query_id: 'saved-query-id-2',
},
target_version: {
type: KqlQueryType.inline_query,
query: 'query string = false',
language: KqlQueryLanguageEnum.kuery,
filters: [],
},
};
describe('if rule is customized', () => {
it('returns SOLVABLE conflict if current and target versions have the same types', () => {
const mockVersions: ThreeVersionsOf<RuleKqlQuery> = {
base_version: MissingVersion,
current_version: {
type: KqlQueryType.inline_query,
query: 'query string = true',
language: KqlQueryLanguageEnum.kuery,
filters: [],
},
target_version: {
type: KqlQueryType.inline_query,
query: 'query string = false',
language: KqlQueryLanguageEnum.kuery,
filters: [],
},
};
const result = kqlQueryDiffAlgorithm(mockVersions);
const result = kqlQueryDiffAlgorithm(mockVersions, true);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.SOLVABLE,
})
);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.SOLVABLE,
})
);
});
it('returns SOLVABLE conflict if current and target versions have different types', () => {
const mockVersions: ThreeVersionsOf<RuleKqlQuery> = {
base_version: MissingVersion,
current_version: {
type: KqlQueryType.saved_query,
saved_query_id: 'saved-query-id-2',
},
target_version: {
type: KqlQueryType.inline_query,
query: 'query string = false',
language: KqlQueryLanguageEnum.kuery,
filters: [],
},
};
const result = kqlQueryDiffAlgorithm(mockVersions, true);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.SOLVABLE,
})
);
});
});
});
});

View file

@ -15,5 +15,6 @@ import { simpleDiffAlgorithm } from './simple_diff_algorithm';
* Diff algorithm for all kql query types (`inline_query` and `saved_query`)
*/
export const kqlQueryDiffAlgorithm = <TValue extends RuleKqlQuery>(
versions: ThreeVersionsOf<TValue>
) => simpleDiffAlgorithm<TValue>(versions);
versions: ThreeVersionsOf<TValue>,
isRuleCustomized: boolean
) => simpleDiffAlgorithm<TValue>(versions, isRuleCustomized);

View file

@ -32,7 +32,7 @@ describe('multiLineStringDiffAlgorithm', () => {
target_version: TEXT_M_A,
};
const result = multiLineStringDiffAlgorithm(mockVersions);
const result = multiLineStringDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -51,7 +51,7 @@ describe('multiLineStringDiffAlgorithm', () => {
target_version: TEXT_M_A,
};
const result = multiLineStringDiffAlgorithm(mockVersions);
const result = multiLineStringDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -70,7 +70,7 @@ describe('multiLineStringDiffAlgorithm', () => {
target_version: TEXT_M_B,
};
const result = multiLineStringDiffAlgorithm(mockVersions);
const result = multiLineStringDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -89,7 +89,7 @@ describe('multiLineStringDiffAlgorithm', () => {
target_version: TEXT_M_B,
};
const result = multiLineStringDiffAlgorithm(mockVersions);
const result = multiLineStringDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -109,7 +109,7 @@ describe('multiLineStringDiffAlgorithm', () => {
target_version: TEXT_M_C,
};
const result = multiLineStringDiffAlgorithm(mockVersions);
const result = multiLineStringDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -130,7 +130,7 @@ describe('multiLineStringDiffAlgorithm', () => {
target_version: 'My description.\nThis is a MODIFIED second line.',
};
const result = multiLineStringDiffAlgorithm(mockVersions);
const result = multiLineStringDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -151,7 +151,7 @@ describe('multiLineStringDiffAlgorithm', () => {
target_version: 'My EXCELLENT description.\nThis is a second line.',
};
const result = multiLineStringDiffAlgorithm(mockVersions);
const result = multiLineStringDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -171,7 +171,7 @@ describe('multiLineStringDiffAlgorithm', () => {
};
const startTime = performance.now();
const result = multiLineStringDiffAlgorithm(mockVersions);
const result = multiLineStringDiffAlgorithm(mockVersions, false);
const endTime = performance.now();
// If the regex merge in this function takes over 2 sec, this test fails
@ -192,46 +192,92 @@ describe('multiLineStringDiffAlgorithm', () => {
});
describe('if base_version is missing', () => {
it('returns current_version as merged output if current_version and target_version are the same - scenario -AA', () => {
const mockVersions: ThreeVersionsOf<string> = {
base_version: MissingVersion,
current_version: TEXT_M_A,
target_version: TEXT_M_A,
};
describe('if target_version as merged output if current_version and target_version are the same - scenario -AA', () => {
it('returns NONE conflict if rule is not customized', () => {
const mockVersions: ThreeVersionsOf<string> = {
base_version: MissingVersion,
current_version: TEXT_M_A,
target_version: TEXT_M_A,
};
const result = multiLineStringDiffAlgorithm(mockVersions);
const result = multiLineStringDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.current_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Current,
conflict: ThreeWayDiffConflict.NONE,
})
);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
it('returns NONE conflict if rule is customized', () => {
const mockVersions: ThreeVersionsOf<string> = {
base_version: MissingVersion,
current_version: TEXT_M_A,
target_version: TEXT_M_A,
};
const result = multiLineStringDiffAlgorithm(mockVersions, true);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
});
it('returns target_version as merged output if current_version and target_version are different - scenario -AB', () => {
const mockVersions: ThreeVersionsOf<string> = {
base_version: MissingVersion,
current_version: TEXT_M_A,
target_version: TEXT_M_B,
};
describe('returns NONE conflict if current_version and target_version are different and rule is not customized - scenario -AB', () => {
it('returns NONE conflict if rule is not customized', () => {
const mockVersions: ThreeVersionsOf<string> = {
base_version: MissingVersion,
current_version: TEXT_M_A,
target_version: TEXT_M_B,
};
const result = multiLineStringDiffAlgorithm(mockVersions);
const result = multiLineStringDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.SOLVABLE,
})
);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
it('returns SOLVABLE conflict if rule is customized', () => {
const mockVersions: ThreeVersionsOf<string> = {
base_version: MissingVersion,
current_version: TEXT_M_A,
target_version: TEXT_M_B,
};
const result = multiLineStringDiffAlgorithm(mockVersions, true);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.SOLVABLE,
})
);
});
});
});
});

View file

@ -24,7 +24,8 @@ import {
* Diff algorithm used for string fields that contain multiple lines
*/
export const multiLineStringDiffAlgorithm = (
versions: ThreeVersionsOf<string>
versions: ThreeVersionsOf<string>,
isRuleCustomized: boolean
): ThreeWayDiff<string> => {
const {
base_version: baseVersion,
@ -42,6 +43,7 @@ export const multiLineStringDiffAlgorithm = (
currentVersion,
targetVersion,
diffOutcome,
isRuleCustomized,
});
return {
@ -69,6 +71,7 @@ interface MergeArgs {
currentVersion: string;
targetVersion: string;
diffOutcome: ThreeWayDiffOutcome;
isRuleCustomized: boolean;
}
const mergeVersions = ({
@ -76,11 +79,9 @@ const mergeVersions = ({
currentVersion,
targetVersion,
diffOutcome,
isRuleCustomized,
}: MergeArgs): MergeResult => {
switch (diffOutcome) {
// Scenario -AA is treated as scenario AAA:
// https://github.com/elastic/kibana/pull/184889#discussion_r1636421293
case ThreeWayDiffOutcome.MissingBaseNoUpdate:
case ThreeWayDiffOutcome.StockValueNoUpdate:
case ThreeWayDiffOutcome.CustomizedValueNoUpdate:
case ThreeWayDiffOutcome.CustomizedValueSameUpdate:
@ -118,14 +119,27 @@ const mergeVersions = ({
};
}
// Scenario -AB is treated as scenario ABC, but marked as
// SOLVABLE, and returns the target version as the merged version
// https://github.com/elastic/kibana/pull/184889#discussion_r1636421293
case ThreeWayDiffOutcome.MissingBaseCanUpdate: {
// Missing base versions always return target version
// Scenario -AA is treated as AAA
// https://github.com/elastic/kibana/issues/210358#issuecomment-2654492854
case ThreeWayDiffOutcome.MissingBaseNoUpdate: {
return {
conflict: ThreeWayDiffConflict.NONE,
mergedVersion: targetVersion,
mergeOutcome: ThreeWayMergeOutcome.Target,
};
}
// Missing base versions always return target version
// If the rule is customized, we return a SOLVABLE conflict
// Since multi-line string fields are mergeable, we would typically return a merged value
// as per https://github.com/elastic/kibana/pull/211862, but with no base version we cannot
// complete a full diff merge and so just return the target version
case ThreeWayDiffOutcome.MissingBaseCanUpdate: {
return {
conflict: isRuleCustomized ? ThreeWayDiffConflict.SOLVABLE : ThreeWayDiffConflict.NONE,
mergedVersion: targetVersion,
mergeOutcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.SOLVABLE,
};
}
default:

View file

@ -22,7 +22,7 @@ describe('numberDiffAlgorithm', () => {
target_version: 1,
};
const result = numberDiffAlgorithm(mockVersions);
const result = numberDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -41,7 +41,7 @@ describe('numberDiffAlgorithm', () => {
target_version: 1,
};
const result = numberDiffAlgorithm(mockVersions);
const result = numberDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -60,7 +60,7 @@ describe('numberDiffAlgorithm', () => {
target_version: 2,
};
const result = numberDiffAlgorithm(mockVersions);
const result = numberDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -79,7 +79,7 @@ describe('numberDiffAlgorithm', () => {
target_version: 2,
};
const result = numberDiffAlgorithm(mockVersions);
const result = numberDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -98,7 +98,7 @@ describe('numberDiffAlgorithm', () => {
target_version: 3,
};
const result = numberDiffAlgorithm(mockVersions);
const result = numberDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -111,46 +111,92 @@ describe('numberDiffAlgorithm', () => {
});
describe('if base_version is missing', () => {
it('returns current_version as merged output if current_version and target_version are the same - scenario -AA', () => {
const mockVersions: ThreeVersionsOf<number> = {
base_version: MissingVersion,
current_version: 1,
target_version: 1,
};
describe('if current_version and target_version are the same - scenario -AA', () => {
it('returns NONE conflict if rule is NOT customized', () => {
const mockVersions: ThreeVersionsOf<number> = {
base_version: MissingVersion,
current_version: 1,
target_version: 1,
};
const result = numberDiffAlgorithm(mockVersions);
const result = numberDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.current_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Current,
conflict: ThreeWayDiffConflict.NONE,
})
);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
it('returns NONE conflict if rule is customized', () => {
const mockVersions: ThreeVersionsOf<number> = {
base_version: MissingVersion,
current_version: 1,
target_version: 1,
};
const result = numberDiffAlgorithm(mockVersions, true);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
});
it('returns target_version as merged output if current_version and target_version are different - scenario -AB', () => {
const mockVersions: ThreeVersionsOf<number> = {
base_version: MissingVersion,
current_version: 1,
target_version: 2,
};
describe('if current_version and target_version are different - scenario -AB', () => {
it('returns NONE conflict if rule is NOT customized', () => {
const mockVersions: ThreeVersionsOf<number> = {
base_version: MissingVersion,
current_version: 1,
target_version: 2,
};
const result = numberDiffAlgorithm(mockVersions);
const result = numberDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.SOLVABLE,
})
);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
it('returns SOLVABLE conflict if rule is customized', () => {
const mockVersions: ThreeVersionsOf<number> = {
base_version: MissingVersion,
current_version: 1,
target_version: 2,
};
const result = numberDiffAlgorithm(mockVersions, true);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.SOLVABLE,
})
);
});
});
});
});

View file

@ -9,5 +9,6 @@ import type { ThreeVersionsOf } from '../../../../../../../../common/api/detecti
import { simpleDiffAlgorithm } from './simple_diff_algorithm';
export const numberDiffAlgorithm = <TValue extends number | undefined>(
versions: ThreeVersionsOf<TValue>
) => simpleDiffAlgorithm<TValue>(versions);
versions: ThreeVersionsOf<TValue>,
isRuleCustomized: boolean
) => simpleDiffAlgorithm<TValue>(versions, isRuleCustomized);

View file

@ -119,47 +119,51 @@ describe('ruleTypeDiffAlgorithm', () => {
});
describe('if base_version is missing', () => {
it('returns current_version as merged output if current_version and target_version are the same - scenario -AA', () => {
const mockVersions: ThreeVersionsOf<DiffableRuleTypes> = {
base_version: MissingVersion,
current_version: 'query',
target_version: 'query',
};
describe('if current_version and target_version are the same - scenario -AA', () => {
it('returns NONE conflict', () => {
const mockVersions: ThreeVersionsOf<DiffableRuleTypes> = {
base_version: MissingVersion,
current_version: 'query',
target_version: 'query',
};
const result = ruleTypeDiffAlgorithm(mockVersions);
const result = ruleTypeDiffAlgorithm(mockVersions);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
})
);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
});
it('returns target_version as merged output if current_version and target_version are different - scenario -AB', () => {
// User can change rule type field between `query` and `saved_query` in the UI, no other rule types
const mockVersions: ThreeVersionsOf<DiffableRuleTypes> = {
base_version: MissingVersion,
current_version: 'query',
target_version: 'saved_query',
};
describe('returns target_version as merged output if current_version and target_version are different - scenario -AB', () => {
it('returns NON_SOLVABLE conflict', () => {
// User can change rule type field between `query` and `saved_query` in the UI, no other rule types
const mockVersions: ThreeVersionsOf<DiffableRuleTypes> = {
base_version: MissingVersion,
current_version: 'query',
target_version: 'saved_query',
};
const result = ruleTypeDiffAlgorithm(mockVersions);
const result = ruleTypeDiffAlgorithm(mockVersions);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NON_SOLVABLE,
})
);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NON_SOLVABLE,
})
);
});
});
});
});

View file

@ -69,8 +69,9 @@ const mergeVersions = <TValue>({
diffOutcome,
}: MergeArgs<TValue>): MergeResult<TValue> => {
switch (diffOutcome) {
// Scenario -AA is treated as scenario AAA:
// https://github.com/elastic/kibana/pull/184889#discussion_r1636421293
// Missing base versions always return target version
// Scenario -AA is treated as AAA
// https://github.com/elastic/kibana/issues/210358#issuecomment-2654492854
case ThreeWayDiffOutcome.MissingBaseNoUpdate:
case ThreeWayDiffOutcome.StockValueNoUpdate:
return {
@ -83,8 +84,9 @@ const mergeVersions = <TValue>({
case ThreeWayDiffOutcome.StockValueCanUpdate:
// NOTE: This scenario is currently inaccessible via normal UI or API workflows, but the logic is covered just in case
case ThreeWayDiffOutcome.CustomizedValueCanUpdate:
// Scenario -AB is treated as scenario ABC:
// https://github.com/elastic/kibana/pull/184889#discussion_r1636421293
// Missing base versions always return target version
// We return all -AB rule type fields as NON_SOLVABLE, whether or not the rule is customized
// https://github.com/elastic/kibana/issues/210358#issuecomment-2654492854
case ThreeWayDiffOutcome.MissingBaseCanUpdate: {
return {
mergedVersion: targetVersion,

View file

@ -22,7 +22,7 @@ describe('scalarArrayDiffAlgorithm', () => {
target_version: ['one', 'two', 'three'],
};
const result = scalarArrayDiffAlgorithm(mockVersions);
const result = scalarArrayDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -41,7 +41,7 @@ describe('scalarArrayDiffAlgorithm', () => {
target_version: ['one', 'two', 'three'],
};
const result = scalarArrayDiffAlgorithm(mockVersions);
const result = scalarArrayDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -60,7 +60,7 @@ describe('scalarArrayDiffAlgorithm', () => {
target_version: ['one', 'four', 'three'],
};
const result = scalarArrayDiffAlgorithm(mockVersions);
const result = scalarArrayDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -79,7 +79,7 @@ describe('scalarArrayDiffAlgorithm', () => {
target_version: ['one', 'four', 'three'],
};
const result = scalarArrayDiffAlgorithm(mockVersions);
const result = scalarArrayDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -99,7 +99,7 @@ describe('scalarArrayDiffAlgorithm', () => {
};
const expectedMergedVersion = ['three', 'four', 'five', 'six'];
const result = scalarArrayDiffAlgorithm(mockVersions);
const result = scalarArrayDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -112,46 +112,94 @@ describe('scalarArrayDiffAlgorithm', () => {
});
describe('if base_version is missing', () => {
it('returns current_version as merged output if current_version and target_version are the same - scenario -AA', () => {
const mockVersions: ThreeVersionsOf<string[]> = {
base_version: MissingVersion,
current_version: ['one', 'two', 'three'],
target_version: ['one', 'two', 'three'],
};
describe('returns target_version as merged output if current_version and target_version are the same - scenario -AA', () => {
it('returns NONE conflict if rule is not customized', () => {
const mockVersions: ThreeVersionsOf<string[]> = {
base_version: MissingVersion,
current_version: ['one', 'two', 'three'],
target_version: ['one', 'two', 'three'],
};
const result = scalarArrayDiffAlgorithm(mockVersions);
const result = scalarArrayDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.current_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Current,
conflict: ThreeWayDiffConflict.NONE,
})
);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
it('returns NONE conflict if rule is customized', () => {
const mockVersions: ThreeVersionsOf<string[]> = {
base_version: MissingVersion,
current_version: ['one', 'two', 'three'],
target_version: ['one', 'two', 'three'],
};
const result = scalarArrayDiffAlgorithm(mockVersions, true);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
});
it('returns target_version as merged output if current_version and target_version are different - scenario -AB', () => {
const mockVersions: ThreeVersionsOf<string[]> = {
base_version: MissingVersion,
current_version: ['one', 'two', 'three'],
target_version: ['one', 'four', 'three'],
};
describe('if current_version and target_version are different - scenario -AB', () => {
it('returns target_version as merged output and NONE conflict if rule is not customized', () => {
const mockVersions: ThreeVersionsOf<string[]> = {
base_version: MissingVersion,
current_version: ['one', 'two', 'three'],
target_version: ['one', 'four', 'three'],
};
const result = scalarArrayDiffAlgorithm(mockVersions);
const result = scalarArrayDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.SOLVABLE,
})
);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
it('returns merged version of current and target as merged output if rule is customized', () => {
const mockVersions: ThreeVersionsOf<string[]> = {
base_version: MissingVersion,
current_version: ['one', 'two', 'three'],
target_version: ['one', 'four', 'three'],
};
const expectedMergedVersion = ['one', 'two', 'three', 'four'];
const result = scalarArrayDiffAlgorithm(mockVersions, true);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: expectedMergedVersion,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Merged,
conflict: ThreeWayDiffConflict.SOLVABLE,
})
);
});
});
});
@ -163,7 +211,7 @@ describe('scalarArrayDiffAlgorithm', () => {
target_version: ['three', 'one', 'two'],
};
const result = scalarArrayDiffAlgorithm(mockVersions);
const result = scalarArrayDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -184,7 +232,7 @@ describe('scalarArrayDiffAlgorithm', () => {
};
const expectedMergedVersion = ['one', 'two'];
const result = scalarArrayDiffAlgorithm(mockVersions);
const result = scalarArrayDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -204,7 +252,7 @@ describe('scalarArrayDiffAlgorithm', () => {
};
const expectedMergedVersion = ['one', 'two'];
const result = scalarArrayDiffAlgorithm(mockVersions);
const result = scalarArrayDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -224,7 +272,7 @@ describe('scalarArrayDiffAlgorithm', () => {
};
const expectedMergedVersion = ['one', 'two'];
const result = scalarArrayDiffAlgorithm(mockVersions);
const result = scalarArrayDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -244,7 +292,7 @@ describe('scalarArrayDiffAlgorithm', () => {
};
const expectedMergedVersion = ['three'];
const result = scalarArrayDiffAlgorithm(mockVersions);
const result = scalarArrayDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -265,7 +313,7 @@ describe('scalarArrayDiffAlgorithm', () => {
target_version: ['one', 'two'],
};
const result = scalarArrayDiffAlgorithm(mockVersions);
const result = scalarArrayDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -284,7 +332,7 @@ describe('scalarArrayDiffAlgorithm', () => {
target_version: ['one', 'two'],
};
const result = scalarArrayDiffAlgorithm(mockVersions);
const result = scalarArrayDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -303,7 +351,7 @@ describe('scalarArrayDiffAlgorithm', () => {
target_version: [],
};
const result = scalarArrayDiffAlgorithm(mockVersions);
const result = scalarArrayDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -322,7 +370,7 @@ describe('scalarArrayDiffAlgorithm', () => {
target_version: [],
};
const result = scalarArrayDiffAlgorithm(mockVersions);
const result = scalarArrayDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { uniq } from 'lodash';
import { union, uniq } from 'lodash';
import { assertUnreachable } from '../../../../../../../../common/utility_types';
import type {
ThreeVersionsOf,
@ -27,7 +27,8 @@ import { mergeDedupedArrays } from './helpers';
* NOTE: Diffing logic will be agnostic to array order
*/
export const scalarArrayDiffAlgorithm = <TValue>(
versions: ThreeVersionsOf<TValue[]>
versions: ThreeVersionsOf<TValue[]>,
isRuleCustomized: boolean
): ThreeWayDiff<TValue[]> => {
const {
base_version: baseVersion,
@ -45,6 +46,7 @@ export const scalarArrayDiffAlgorithm = <TValue>(
currentVersion,
targetVersion,
diffOutcome,
isRuleCustomized,
});
return {
@ -72,6 +74,7 @@ interface MergeArgs<TValue> {
currentVersion: TValue[];
targetVersion: TValue[];
diffOutcome: ThreeWayDiffOutcome;
isRuleCustomized: boolean;
}
const mergeVersions = <TValue>({
@ -79,15 +82,13 @@ const mergeVersions = <TValue>({
currentVersion,
targetVersion,
diffOutcome,
isRuleCustomized,
}: MergeArgs<TValue>): MergeResult<TValue> => {
const dedupedBaseVersion = uniq(baseVersion);
const dedupedCurrentVersion = uniq(currentVersion);
const dedupedTargetVersion = uniq(targetVersion);
switch (diffOutcome) {
// Scenario -AA is treated as scenario AAA:
// https://github.com/elastic/kibana/pull/184889#discussion_r1636421293
case ThreeWayDiffOutcome.MissingBaseNoUpdate:
case ThreeWayDiffOutcome.StockValueNoUpdate:
case ThreeWayDiffOutcome.CustomizedValueNoUpdate:
case ThreeWayDiffOutcome.CustomizedValueSameUpdate:
@ -118,16 +119,34 @@ const mergeVersions = <TValue>({
mergeOutcome: ThreeWayMergeOutcome.Merged,
};
}
// Scenario -AB is treated as scenario ABC, but marked as
// SOLVABLE, and returns the target version as the merged version
// https://github.com/elastic/kibana/pull/184889#discussion_r1636421293
case ThreeWayDiffOutcome.MissingBaseCanUpdate: {
// Missing base versions always return target version
// Scenario -AA is treated as AAA
// https://github.com/elastic/kibana/issues/210358#issuecomment-2654492854
case ThreeWayDiffOutcome.MissingBaseNoUpdate: {
return {
conflict: ThreeWayDiffConflict.NONE,
mergedVersion: targetVersion,
mergeOutcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.SOLVABLE,
};
}
// If the rule is customized, we return a SOLVABLE conflict with a merged outcome
// Otherwise we treat scenario -AB as AAB
// https://github.com/elastic/kibana/issues/210358#issuecomment-2654492854
case ThreeWayDiffOutcome.MissingBaseCanUpdate: {
return isRuleCustomized
? {
mergedVersion: union(dedupedCurrentVersion, dedupedTargetVersion),
mergeOutcome: ThreeWayMergeOutcome.Merged,
conflict: ThreeWayDiffConflict.SOLVABLE,
}
: {
mergedVersion: targetVersion,
mergeOutcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
};
}
default:
return assertUnreachable(diffOutcome);
}

View file

@ -25,7 +25,8 @@ import {
* Meant to be used with primitive types (strings, numbers, booleans), NOT Arrays or Objects
*/
export const simpleDiffAlgorithm = <TValue>(
versions: ThreeVersionsOf<TValue>
versions: ThreeVersionsOf<TValue>,
isRuleCustomized: boolean
): ThreeWayDiff<TValue> => {
const {
base_version: baseVersion,
@ -39,10 +40,10 @@ export const simpleDiffAlgorithm = <TValue>(
const hasBaseVersion = baseVersion !== MissingVersion;
const { mergeOutcome, conflict, mergedVersion } = mergeVersions({
hasBaseVersion,
currentVersion,
targetVersion,
diffOutcome,
isRuleCustomized,
});
return {
@ -66,22 +67,19 @@ interface MergeResult<TValue> {
}
interface MergeArgs<TValue> {
hasBaseVersion: boolean;
currentVersion: TValue;
targetVersion: TValue;
diffOutcome: ThreeWayDiffOutcome;
isRuleCustomized: boolean;
}
const mergeVersions = <TValue>({
hasBaseVersion,
currentVersion,
targetVersion,
diffOutcome,
isRuleCustomized,
}: MergeArgs<TValue>): MergeResult<TValue> => {
switch (diffOutcome) {
// Scenario -AA is treated as scenario AAA:
// https://github.com/elastic/kibana/pull/184889#discussion_r1636421293
case ThreeWayDiffOutcome.MissingBaseNoUpdate:
case ThreeWayDiffOutcome.StockValueNoUpdate:
case ThreeWayDiffOutcome.CustomizedValueNoUpdate:
case ThreeWayDiffOutcome.CustomizedValueSameUpdate:
@ -106,14 +104,26 @@ const mergeVersions = <TValue>({
};
}
// Scenario -AB is treated as scenario ABC, but marked as
// SOLVABLE, and returns the target version as the merged version
// https://github.com/elastic/kibana/pull/184889#discussion_r1636421293
case ThreeWayDiffOutcome.MissingBaseCanUpdate: {
// Missing base versions always return target version
// Scenario -AA is treated as AAA
// https://github.com/elastic/kibana/issues/210358#issuecomment-2654492854
case ThreeWayDiffOutcome.MissingBaseNoUpdate: {
return {
conflict: ThreeWayDiffConflict.NONE,
mergedVersion: targetVersion,
mergeOutcome: ThreeWayMergeOutcome.Target,
};
}
// Missing base versions always return target version
// If the rule is customized, we return a SOLVABLE conflict
// Otherwise we treat scenario -AB as AAB
// https://github.com/elastic/kibana/issues/210358#issuecomment-2654492854
case ThreeWayDiffOutcome.MissingBaseCanUpdate: {
return {
conflict: isRuleCustomized ? ThreeWayDiffConflict.SOLVABLE : ThreeWayDiffConflict.NONE,
mergedVersion: targetVersion,
mergeOutcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.SOLVABLE,
};
}
default:

View file

@ -22,7 +22,7 @@ describe('singleLineStringDiffAlgorithm', () => {
target_version: 'A',
};
const result = singleLineStringDiffAlgorithm(mockVersions);
const result = singleLineStringDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -41,7 +41,7 @@ describe('singleLineStringDiffAlgorithm', () => {
target_version: 'A',
};
const result = singleLineStringDiffAlgorithm(mockVersions);
const result = singleLineStringDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -60,7 +60,7 @@ describe('singleLineStringDiffAlgorithm', () => {
target_version: 'B',
};
const result = singleLineStringDiffAlgorithm(mockVersions);
const result = singleLineStringDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -79,7 +79,7 @@ describe('singleLineStringDiffAlgorithm', () => {
target_version: 'B',
};
const result = singleLineStringDiffAlgorithm(mockVersions);
const result = singleLineStringDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -98,7 +98,7 @@ describe('singleLineStringDiffAlgorithm', () => {
target_version: 'C',
};
const result = singleLineStringDiffAlgorithm(mockVersions);
const result = singleLineStringDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
@ -111,46 +111,92 @@ describe('singleLineStringDiffAlgorithm', () => {
});
describe('if base_version is missing', () => {
it('returns current_version as merged output if current_version and target_version are the same - scenario -AA', () => {
const mockVersions: ThreeVersionsOf<string> = {
base_version: MissingVersion,
current_version: 'A',
target_version: 'A',
};
describe('returns current_version as merged output if current_version and target_version are the same - scenario -AA', () => {
it('returns NONE conflict if rule is NOT customized', () => {
const mockVersions: ThreeVersionsOf<string> = {
base_version: MissingVersion,
current_version: 'A',
target_version: 'A',
};
const result = singleLineStringDiffAlgorithm(mockVersions);
const result = singleLineStringDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.current_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Current,
conflict: ThreeWayDiffConflict.NONE,
})
);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
it('returns NONE conflict if rule is customized', () => {
const mockVersions: ThreeVersionsOf<string> = {
base_version: MissingVersion,
current_version: 'A',
target_version: 'A',
};
const result = singleLineStringDiffAlgorithm(mockVersions, true);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseNoUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
});
it('returns target_version as merged output if current_version and target_version are different - scenario -AB', () => {
const mockVersions: ThreeVersionsOf<string> = {
base_version: MissingVersion,
current_version: 'A',
target_version: 'B',
};
describe('returns target_version as merged output if current_version and target_version are different - scenario -AB', () => {
it('returns NONE conflict if rule is NOT customized', () => {
const mockVersions: ThreeVersionsOf<string> = {
base_version: MissingVersion,
current_version: 'A',
target_version: 'B',
};
const result = singleLineStringDiffAlgorithm(mockVersions);
const result = singleLineStringDiffAlgorithm(mockVersions, false);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.SOLVABLE,
})
);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.NONE,
})
);
});
it('returns SOLVABLE conflict if rule is customized', () => {
const mockVersions: ThreeVersionsOf<string> = {
base_version: MissingVersion,
current_version: 'A',
target_version: 'B',
};
const result = singleLineStringDiffAlgorithm(mockVersions, true);
expect(result).toEqual(
expect.objectContaining({
has_base_version: false,
base_version: undefined,
merged_version: mockVersions.target_version,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.SOLVABLE,
})
);
});
});
});
});

View file

@ -9,5 +9,6 @@ import type { ThreeVersionsOf } from '../../../../../../../../common/api/detecti
import { simpleDiffAlgorithm } from './simple_diff_algorithm';
export const singleLineStringDiffAlgorithm = <TValue extends string | undefined>(
versions: ThreeVersionsOf<TValue>
) => simpleDiffAlgorithm<TValue>(versions);
versions: ThreeVersionsOf<TValue>,
isRuleCustomized: boolean
) => simpleDiffAlgorithm<TValue>(versions, isRuleCustomized);

View file

@ -60,9 +60,10 @@ const TARGET_TYPE_ERROR = `Target version can't be of different rule type`;
* three-way diffs calculated for those fields.
*/
export const calculateRuleFieldsDiff = (
ruleVersions: ThreeVersionsOf<DiffableRule>
ruleVersions: ThreeVersionsOf<DiffableRule>,
isRuleCustomized: boolean = false
): RuleFieldsDiff => {
const commonFieldsDiff = calculateCommonFieldsDiff(ruleVersions);
const commonFieldsDiff = calculateCommonFieldsDiff(ruleVersions, isRuleCustomized);
// eslint-disable-next-line @typescript-eslint/naming-convention
const { base_version, current_version, target_version } = ruleVersions;
const hasBaseVersion = base_version !== MissingVersion;
@ -78,11 +79,14 @@ export const calculateRuleFieldsDiff = (
// only for fields of a single rule type, and need to calculate it for all fields
// of all the rule types we have.
// TODO: Try to get rid of "as" casting
return calculateAllFieldsDiff({
base_version: base_version as DiffableAllFields | MissingVersion,
current_version: current_version as DiffableAllFields,
target_version: target_version as DiffableAllFields,
}) as RuleFieldsDiff;
return calculateAllFieldsDiff(
{
base_version: base_version as DiffableAllFields | MissingVersion,
current_version: current_version as DiffableAllFields,
target_version: target_version as DiffableAllFields,
},
isRuleCustomized
) as RuleFieldsDiff;
}
switch (current_version.type) {
@ -93,7 +97,10 @@ export const calculateRuleFieldsDiff = (
invariant(target_version.type === 'query', TARGET_TYPE_ERROR);
return {
...commonFieldsDiff,
...calculateCustomQueryFieldsDiff({ base_version, current_version, target_version }),
...calculateCustomQueryFieldsDiff(
{ base_version, current_version, target_version },
isRuleCustomized
),
};
}
case 'saved_query': {
@ -103,7 +110,10 @@ export const calculateRuleFieldsDiff = (
invariant(target_version.type === 'saved_query', TARGET_TYPE_ERROR);
return {
...commonFieldsDiff,
...calculateSavedQueryFieldsDiff({ base_version, current_version, target_version }),
...calculateSavedQueryFieldsDiff(
{ base_version, current_version, target_version },
isRuleCustomized
),
};
}
case 'eql': {
@ -113,7 +123,10 @@ export const calculateRuleFieldsDiff = (
invariant(target_version.type === 'eql', TARGET_TYPE_ERROR);
return {
...commonFieldsDiff,
...calculateEqlFieldsDiff({ base_version, current_version, target_version }),
...calculateEqlFieldsDiff(
{ base_version, current_version, target_version },
isRuleCustomized
),
};
}
case 'threat_match': {
@ -123,7 +136,10 @@ export const calculateRuleFieldsDiff = (
invariant(target_version.type === 'threat_match', TARGET_TYPE_ERROR);
return {
...commonFieldsDiff,
...calculateThreatMatchFieldsDiff({ base_version, current_version, target_version }),
...calculateThreatMatchFieldsDiff(
{ base_version, current_version, target_version },
isRuleCustomized
),
};
}
case 'threshold': {
@ -133,7 +149,10 @@ export const calculateRuleFieldsDiff = (
invariant(target_version.type === 'threshold', TARGET_TYPE_ERROR);
return {
...commonFieldsDiff,
...calculateThresholdFieldsDiff({ base_version, current_version, target_version }),
...calculateThresholdFieldsDiff(
{ base_version, current_version, target_version },
isRuleCustomized
),
};
}
case 'machine_learning': {
@ -143,7 +162,10 @@ export const calculateRuleFieldsDiff = (
invariant(target_version.type === 'machine_learning', TARGET_TYPE_ERROR);
return {
...commonFieldsDiff,
...calculateMachineLearningFieldsDiff({ base_version, current_version, target_version }),
...calculateMachineLearningFieldsDiff(
{ base_version, current_version, target_version },
isRuleCustomized
),
};
}
case 'new_terms': {
@ -153,7 +175,10 @@ export const calculateRuleFieldsDiff = (
invariant(target_version.type === 'new_terms', TARGET_TYPE_ERROR);
return {
...commonFieldsDiff,
...calculateNewTermsFieldsDiff({ base_version, current_version, target_version }),
...calculateNewTermsFieldsDiff(
{ base_version, current_version, target_version },
isRuleCustomized
),
};
}
case 'esql': {
@ -163,7 +188,10 @@ export const calculateRuleFieldsDiff = (
invariant(target_version.type === 'esql', TARGET_TYPE_ERROR);
return {
...commonFieldsDiff,
...calculateEsqlFieldsDiff({ base_version, current_version, target_version }),
...calculateEsqlFieldsDiff(
{ base_version, current_version, target_version },
isRuleCustomized
),
};
}
default: {
@ -173,9 +201,10 @@ export const calculateRuleFieldsDiff = (
};
const calculateCommonFieldsDiff = (
ruleVersions: ThreeVersionsOf<DiffableCommonFields>
ruleVersions: ThreeVersionsOf<DiffableCommonFields>,
isRuleCustomized: boolean
): CommonFieldsDiff => {
return calculateFieldsDiffFor(ruleVersions, commonFieldsDiffAlgorithms);
return calculateFieldsDiffFor(ruleVersions, commonFieldsDiffAlgorithms, isRuleCustomized);
};
const commonFieldsDiffAlgorithms: FieldsDiffAlgorithmsFor<DiffableCommonFields> = {
@ -209,9 +238,10 @@ const commonFieldsDiffAlgorithms: FieldsDiffAlgorithmsFor<DiffableCommonFields>
};
const calculateCustomQueryFieldsDiff = (
ruleVersions: ThreeVersionsOf<DiffableCustomQueryFields>
ruleVersions: ThreeVersionsOf<DiffableCustomQueryFields>,
isRuleCustomized: boolean
): CustomQueryFieldsDiff => {
return calculateFieldsDiffFor(ruleVersions, customQueryFieldsDiffAlgorithms);
return calculateFieldsDiffFor(ruleVersions, customQueryFieldsDiffAlgorithms, isRuleCustomized);
};
const customQueryFieldsDiffAlgorithms: FieldsDiffAlgorithmsFor<DiffableCustomQueryFields> = {
@ -222,9 +252,10 @@ const customQueryFieldsDiffAlgorithms: FieldsDiffAlgorithmsFor<DiffableCustomQue
};
const calculateSavedQueryFieldsDiff = (
ruleVersions: ThreeVersionsOf<DiffableSavedQueryFields>
ruleVersions: ThreeVersionsOf<DiffableSavedQueryFields>,
isRuleCustomized: boolean
): SavedQueryFieldsDiff => {
return calculateFieldsDiffFor(ruleVersions, savedQueryFieldsDiffAlgorithms);
return calculateFieldsDiffFor(ruleVersions, savedQueryFieldsDiffAlgorithms, isRuleCustomized);
};
const savedQueryFieldsDiffAlgorithms: FieldsDiffAlgorithmsFor<DiffableSavedQueryFields> = {
@ -235,9 +266,10 @@ const savedQueryFieldsDiffAlgorithms: FieldsDiffAlgorithmsFor<DiffableSavedQuery
};
const calculateEqlFieldsDiff = (
ruleVersions: ThreeVersionsOf<DiffableEqlFields>
ruleVersions: ThreeVersionsOf<DiffableEqlFields>,
isRuleCustomized: boolean
): EqlFieldsDiff => {
return calculateFieldsDiffFor(ruleVersions, eqlFieldsDiffAlgorithms);
return calculateFieldsDiffFor(ruleVersions, eqlFieldsDiffAlgorithms, isRuleCustomized);
};
const eqlFieldsDiffAlgorithms: FieldsDiffAlgorithmsFor<DiffableEqlFields> = {
@ -248,9 +280,10 @@ const eqlFieldsDiffAlgorithms: FieldsDiffAlgorithmsFor<DiffableEqlFields> = {
};
const calculateEsqlFieldsDiff = (
ruleVersions: ThreeVersionsOf<DiffableEsqlFields>
ruleVersions: ThreeVersionsOf<DiffableEsqlFields>,
isRuleCustomized: boolean
): EsqlFieldsDiff => {
return calculateFieldsDiffFor(ruleVersions, esqlFieldsDiffAlgorithms);
return calculateFieldsDiffFor(ruleVersions, esqlFieldsDiffAlgorithms, isRuleCustomized);
};
const esqlFieldsDiffAlgorithms: FieldsDiffAlgorithmsFor<DiffableEsqlFields> = {
@ -260,9 +293,10 @@ const esqlFieldsDiffAlgorithms: FieldsDiffAlgorithmsFor<DiffableEsqlFields> = {
};
const calculateThreatMatchFieldsDiff = (
ruleVersions: ThreeVersionsOf<DiffableThreatMatchFields>
ruleVersions: ThreeVersionsOf<DiffableThreatMatchFields>,
isRuleCustomized: boolean
): ThreatMatchFieldsDiff => {
return calculateFieldsDiffFor(ruleVersions, threatMatchFieldsDiffAlgorithms);
return calculateFieldsDiffFor(ruleVersions, threatMatchFieldsDiffAlgorithms, isRuleCustomized);
};
const threatMatchFieldsDiffAlgorithms: FieldsDiffAlgorithmsFor<DiffableThreatMatchFields> = {
@ -277,9 +311,10 @@ const threatMatchFieldsDiffAlgorithms: FieldsDiffAlgorithmsFor<DiffableThreatMat
};
const calculateThresholdFieldsDiff = (
ruleVersions: ThreeVersionsOf<DiffableThresholdFields>
ruleVersions: ThreeVersionsOf<DiffableThresholdFields>,
isRuleCustomized: boolean
): ThresholdFieldsDiff => {
return calculateFieldsDiffFor(ruleVersions, thresholdFieldsDiffAlgorithms);
return calculateFieldsDiffFor(ruleVersions, thresholdFieldsDiffAlgorithms, isRuleCustomized);
};
const thresholdFieldsDiffAlgorithms: FieldsDiffAlgorithmsFor<DiffableThresholdFields> = {
@ -291,9 +326,14 @@ const thresholdFieldsDiffAlgorithms: FieldsDiffAlgorithmsFor<DiffableThresholdFi
};
const calculateMachineLearningFieldsDiff = (
ruleVersions: ThreeVersionsOf<DiffableMachineLearningFields>
ruleVersions: ThreeVersionsOf<DiffableMachineLearningFields>,
isRuleCustomized: boolean
): MachineLearningFieldsDiff => {
return calculateFieldsDiffFor(ruleVersions, machineLearningFieldsDiffAlgorithms);
return calculateFieldsDiffFor(
ruleVersions,
machineLearningFieldsDiffAlgorithms,
isRuleCustomized
);
};
const machineLearningFieldsDiffAlgorithms: FieldsDiffAlgorithmsFor<DiffableMachineLearningFields> =
@ -305,9 +345,10 @@ const machineLearningFieldsDiffAlgorithms: FieldsDiffAlgorithmsFor<DiffableMachi
};
const calculateNewTermsFieldsDiff = (
ruleVersions: ThreeVersionsOf<DiffableNewTermsFields>
ruleVersions: ThreeVersionsOf<DiffableNewTermsFields>,
isRuleCustomized: boolean
): NewTermsFieldsDiff => {
return calculateFieldsDiffFor(ruleVersions, newTermsFieldsDiffAlgorithms);
return calculateFieldsDiffFor(ruleVersions, newTermsFieldsDiffAlgorithms, isRuleCustomized);
};
const newTermsFieldsDiffAlgorithms: FieldsDiffAlgorithmsFor<DiffableNewTermsFields> = {
@ -320,9 +361,10 @@ const newTermsFieldsDiffAlgorithms: FieldsDiffAlgorithmsFor<DiffableNewTermsFiel
};
const calculateAllFieldsDiff = (
ruleVersions: ThreeVersionsOf<DiffableAllFields>
ruleVersions: ThreeVersionsOf<DiffableAllFields>,
isRuleCustomized: boolean
): AllFieldsDiff => {
return calculateFieldsDiffFor(ruleVersions, allFieldsDiffAlgorithms);
return calculateFieldsDiffFor(ruleVersions, allFieldsDiffAlgorithms, isRuleCustomized);
};
const allFieldsDiffAlgorithms: FieldsDiffAlgorithmsFor<DiffableAllFields> = {

View file

@ -15,11 +15,12 @@ import { MissingVersion } from '../../../../../../../common/api/detection_engine
export const calculateFieldsDiffFor = <TObject extends object>(
ruleVersions: ThreeVersionsOf<TObject>,
fieldsDiffAlgorithms: FieldsDiffAlgorithmsFor<TObject>
fieldsDiffAlgorithms: FieldsDiffAlgorithmsFor<TObject>,
isRuleCustomized: boolean
): FieldsDiff<TObject> => {
const result = mapValues(fieldsDiffAlgorithms, (calculateFieldDiff, fieldName) => {
const fieldVersions = pickField(fieldName as keyof TObject, ruleVersions);
const fieldDiff = calculateFieldDiff(fieldVersions);
const fieldDiff = calculateFieldDiff(fieldVersions, isRuleCustomized);
return fieldDiff;
});

View file

@ -297,10 +297,11 @@ export function referencesField({ getService }: FtrProviderContext): void {
ruleUpgradeAssets,
diffableRuleFieldName: 'references',
expectedDiffOutcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
isMergableField: true,
expectedFieldDiffValues: {
current: ['http://url-3'],
target: ['http://url-1', 'http://url-2'],
merged: ['http://url-1', 'http://url-2'],
merged: ['http://url-3', 'http://url-1', 'http://url-2'],
},
},
getService

View file

@ -297,10 +297,11 @@ export function tagsField({ getService }: FtrProviderContext): void {
ruleUpgradeAssets,
diffableRuleFieldName: 'tags',
expectedDiffOutcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
isMergableField: true,
expectedFieldDiffValues: {
current: ['tagB'],
target: ['tagC'],
merged: ['tagC'],
merged: ['tagB', 'tagC'],
},
},
getService

View file

@ -78,6 +78,7 @@ type ExpectedDiffOutcome =
| {
expectedDiffOutcome: ThreeWayDiffOutcome.MissingBaseCanUpdate;
expectedFieldDiffValues: MissingHistoricalRuleVersionsFieldDiffValueVersions;
isMergableField?: boolean;
};
/**
@ -160,6 +161,7 @@ export function testFieldUpgradeReview(
expectMissingBaseABFieldDiff(diff, {
diffableRuleFieldName: params.diffableRuleFieldName,
valueVersions: params.expectedFieldDiffValues,
isMergableField: params.isMergableField,
});
break;
}
@ -495,6 +497,7 @@ function expectMissingBaseAAFieldDiff(
interface MissingBaseFieldAssertParams {
diffableRuleFieldName: string;
valueVersions: MissingHistoricalRuleVersionsFieldDiffValueVersions;
isMergableField?: boolean;
}
/**
@ -518,7 +521,9 @@ function expectMissingBaseABFieldDiff(
target_version: fieldAssertParams.valueVersions.target,
merged_version: fieldAssertParams.valueVersions.merged,
diff_outcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
merge_outcome: ThreeWayMergeOutcome.Target,
merge_outcome: fieldAssertParams.isMergableField
? ThreeWayMergeOutcome.Merged
: ThreeWayMergeOutcome.Target,
conflict: ThreeWayDiffConflict.SOLVABLE,
},
isUndefined

View file

@ -302,10 +302,11 @@ export function newTermsFieldsField({ getService }: FtrProviderContext): void {
ruleUpgradeAssets,
diffableRuleFieldName: 'new_terms_fields',
expectedDiffOutcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
isMergableField: true,
expectedFieldDiffValues: {
current: ['fieldB'],
target: ['fieldA', 'fieldC'],
merged: ['fieldA', 'fieldC'],
merged: ['fieldB', 'fieldA', 'fieldC'],
},
},
getService

View file

@ -302,10 +302,11 @@ export function threatIndexField({ getService }: FtrProviderContext): void {
ruleUpgradeAssets,
diffableRuleFieldName: 'threat_index',
expectedDiffOutcome: ThreeWayDiffOutcome.MissingBaseCanUpdate,
isMergableField: true,
expectedFieldDiffValues: {
current: ['indexD'],
target: ['indexB', 'indexC'],
merged: ['indexB', 'indexC'],
merged: ['indexD', 'indexB', 'indexC'],
},
},
getService

View file

@ -16,6 +16,7 @@ import {
RULES_UPDATES_TABLE,
UPGRADE_ALL_RULES_BUTTON,
UPGRADE_SELECTED_RULES_BUTTON,
getReviewSingleRuleButtonByRuleId,
getUpgradeSingleRuleButtonByRuleId,
} from '../../../../screens/alerts_detection_rules';
import { selectRulesByName } from '../../../../tasks/alerts_detection_rules';
@ -102,11 +103,11 @@ describe(
clickRuleUpdatesTab();
});
it('should disable individual upgrade buttons for all prebuilt rules with conflicts', () => {
// All buttons should be disabled because of conflicts
it('should display individual review buttons for all prebuilt rules with conflicts', () => {
// All buttons should be review buttons because of conflicts
for (const rule of [OUTDATED_RULE_1, OUTDATED_RULE_2]) {
const { rule_id: ruleId } = rule['security-rule'];
expect(cy.get(getUpgradeSingleRuleButtonByRuleId(ruleId)).should('be.disabled'));
expect(cy.get(getReviewSingleRuleButtonByRuleId(ruleId)).should('exist'));
}
});
@ -203,24 +204,24 @@ describe(
assertRulesPresentInRuleUpdatesTable([OUTDATED_RULE_3]);
});
it('should disable the upgrade button for conflicting rules while allowing upgrades of no-conflict rules', () => {
// Verify the conflicting rule's upgrade button is disabled
it('should switch to a review button for conflicting rules while allowing upgrades of no-conflict rules', () => {
// Verify the conflicting rule's upgrade button has the review label
expect(
cy
.get(getUpgradeSingleRuleButtonByRuleId(OUTDATED_RULE_1['security-rule'].rule_id))
.should('be.disabled')
.get(getReviewSingleRuleButtonByRuleId(OUTDATED_RULE_1['security-rule'].rule_id))
.should('exist')
);
// Verify non-conflicting rules' upgrade buttons are enabled
// Verify non-conflicting rules' upgrade buttons do not have the review label
expect(
cy
.get(getUpgradeSingleRuleButtonByRuleId(OUTDATED_RULE_2['security-rule'].rule_id))
.should('not.be.disabled')
.should('exist')
);
expect(
cy
.get(getUpgradeSingleRuleButtonByRuleId(OUTDATED_RULE_3['security-rule'].rule_id))
.should('not.be.disabled')
.should('exist')
);
});
@ -305,10 +306,10 @@ describe(
});
it('should disable individual upgrade button for all rules', () => {
// All buttons should be disabled because rule type changes are considered conflicts
// All buttons should be displayed as review buttons because rule type changes are considered conflicts
for (const rule of [OUTDATED_QUERY_RULE_1, OUTDATED_QUERY_RULE_2]) {
const { rule_id: ruleId } = rule['security-rule'];
expect(cy.get(getUpgradeSingleRuleButtonByRuleId(ruleId)).should('be.disabled'));
expect(cy.get(getReviewSingleRuleButtonByRuleId(ruleId)).should('exist'));
}
});

View file

@ -196,6 +196,10 @@ export const getUpgradeSingleRuleButtonByRuleId = (ruleId: string) => {
return `[data-test-subj="upgradeSinglePrebuiltRuleButton-${ruleId}"]`;
};
export const getReviewSingleRuleButtonByRuleId = (ruleId: string) => {
return `[data-test-subj="reviewSinglePrebuiltRuleButton-${ruleId}"]`;
};
export const NO_RULES_AVAILABLE_FOR_INSTALL_MESSAGE =
'[data-test-subj="noPrebuiltRulesAvailableForInstall"]';
export const NO_RULES_AVAILABLE_FOR_UPGRADE_MESSAGE =