diff --git a/ee/app/assets/javascripts/security_orchestration/components/policy_editor/constants.js b/ee/app/assets/javascripts/security_orchestration/components/policy_editor/constants.js index a97e763c0d1e4d1ab9b02a56c094105b113ded87..64b3aac3218013d6aa892bfc809472f33ef39254 100644 --- a/ee/app/assets/javascripts/security_orchestration/components/policy_editor/constants.js +++ b/ee/app/assets/javascripts/security_orchestration/components/policy_editor/constants.js @@ -118,12 +118,20 @@ export const ALL_PROTECTED_BRANCHES = { export const ANY_OPERATOR = 'ANY'; -export const MORE_THAN_OPERATOR = 'MORE_THAN'; +export const GREATER_THAN_OPERATOR = 'greater_than'; -export const NUMBER_RANGE_I18N_MAP = { - [ANY_OPERATOR]: s__('ApprovalRule|Any'), - [MORE_THAN_OPERATOR]: s__('ApprovalRule|More than'), -}; +export const LESS_THAN_OPERATOR = 'less_than'; + +export const VULNERABILITIES_ALLOWED_OPERATORS = [ + { value: ANY_OPERATOR, text: s__('ApprovalRule|Any') }, + { value: GREATER_THAN_OPERATOR, text: s__('ApprovalRule|More than') }, +]; + +export const VULNERABILITY_AGE_OPERATORS = [ + { value: ANY_OPERATOR, text: s__('ApprovalRule|Any') }, + { value: GREATER_THAN_OPERATOR, text: s__('ApprovalRule|Greater than') }, + { value: LESS_THAN_OPERATOR, text: s__('ApprovalRule|Less than') }, +]; export const SCAN_RESULT_BRANCH_TYPE_OPTIONS = (nameSpaceType = NAMESPACE_TYPES.GROUP) => [ nameSpaceType === NAMESPACE_TYPES.GROUP ? GROUP_DEFAULT_BRANCHES : PROJECT_DEFAULT_BRANCH, diff --git a/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_filter_selector.vue b/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_filter_selector.vue index 4274de622b122e943600cab4c6f6be39f52064fc..38088565d1e7d8a86971e95103fa5d8fc0550c45 100644 --- a/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_filter_selector.vue +++ b/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_filter_selector.vue @@ -34,23 +34,36 @@ export default { required: false, default: () => ({}), }, + shouldDisableFilter: { + type: Function, + required: false, + default: () => false, + }, tooltipTitle: { type: String, required: false, default: '', }, + customFilterTooltip: { + type: Function, + required: false, + default: () => null, + }, }, methods: { - filterSelected(value) { - return Boolean(this.selected[value]); + filterDisabled(value) { + return this.shouldDisableFilter(value) || Boolean(this.selected[value]); }, selectFilter(filter) { - if (this.filterSelected(filter)) { + if (this.filterDisabled(filter)) { return; } this.$emit('select', filter); }, + filterTooltip(filter) { + return this.customFilterTooltip(filter) || filter.tooltip; + }, }, }; </script> @@ -75,17 +88,17 @@ export default { <span :id="item.value" class="gl-pr-3" - :class="{ 'gl-text-gray-500': filterSelected(item.value) }" + :class="{ 'gl-text-gray-500': filterDisabled(item.value) }" > {{ item.text }} </span> <gl-badge - v-if="filterSelected(item.value)" + v-if="filterDisabled(item.value)" v-gl-tooltip.right.viewport class="gl-ml-auto" size="sm" variant="neutral" - :title="item.tooltip" + :title="filterTooltip(item)" > {{ $options.i18n.disabledLabel }} </gl-badge> diff --git a/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_result_policy/lib/from_yaml.js b/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_result_policy/lib/from_yaml.js index a090f751d52fb6e8e9c86e0628a91443c4e6734b..7baf940d379cbd7847b984c7fd6545fafbe51a9e 100644 --- a/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_result_policy/lib/from_yaml.js +++ b/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_result_policy/lib/from_yaml.js @@ -26,6 +26,7 @@ export const fromYaml = ({ manifest, validateRuleMode = false }) => { 'severity_levels', 'vulnerabilities_allowed', 'vulnerability_states', + 'vulnerability_age', ]; const actionsKeys = [ 'type', diff --git a/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_result_policy/number_range_select.vue b/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_result_policy/number_range_select.vue index 4e4bc2a2a2e9c01abab33828d7a637c4ffc0c755..33d01b01b5876e73ea6c406d5e837dc2b86484c7 100644 --- a/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_result_policy/number_range_select.vue +++ b/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_result_policy/number_range_select.vue @@ -1,7 +1,8 @@ <script> import { GlCollapsibleListbox, GlFormInput } from '@gitlab/ui'; import { s__ } from '~/locale'; -import { ANY_OPERATOR, NUMBER_RANGE_I18N_MAP } from '../constants'; +import { ANY_OPERATOR } from '../constants'; +import { enforceIntValue } from '../utils'; export default { components: { @@ -34,16 +35,10 @@ export default { }, data() { return { - operator: this.selected || this.operators[0], + operator: this.selected || this.operators[0]?.value, }; }, computed: { - listBoxItems() { - return this.operators.map((operator) => ({ - value: operator, - text: NUMBER_RANGE_I18N_MAP[operator], - })); - }, showNumberInput() { return this.operator !== ANY_OPERATOR; }, @@ -56,6 +51,9 @@ export default { this.operator = item; this.$emit('operator-change', item); }, + onValueChange(value) { + this.$emit('input', enforceIntValue(value)); + }, }, i18n: { headerText: s__('ScanResultPolicy|Choose an option'), @@ -66,7 +64,7 @@ export default { <template> <div class="gl-display-flex gl-gap-3"> <gl-collapsible-listbox - :items="listBoxItems" + :items="operators" :header-text="$options.i18n.headerText" :selected="operator" :data-testid="`${id}-operator`" @@ -85,7 +83,7 @@ export default { class="gl-w-11!" :min="0" :data-testid="`${id}-input`" - @input="$emit('input', $event)" + @input="onValueChange" /> </template> </div> diff --git a/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_result_policy/scan_filters/age_filter.vue b/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_result_policy/scan_filters/age_filter.vue new file mode 100644 index 0000000000000000000000000000000000000000..9ec6af9647444ec65df1f9fbb4fa0070c7495172 --- /dev/null +++ b/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_result_policy/scan_filters/age_filter.vue @@ -0,0 +1,92 @@ +<script> +import { GlCollapsibleListbox } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import BaseLayoutComponent from '../base_layout/base_layout_component.vue'; +import NumberRangeSelect from '../number_range_select.vue'; +import { ANY_OPERATOR, VULNERABILITY_AGE_OPERATORS } from '../../constants'; +import { AGE, AGE_DAY, AGE_INTERVALS } from './constants'; + +export default { + i18n: { + label: s__('ScanResultPolicy|Age is:'), + headerText: s__('ScanResultPolicy|Choose an option'), + }, + name: 'AgeFilter', + components: { + BaseLayoutComponent, + NumberRangeSelect, + GlCollapsibleListbox, + }, + props: { + selected: { + type: Object, + required: false, + default: () => ({}), + }, + showRemoveButton: { + type: Boolean, + required: false, + default: true, + }, + }, + computed: { + showInterval() { + return this.operator !== ANY_OPERATOR; + }, + value() { + return this.selected.value || 0; + }, + operator() { + return this.selected.operator || ANY_OPERATOR; + }, + interval() { + return this.selected.interval || AGE_DAY; + }, + }, + methods: { + remove() { + this.$emit('remove', AGE); + }, + emitChange(data) { + this.$emit('input', { + operator: this.operator, + value: this.value, + interval: this.interval, + ...data, + }); + }, + }, + VULNERABILITY_AGE_OPERATORS, + AGE_INTERVALS, +}; +</script> + +<template> + <base-layout-component + class="gl-w-full gl-bg-white" + content-class="gl-bg-white gl-rounded-base gl-p-5" + :show-label="false" + :show-remove-button="showRemoveButton" + @remove="remove" + > + <template #selector> + <label class="gl-mb-0" :title="$options.i18n.label">{{ $options.i18n.label }}</label> + <number-range-select + id="vulnerability-age-select" + :value="value" + :label="$options.i18n.headerText" + :selected="operator" + :operators="$options.VULNERABILITY_AGE_OPERATORS" + @input="emitChange({ value: $event })" + @operator-change="emitChange({ operator: $event })" + /> + <gl-collapsible-listbox + v-if="showInterval" + :selected="interval" + :header-text="$options.i18n.headerText" + :items="$options.AGE_INTERVALS" + @select="emitChange({ interval: $event })" + /> + </template> + </base-layout-component> +</template> diff --git a/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_result_policy/scan_filters/constants.js b/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_result_policy/scan_filters/constants.js index b4ba1198031d53ab77df6b0ba6d8f69f37382e2e..9338468e2356b6235cbb54e25f252c359b617d54 100644 --- a/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_result_policy/scan_filters/constants.js +++ b/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_result_policy/scan_filters/constants.js @@ -2,31 +2,51 @@ import { s__ } from '~/locale'; export const SEVERITY = 'severity'; export const STATUS = 'status'; +export const AGE = 'age'; export const UNKNOWN_LICENSE = { value: 'unknown', text: s__('ScanResultPolicy|Unknown'), }; +export const AGE_TOOLTIP_MAXIMUM_REACHED = 'maximumReached'; +export const AGE_TOOLTIP_NO_PREVIOUSLY_EXISTING_VULNERABILITY = 'noPreviouslyExistingVulnerability'; + +const AGE_TOOLTIPS = { + [AGE_TOOLTIP_MAXIMUM_REACHED]: s__('ScanResultPolicy|Only 1 age criteria is allowed'), + [AGE_TOOLTIP_NO_PREVIOUSLY_EXISTING_VULNERABILITY]: s__( + 'ScanResultPolicy|Age criteria can only be added for pre-existing vulnerabilities', + ), +}; + export const FILTERS = [ { text: s__('ScanResultPolicy|New severity'), value: SEVERITY, - tooltip: s__('ScanResultPolicy|Maximum number of severity-criteria is one'), + tooltip: s__('ScanResultPolicy|Only 1 severity is allowed'), }, { text: s__('ScanResultPolicy|New status'), value: STATUS, - tooltip: s__('ScanResultPolicy|Maximum number of status-criteria is two'), + tooltip: s__('ScanResultPolicy|Only 2 status criteria are allowed'), + }, + { + text: s__('ScanResultPolicy|New age'), + value: AGE, + tooltip: AGE_TOOLTIPS, }, ]; -export const FILTERS_STATUS_INDEX = FILTERS.findIndex(({ value }) => value === STATUS); +export const AGE_DAY = 'day'; -export const FILTER_POLICY_PROPERTY_MAP = { - [STATUS]: 'vulnerability_states', - [SEVERITY]: 'severity_levels', -}; +export const AGE_INTERVALS = [ + { value: AGE_DAY, text: s__('ApprovalRule|day(s)') }, + { value: 'week', text: s__('ApprovalRule|week(s)') }, + { value: 'month', text: s__('ApprovalRule|month(s)') }, + { value: 'year', text: s__('ApprovalRule||year(s)') }, +]; + +export const FILTERS_STATUS_INDEX = FILTERS.findIndex(({ value }) => value === STATUS); export const NEWLY_DETECTED = 'newly_detected'; export const PREVIOUSLY_EXISTING = 'previously_existing'; diff --git a/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_result_policy/security_scan_rule_builder.vue b/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_result_policy/security_scan_rule_builder.vue index 1570f38badef26cf5e07118e462d4bb8f0928e0c..547ea5c872fa269ccd5f6f90f9a95b4e11b6cfcf 100644 --- a/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_result_policy/security_scan_rule_builder.vue +++ b/ee/app/assets/javascripts/security_orchestration/components/policy_editor/scan_result_policy/security_scan_rule_builder.vue @@ -3,11 +3,17 @@ import { GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; import { REPORT_TYPES_DEFAULT, SEVERITY_LEVELS } from 'ee/security_dashboard/store/constants'; import PolicyRuleMultiSelect from '../../policy_rule_multi_select.vue'; -import { ANY_OPERATOR, MORE_THAN_OPERATOR, SCAN_RESULT_BRANCH_TYPE_OPTIONS } from '../constants'; +import { + ANY_OPERATOR, + GREATER_THAN_OPERATOR, + VULNERABILITIES_ALLOWED_OPERATORS, + SCAN_RESULT_BRANCH_TYPE_OPTIONS, +} from '../constants'; import { enforceIntValue } from '../utils'; import ScanFilterSelector from '../scan_filter_selector.vue'; import { getDefaultRule, groupSelectedVulnerabilityStates } from './lib'; import SeverityFilter from './scan_filters/severity_filter.vue'; +import AgeFilter from './scan_filters/age_filter.vue'; import StatusFilters from './scan_filters/status_filters.vue'; import BaseLayoutComponent from './base_layout/base_layout_component.vue'; import PolicyRuleBranchSelection from './policy_rule_branch_selection.vue'; @@ -17,6 +23,9 @@ import { PREVIOUSLY_EXISTING, SEVERITY, STATUS, + AGE, + AGE_TOOLTIP_NO_PREVIOUSLY_EXISTING_VULNERABILITY, + AGE_TOOLTIP_MAXIMUM_REACHED, } from './scan_filters/constants'; import NumberRangeSelect from './number_range_select.vue'; import ScanTypeSelect from './base_layout/scan_type_select.vue'; @@ -25,6 +34,7 @@ export default { FILTERS, SEVERITY, STATUS, + AGE, NEWLY_DETECTED, PREVIOUSLY_EXISTING, scanResultRuleCopy: s__( @@ -38,6 +48,7 @@ export default { ScanFilterSelector, ScanTypeSelect, SeverityFilter, + AgeFilter, StatusFilters, NumberRangeSelect, }, @@ -52,9 +63,11 @@ export default { const vulnerabilityStateGroups = groupSelectedVulnerabilityStates( this.initRule.vulnerability_states, ); + const { vulnerability_age: vulnerabilityAge, severity_levels: severityLevels } = this.initRule; const filters = { - [SEVERITY]: this.initRule.severity_levels.length ? this.initRule.severity_levels : null, + [SEVERITY]: severityLevels.length ? severityLevels : null, + [AGE]: Object.keys(vulnerabilityAge || {}).length ? vulnerabilityAge : null, [NEWLY_DETECTED]: vulnerabilityStateGroups[NEWLY_DETECTED], [PREVIOUSLY_EXISTING]: vulnerabilityStateGroups[PREVIOUSLY_EXISTING], }; @@ -65,11 +78,13 @@ export default { }; }, computed: { - severityLevelsToAdd() { - return this.initRule.severity_levels; - }, - vulnerabilityStates() { - return this.initRule.vulnerability_states; + severityLevelsToAdd: { + get() { + return this.initRule.severity_levels; + }, + set(value) { + this.triggerChanged({ severity_levels: value }); + }, }, branchTypes() { return SCAN_RESULT_BRANCH_TYPE_OPTIONS(this.namespaceType); @@ -91,12 +106,29 @@ export default { return enforceIntValue(this.initRule.vulnerabilities_allowed); }, set(value) { - this.triggerChanged({ vulnerabilities_allowed: enforceIntValue(value) }); + this.triggerChanged({ vulnerabilities_allowed: value }); + }, + }, + vulnerabilityAge: { + get() { + return this.initRule.vulnerability_age || {}; + }, + set(value) { + if (!Object.keys(value).length) { + this.removeFilterFromRule('vulnerability_age'); + } else { + this.triggerChanged({ vulnerability_age: value }); + } }, }, isSeverityFilterSelected() { return this.isFilterSelected(this.$options.SEVERITY) || this.severityLevelsToAdd.length > 0; }, + isAgeFilterSelected() { + return ( + this.isFilterSelected(this.$options.AGE) || Object.keys(this.vulnerabilityAge).length > 0 + ); + }, isStatusFilterSelected() { return ( this.isFilterSelected(this.$options.NEWLY_DETECTED) || @@ -104,7 +136,17 @@ export default { ); }, selectedVulnerabilitiesOperator() { - return this.vulnerabilitiesAllowed === 0 ? ANY_OPERATOR : MORE_THAN_OPERATOR; + return this.vulnerabilitiesAllowed === 0 ? ANY_OPERATOR : GREATER_THAN_OPERATOR; + }, + }, + watch: { + filters: { + handler(value) { + if (!value[PREVIOUSLY_EXISTING]?.length && this.isFilterSelected(AGE)) { + this.removeAgeFilter(); + } + }, + deep: true, }, }, methods: { @@ -135,6 +177,10 @@ export default { this.filters[SEVERITY] = null; this.emitSeverityFilterChanges(); }, + removeAgeFilter() { + this.filters[AGE] = null; + this.vulnerabilityAge = {}; + }, removeStatusFilter(filter) { this.filters[filter] = null; this.updateCombinedFilters(); @@ -144,11 +190,22 @@ export default { this.filters[STATUS] = this.filters[NEWLY_DETECTED] && this.filters[PREVIOUSLY_EXISTING] ? [] : null; }, + removeFilterFromRule(filter) { + const { [filter]: deletedFilter, ...otherFilters } = this.initRule; + this.$emit('changed', otherFilters); + }, handleVulnerabilitiesAllowedOperatorChange(value) { if (value === ANY_OPERATOR) { this.vulnerabilitiesAllowed = 0; } }, + handleVulnerabilityAgeChanges(ageValues) { + if (ageValues.operator === ANY_OPERATOR) { + this.vulnerabilityAge = {}; + return; + } + this.vulnerabilityAge = { ...this.vulnerabilityAge, ...ageValues }; + }, setStatus(updatedFilters) { this.filters = updatedFilters; this.emitStatusFilterChanges(); @@ -165,11 +222,29 @@ export default { const states = [...(this.filters[SEVERITY] || [])]; this.triggerChanged({ severity_levels: states }); }, + shouldDisableFilterSelector(filter) { + if (filter !== AGE) { + return false; + } + + return !this.filters[PREVIOUSLY_EXISTING]?.length; + }, + customFilterSelectorTooltip(filter) { + switch (filter.value) { + case AGE: + if (!this.filters[PREVIOUSLY_EXISTING]?.length) { + return filter.tooltip[AGE_TOOLTIP_NO_PREVIOUSLY_EXISTING_VULNERABILITY]; + } + return filter.tooltip[AGE_TOOLTIP_MAXIMUM_REACHED]; + default: + return ''; + } + }, }, REPORT_TYPES_DEFAULT_KEYS: Object.keys(REPORT_TYPES_DEFAULT), REPORT_TYPES_DEFAULT, SEVERITY_LEVELS, - VULNERABILITIES_ALLOWED_OPERATORS: [ANY_OPERATOR, MORE_THAN_OPERATOR], + VULNERABILITIES_ALLOWED_OPERATORS, i18n: { severityLevels: s__('ScanResultPolicy|severity levels'), scanners: s__('ScanResultPolicy|scanners'), @@ -243,7 +318,7 @@ export default { :selected="severityLevelsToAdd" class="gl-bg-white!" @remove="removeSeverityFilter" - @input="triggerChanged({ severity_levels: $event })" + @input="severityLevelsToAdd = $event" /> <status-filters @@ -253,10 +328,19 @@ export default { @input="setStatus" /> + <age-filter + v-if="isAgeFilterSelected" + :selected="vulnerabilityAge" + @remove="removeAgeFilter" + @input="handleVulnerabilityAgeChanges" + /> + <scan-filter-selector class="gl-bg-white! gl-w-full" :filters="$options.FILTERS" :selected="filters" + :should-disable-filter="shouldDisableFilterSelector" + :custom-filter-tooltip="customFilterSelectorTooltip" @select="selectFilter" /> </template> diff --git a/ee/spec/frontend/security_orchestration/components/policy_editor/scan_filter_selector_spec.js b/ee/spec/frontend/security_orchestration/components/policy_editor/scan_filter_selector_spec.js index f4c3782a3f79dbe13fe22d53bdd2cbf926abd0e6..570da13c6b85229efceccf97dcc2c428c23fcf6a 100644 --- a/ee/spec/frontend/security_orchestration/components/policy_editor/scan_filter_selector_spec.js +++ b/ee/spec/frontend/security_orchestration/components/policy_editor/scan_filter_selector_spec.js @@ -40,6 +40,18 @@ describe('ScanFilterSelector', () => { createComponent({ tooltipTitle }); expect(findListbox().attributes('title')).toBe(tooltipTitle); }); + + it('can render custom filter tooltip based on callback', () => { + const customFilterTooltip = () => 'Custom'; + createComponent({ filters: FILTERS, selected: { [GOOD_FILTER]: [] }, customFilterTooltip }); + expect(findDisabledBadge().attributes('title')).toEqual('Custom'); + }); + + it('can set filter disabled on callback', () => { + const shouldDisableFilter = () => true; + createComponent({ filters: FILTERS, shouldDisableFilter }); + expect(findDisabledBadge().exists()).toBe(true); + }); }); describe('when filter is unselected', () => { diff --git a/ee/spec/frontend/security_orchestration/components/policy_editor/scan_result_policy/number_range_select_spec.js b/ee/spec/frontend/security_orchestration/components/policy_editor/scan_result_policy/number_range_select_spec.js index 8fc1f440a6b8ae9649a070614d1cc9cff91ef665..b19a8a603c20e2d3f3846fe429a75051b6d3be01 100644 --- a/ee/spec/frontend/security_orchestration/components/policy_editor/scan_result_policy/number_range_select_spec.js +++ b/ee/spec/frontend/security_orchestration/components/policy_editor/scan_result_policy/number_range_select_spec.js @@ -3,7 +3,8 @@ import { mountExtended } from 'helpers/vue_test_utils_helper'; import NumberRangeSelect from 'ee/security_orchestration/components/policy_editor/scan_result_policy/number_range_select.vue'; import { ANY_OPERATOR, - MORE_THAN_OPERATOR, + GREATER_THAN_OPERATOR, + VULNERABILITIES_ALLOWED_OPERATORS, } from 'ee/security_orchestration/components/policy_editor/constants'; describe('NumberRangeSelect', () => { @@ -13,7 +14,7 @@ describe('NumberRangeSelect', () => { id: 'test-dropdown', value: 0, label: 'Test dropdown', - operators: [ANY_OPERATOR, MORE_THAN_OPERATOR], + operators: VULNERABILITIES_ALLOWED_OPERATORS, }; const createComponent = (propsData = {}) => { @@ -30,9 +31,9 @@ describe('NumberRangeSelect', () => { describe('initial rendering', () => { it.each` - selected | inputExists - ${ANY_OPERATOR} | ${false} - ${MORE_THAN_OPERATOR} | ${true} + selected | inputExists + ${ANY_OPERATOR} | ${false} + ${GREATER_THAN_OPERATOR} | ${true} `('renders input based on operator with', ({ selected, inputExists }) => { createComponent({ selected }); @@ -51,17 +52,17 @@ describe('NumberRangeSelect', () => { .props('items') .map(({ value }) => value); - expect(itemValues).toEqual([ANY_OPERATOR, MORE_THAN_OPERATOR]); + expect(itemValues).toEqual([ANY_OPERATOR, GREATER_THAN_OPERATOR]); }); it('can renders only the required operators', () => { - createComponent({ operators: [MORE_THAN_OPERATOR] }); + createComponent({ operators: [{ value: GREATER_THAN_OPERATOR, text: 'greater than' }] }); const itemValues = findOperator() .props('items') .map(({ value }) => value); - expect(itemValues).toEqual([MORE_THAN_OPERATOR]); + expect(itemValues).toEqual([GREATER_THAN_OPERATOR]); }); }); @@ -71,22 +72,22 @@ describe('NumberRangeSelect', () => { expect(wrapper.emitted('operator-change')).toBeUndefined(); - findOperator().vm.$emit('select', MORE_THAN_OPERATOR); + findOperator().vm.$emit('select', GREATER_THAN_OPERATOR); - expect(wrapper.emitted('operator-change')).toEqual([[MORE_THAN_OPERATOR]]); + expect(wrapper.emitted('operator-change')).toEqual([[GREATER_THAN_OPERATOR]]); }); - it('shows the number input when changing to MORE_THAN_OPERATOR', async () => { + it('shows the number input when changing to GREATER_THAN_OPERATOR', async () => { createComponent({ selected: ANY_OPERATOR }); - await findOperator().vm.$emit('select', MORE_THAN_OPERATOR); + await findOperator().vm.$emit('select', GREATER_THAN_OPERATOR); expect(findInput().exists()).toBe(true); expect(findInput().element.value).toEqual('0'); }); it('hides the number input when changing to ANY_OPERATOR', async () => { - createComponent({ selected: MORE_THAN_OPERATOR, value: 2 }); + createComponent({ selected: GREATER_THAN_OPERATOR, value: 2 }); await findOperator().vm.$emit('select', ANY_OPERATOR); @@ -95,10 +96,10 @@ describe('NumberRangeSelect', () => { }); it('emits underlying input changes', async () => { - createComponent({ selected: MORE_THAN_OPERATOR, value: 2 }); + createComponent({ selected: GREATER_THAN_OPERATOR, value: 2 }); await findInput().vm.$emit('input', '3'); - expect(wrapper.emitted('input')).toEqual([['3']]); + expect(wrapper.emitted('input')).toEqual([[3]]); }); }); diff --git a/ee/spec/frontend/security_orchestration/components/policy_editor/scan_result_policy/scan_filters/age_filter_spec.js b/ee/spec/frontend/security_orchestration/components/policy_editor/scan_result_policy/scan_filters/age_filter_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..23fd826e6f547d6cc867338aaa6fa46ab30ebe9e --- /dev/null +++ b/ee/spec/frontend/security_orchestration/components/policy_editor/scan_result_policy/scan_filters/age_filter_spec.js @@ -0,0 +1,99 @@ +import { GlCollapsibleListbox } from '@gitlab/ui'; +import BaseLayoutComponent from 'ee/security_orchestration/components/policy_editor/scan_result_policy/base_layout/base_layout_component.vue'; +import AgeFilter from 'ee/security_orchestration/components/policy_editor/scan_result_policy/scan_filters/age_filter.vue'; +import NumberRangeSelect from 'ee/security_orchestration/components/policy_editor/scan_result_policy/number_range_select.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { + ANY_OPERATOR, + GREATER_THAN_OPERATOR, + LESS_THAN_OPERATOR, +} from 'ee/security_orchestration/components/policy_editor/constants'; +import { + AGE, + AGE_INTERVALS, +} from 'ee/security_orchestration/components/policy_editor/scan_result_policy/scan_filters/constants'; + +describe('AgeFilter', () => { + let wrapper; + + const [{ value: intervalDay }, { value: intervalWeek }] = AGE_INTERVALS; + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(AgeFilter, { + propsData: { + ...props, + }, + stubs: { + BaseLayoutComponent, + GlCollapsibleListbox, + }, + }); + }; + + const findBaseLayoutComponent = () => wrapper.findComponent(BaseLayoutComponent); + const findNumberRangeSelect = () => wrapper.findComponent(NumberRangeSelect); + const findIntervalSelect = () => wrapper.findComponent(GlCollapsibleListbox); + + it('renders operator dropdown', () => { + createComponent(); + + expect(findNumberRangeSelect().exists()).toBe(true); + }); + + it('renders initially as ANY_OPERATOR', () => { + createComponent(); + + expect(findNumberRangeSelect().props('selected')).toEqual(ANY_OPERATOR); + }); + + it.each([GREATER_THAN_OPERATOR, LESS_THAN_OPERATOR])( + 'renders the interval listbox for %s operator', + (operator) => { + createComponent({ selected: { operator } }); + + expect(findIntervalSelect().exists()).toEqual(true); + }, + ); + + it('emits change when setting an operator', async () => { + createComponent(); + + await findNumberRangeSelect().vm.$emit('operator-change', GREATER_THAN_OPERATOR); + + expect(wrapper.emitted('input')).toEqual([ + [{ interval: intervalDay, operator: GREATER_THAN_OPERATOR, value: 0 }], + ]); + }); + + it('emits change when setting a value', async () => { + createComponent({ selected: { operator: GREATER_THAN_OPERATOR } }); + + await findNumberRangeSelect().vm.$emit('input', 2); + + expect(wrapper.emitted('input')).toEqual([ + [{ interval: intervalDay, operator: GREATER_THAN_OPERATOR, value: 2 }], + ]); + }); + + it('emits change when setting an interval', async () => { + createComponent({ selected: { operator: GREATER_THAN_OPERATOR } }); + + await findIntervalSelect().vm.$emit('select', intervalWeek); + + expect(wrapper.emitted('input')).toEqual([ + [{ interval: intervalWeek, operator: GREATER_THAN_OPERATOR, value: 0 }], + ]); + }); + + describe('remove', () => { + it('should emit remove event', async () => { + createComponent({ + selected: { operator: GREATER_THAN_OPERATOR, value: 1, interval: intervalDay }, + }); + + await findBaseLayoutComponent().vm.$emit('remove'); + + expect(wrapper.emitted('remove')).toEqual([[AGE]]); + }); + }); +}); diff --git a/ee/spec/frontend/security_orchestration/components/policy_editor/scan_result_policy/security_scan_rule_builder_spec.js b/ee/spec/frontend/security_orchestration/components/policy_editor/scan_result_policy/security_scan_rule_builder_spec.js index 3c75d31c261684340fa1fdc586070ba8e5c0591e..041205a944877e8981cb938742aa6eb5e3315899 100644 --- a/ee/spec/frontend/security_orchestration/components/policy_editor/scan_result_policy/security_scan_rule_builder_spec.js +++ b/ee/spec/frontend/security_orchestration/components/policy_editor/scan_result_policy/security_scan_rule_builder_spec.js @@ -1,4 +1,5 @@ import { nextTick } from 'vue'; +import { GlBadge } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import Api from 'ee/api'; import SecurityScanRuleBuilder from 'ee/security_orchestration/components/policy_editor/scan_result_policy/security_scan_rule_builder.vue'; @@ -6,6 +7,7 @@ import PolicyRuleMultiSelect from 'ee/security_orchestration/components/policy_r import PolicyRuleBranchSelection from 'ee/security_orchestration/components/policy_editor/scan_result_policy/policy_rule_branch_selection.vue'; import SeverityFilter from 'ee/security_orchestration/components/policy_editor/scan_result_policy/scan_filters/severity_filter.vue'; import StatusFilter from 'ee/security_orchestration/components/policy_editor/scan_result_policy/scan_filters/status_filter.vue'; +import AgeFilter from 'ee/security_orchestration/components/policy_editor/scan_result_policy/scan_filters/age_filter.vue'; import ScanTypeSelect from 'ee/security_orchestration/components/policy_editor/scan_result_policy/base_layout/scan_type_select.vue'; import ScanFilterSelector from 'ee/security_orchestration/components/policy_editor/scan_filter_selector.vue'; import { NAMESPACE_TYPES } from 'ee/security_orchestration/constants'; @@ -19,10 +21,13 @@ import { STATUS, NEWLY_DETECTED, PREVIOUSLY_EXISTING, + AGE, + AGE_DAY, } from 'ee/security_orchestration/components/policy_editor/scan_result_policy/scan_filters/constants'; import { ANY_OPERATOR, - MORE_THAN_OPERATOR, + GREATER_THAN_OPERATOR, + LESS_THAN_OPERATOR, } from 'ee/security_orchestration/components/policy_editor/constants'; describe('SecurityScanRuleBuilder', () => { @@ -36,7 +41,8 @@ describe('SecurityScanRuleBuilder', () => { scanners: ['dast'], vulnerabilities_allowed: 1, severity_levels: ['high'], - vulnerability_states: ['newly_detected'], + vulnerability_states: ['detected'], + vulnerability_age: { interval: AGE_DAY, value: 1, operator: LESS_THAN_OPERATOR }, }; const factory = (propsData = {}, provide = {}) => { @@ -69,6 +75,8 @@ describe('SecurityScanRuleBuilder', () => { const findAllStatusFilters = () => wrapper.findAllComponents(StatusFilter); const findSeverityFilter = () => wrapper.findComponent(SeverityFilter); const findScanTypeSelect = () => wrapper.findComponent(ScanTypeSelect); + const findAgeFilter = () => wrapper.findComponent(AgeFilter); + const findScanFilterSelectorBadge = () => findScanFilterSelector().findComponent(GlBadge); beforeEach(() => { jest @@ -119,11 +127,11 @@ describe('SecurityScanRuleBuilder', () => { }); describe('vulnerabilities allowed', () => { - it('renders MORE_THAN_OPERATOR when initial vulnerabilities_allowed are not zero', async () => { + it('renders GREATER_THAN_OPERATOR when initial vulnerabilities_allowed are not zero', async () => { factory({ initRule: { ...UPDATED_RULE, vulnerabilities_allowed: 1 } }); await nextTick(); expect(findVulnAllowed().exists()).toBe(true); - expect(findVulnAllowedOperator().props('selected')).toEqual(MORE_THAN_OPERATOR); + expect(findVulnAllowedOperator().props('selected')).toEqual(GREATER_THAN_OPERATOR); }); describe('when editing vulnerabilities allowed', () => { @@ -139,7 +147,7 @@ describe('SecurityScanRuleBuilder', () => { `( 'triggers a changed event (by $currentComponent) with the updated rule', async ({ currentComponent, newValue, expected }) => { - findVulnAllowedOperator().vm.$emit('select', MORE_THAN_OPERATOR); + findVulnAllowedOperator().vm.$emit('select', GREATER_THAN_OPERATOR); await nextTick(); currentComponent().vm.$emit('input', newValue); await nextTick(); @@ -149,7 +157,7 @@ describe('SecurityScanRuleBuilder', () => { ); it('resets vulnerabilities_allowed to 0 after changing to ANY_OPERATOR', async () => { - findVulnAllowedOperator().vm.$emit('select', MORE_THAN_OPERATOR); + findVulnAllowedOperator().vm.$emit('select', GREATER_THAN_OPERATOR); await nextTick(); findVulnAllowed().vm.$emit('input', 1); await nextTick(); @@ -165,20 +173,26 @@ describe('SecurityScanRuleBuilder', () => { }); it.each` - currentComponent | selectedFilter - ${findSeverities} | ${SEVERITY} - ${findVulnStates} | ${STATUS} - `('select different filters', async ({ currentComponent, selectedFilter }) => { - factory(); - await findScanFilterSelector().vm.$emit('select', selectedFilter); - - expect(currentComponent().exists()).toBe(true); - }); + currentComponent | selectedFilter | existingFilters | expectedExists + ${findSeverities} | ${SEVERITY} | ${{}} | ${true} + ${findVulnStates} | ${STATUS} | ${{}} | ${true} + ${findAgeFilter} | ${AGE} | ${{}} | ${false} + ${findAgeFilter} | ${AGE} | ${{ vulnerability_states: ['detected'] }} | ${true} + `( + 'select $selectedFilter filter', + async ({ currentComponent, selectedFilter, existingFilters, expectedExists }) => { + factory({ initRule: { ...securityScanBuildRule(), ...existingFilters } }); + await findScanFilterSelector().vm.$emit('select', selectedFilter); + + expect(currentComponent().exists()).toBe(expectedExists); + }, + ); it('selects the correct filters', () => { factory({ initRule: UPDATED_RULE }); expect(findScanFilterSelector().props('selected')).toEqual({ - newly_detected: ['new_needs_triage', 'new_dismissed'], + age: { operator: 'less_than', value: 1, interval: 'day' }, + previously_existing: ['detected'], severity: ['high'], status: null, }); @@ -195,17 +209,19 @@ describe('SecurityScanRuleBuilder', () => { expect(statusFilters.at(0).props('filter')).toEqual(NEWLY_DETECTED); expect(statusFilters.at(1).props('filter')).toEqual(PREVIOUSLY_EXISTING); expect(findScanFilterSelector().props('selected')).toEqual({ - newly_detected: ['new_needs_triage', 'new_dismissed'], - previously_existing: [], + age: { operator: 'less_than', value: 1, interval: 'day' }, + previously_existing: ['detected'], + newly_detected: [], severity: ['high'], status: [], }); - await statusFilters.at(1).vm.$emit('remove', PREVIOUSLY_EXISTING); + await statusFilters.at(1).vm.$emit('remove', NEWLY_DETECTED); expect(findScanFilterSelector().props('selected')).toEqual({ - newly_detected: ['new_needs_triage', 'new_dismissed'], - previously_existing: null, + age: { operator: 'less_than', value: 1, interval: 'day' }, + newly_detected: null, + previously_existing: ['detected'], severity: ['high'], status: null, }); @@ -216,28 +232,71 @@ describe('SecurityScanRuleBuilder', () => { expect(findSeverities().exists()).toBe(true); expect(findVulnStates().exists()).toBe(true); + expect(findAgeFilter().exists()).toBe(true); }); it.each` - currentComponent | selectedFilter - ${findSeverityFilter} | ${SEVERITY} - ${findStatusFilter} | ${NEWLY_DETECTED} - ${findStatusFilter} | ${PREVIOUSLY_EXISTING} - `('removes existing filters', async ({ currentComponent, selectedFilter }) => { - factory(); - await findScanFilterSelector().vm.$emit('select', selectedFilter); - expect(currentComponent().exists()).toBe(true); - - await currentComponent().vm.$emit('remove', selectedFilter); - - expect(currentComponent().exists()).toBe(false); - expect(wrapper.emitted('changed')).toHaveLength(1); + currentComponent | selectedFilter | existingFilters + ${findSeverityFilter} | ${SEVERITY} | ${{}} + ${findStatusFilter} | ${NEWLY_DETECTED} | ${{}} + ${findStatusFilter} | ${PREVIOUSLY_EXISTING} | ${{}} + ${findAgeFilter} | ${AGE} | ${{ vulnerability_states: ['detected'] }} + `( + 'removes existing $selectedFilter filter', + async ({ currentComponent, selectedFilter, existingFilters }) => { + factory({ initRule: { ...securityScanBuildRule(), ...existingFilters } }); + await findScanFilterSelector().vm.$emit('select', selectedFilter); + expect(currentComponent().exists()).toBe(true); + + await currentComponent().vm.$emit('remove', selectedFilter); + + expect(currentComponent().exists()).toBe(false); + expect(wrapper.emitted('changed')).toHaveLength(1); + }, + ); + + it('handles age filter specific behavior in combination previously existing filter', async () => { + factory({ initRule: securityScanBuildRule() }); + + expect(findScanFilterSelectorBadge().attributes('title')).toEqual( + 'Age criteria can only be added for pre-existing vulnerabilities', + ); + + await findScanFilterSelector().vm.$emit('select', PREVIOUSLY_EXISTING); + await findStatusFilter().vm.$emit('input', ['detected']); + + expect(findScanFilterSelectorBadge().exists()).toBe(false); + + await findScanFilterSelector().vm.$emit('select', AGE); + + expect(findScanFilterSelectorBadge().attributes('title')).toEqual( + 'Only 1 age criteria is allowed', + ); + + await findAgeFilter().vm.$emit('input', { + operator: GREATER_THAN_OPERATOR, + value: 1, + interval: AGE_DAY, + }); + expect(wrapper.emitted('changed')).toHaveLength(2); + + await findStatusFilter().vm.$emit('remove', PREVIOUSLY_EXISTING); + + expect(findAgeFilter().exists()).toBe(false); + expect(findStatusFilter().exists()).toBe(false); + expect(wrapper.emitted('changed')).toHaveLength(4); }); + const updatedRuleWithoutFilter = (filter) => { + const { [filter]: deletedFilter, ...rule } = UPDATED_RULE; + return rule; + }; + it.each` - currentComponent | selectedFilter | emittedPayload - ${findSeverityFilter} | ${SEVERITY} | ${{ ...UPDATED_RULE, severity_levels: [] }} - ${findStatusFilter} | ${NEWLY_DETECTED} | ${{ ...UPDATED_RULE, vulnerability_states: [] }} + currentComponent | selectedFilter | emittedPayload + ${findSeverityFilter} | ${SEVERITY} | ${{ ...UPDATED_RULE, severity_levels: [] }} + ${findStatusFilter} | ${PREVIOUSLY_EXISTING} | ${{ ...UPDATED_RULE, vulnerability_states: [] }} + ${findAgeFilter} | ${AGE} | ${updatedRuleWithoutFilter('vulnerability_age')} `( 'removes existing filters for saved policies', ({ currentComponent, selectedFilter, emittedPayload }) => { diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 9b76cc2dee43c76855a9b2bec212707b9c63a956..60d737857141d346ab11f3876df46fe1e3bc38f7 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -5863,6 +5863,9 @@ msgstr "" msgid "ApprovalRule|Examples: QA, Security." msgstr "" +msgid "ApprovalRule|Greater than" +msgstr "" + msgid "ApprovalRule|Improve your organization's code review with required approvals." msgstr "" @@ -5872,6 +5875,9 @@ msgstr "" msgid "ApprovalRule|Learn more about merge request approval rules." msgstr "" +msgid "ApprovalRule|Less than" +msgstr "" + msgid "ApprovalRule|More than" msgstr "" @@ -5917,9 +5923,21 @@ msgstr "" msgid "ApprovalRule|all groups" msgstr "" +msgid "ApprovalRule|day(s)" +msgstr "" + +msgid "ApprovalRule|month(s)" +msgstr "" + msgid "ApprovalRule|project groups" msgstr "" +msgid "ApprovalRule|week(s)" +msgstr "" + +msgid "ApprovalRule||year(s)" +msgstr "" + msgid "ApprovalSettings|Keep approvals" msgstr "" @@ -40926,6 +40944,12 @@ msgstr "" msgid "ScanResultPolicy|Add new criteria" msgstr "" +msgid "ScanResultPolicy|Age criteria can only be added for pre-existing vulnerabilities" +msgstr "" + +msgid "ScanResultPolicy|Age is:" +msgstr "" + msgid "ScanResultPolicy|Choose an option" msgstr "" @@ -40950,10 +40974,7 @@ msgstr "" msgid "ScanResultPolicy|Matching" msgstr "" -msgid "ScanResultPolicy|Maximum number of severity-criteria is one" -msgstr "" - -msgid "ScanResultPolicy|Maximum number of status-criteria is two" +msgid "ScanResultPolicy|New age" msgstr "" msgid "ScanResultPolicy|New severity" @@ -40965,6 +40986,15 @@ msgstr "" msgid "ScanResultPolicy|Newly Detected" msgstr "" +msgid "ScanResultPolicy|Only 1 age criteria is allowed" +msgstr "" + +msgid "ScanResultPolicy|Only 1 severity is allowed" +msgstr "" + +msgid "ScanResultPolicy|Only 2 status criteria are allowed" +msgstr "" + msgid "ScanResultPolicy|Pre-existing" msgstr ""