Commit 450f2c4f authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'add_policy_rule_builder_for_scan_result_policy' into 'master'

Add policy rule builder for scan result policy

See merge request gitlab-org/gitlab!80132
parents ab087cbf 4a96f195
export { fromYaml } from './from_yaml';
export { toYaml } from './to_yaml';
export { buildRule } from './rules';
export * from './humanize';
export const DEFAULT_SCAN_RESULT_POLICY = `type: scan_result_policy
......
/*
Construct a new rule object.
*/
export function buildRule() {
return {
type: 'scan_finding',
branches: [],
scanners: [],
vulnerabilities_allowed: 0,
severity_levels: [],
vulnerability_states: [],
};
}
<script>
import { GlSprintf, GlForm, GlButton, GlFormGroup, GlFormInput } from '@gitlab/ui';
import { s__ } from '~/locale';
import {
REPORT_TYPES_NO_CLUSTER_IMAGE,
SEVERITY_LEVELS,
} from 'ee/security_dashboard/store/constants';
import ProtectedBranchesSelector from 'ee/vue_shared/components/branches_selector/protected_branches_selector.vue';
import PolicyRuleMultiSelect from 'ee/threat_monitoring/components/policy_rule_multi_select.vue';
import { APPROVAL_VULNERABILITY_STATES } from 'ee/approvals/constants';
export default {
scanResultRuleCopy: s__(
'ScanResultPolicy|%{ifLabelStart}if%{ifLabelEnd} %{scanners} scan in an open merge request targeting the %{branches} branch(es) finds %{vulnerabilitiesAllowed} or more %{severities} vulnerabilities that are %{vulnerabilityStates}',
),
components: {
GlSprintf,
GlForm,
GlButton,
GlFormInput,
ProtectedBranchesSelector,
GlFormGroup,
PolicyRuleMultiSelect,
},
inject: ['projectId'],
props: {
initRule: {
type: Object,
required: true,
},
},
data() {
return {
reportTypesKeys: Object.keys(REPORT_TYPES_NO_CLUSTER_IMAGE),
};
},
computed: {
branchesToAdd: {
get() {
return this.initRule.branches;
},
set(value) {
const branches = value.id === null ? [] : [value.name];
this.triggerChanged({ branches });
},
},
severityLevelsToAdd: {
get() {
return this.initRule.severity_levels;
},
set(values) {
this.triggerChanged({ severity_levels: values });
},
},
scannersToAdd: {
get() {
return this.initRule.scanners;
},
set(values) {
this.triggerChanged({ scanners: values });
},
},
vulnerabilityStates: {
get() {
return this.initRule.vulnerability_states;
},
set(values) {
this.triggerChanged({ vulnerability_states: values });
},
},
vulnerabilitiesAllowed: {
get() {
return this.initRule.vulnerabilities_allowed;
},
set(value) {
this.triggerChanged({ vulnerabilities_allowed: parseInt(value, 10) });
},
},
},
methods: {
triggerChanged(value) {
this.$emit('changed', { ...this.initRule, ...value });
},
},
REPORT_TYPES_NO_CLUSTER_IMAGE,
SEVERITY_LEVELS,
APPROVAL_VULNERABILITY_STATES,
i18n: {
severityLevels: s__('ScanResultPolicy|severity levels'),
scanners: s__('ScanResultPolicy|scanners'),
vulnerabilityStates: s__('ScanResultPolicy|vulnerability states'),
},
};
</script>
<template>
<div
class="gl-bg-gray-10 gl-border-solid gl-border-1 gl-border-gray-100 gl-rounded-base px-3 pt-3 gl-relative gl-pb-4"
>
<gl-form inline @submit.prevent>
<gl-sprintf :message="$options.scanResultRuleCopy">
<template #ifLabel="{ content }">
<label for="scanners" class="text-uppercase gl-font-lg gl-mr-3">{{ content }}</label>
</template>
<template #scanners>
<policy-rule-multi-select
v-model="scannersToAdd"
class="gl-mr-3"
:item-type-name="$options.i18n.scanners"
:items="$options.REPORT_TYPES_NO_CLUSTER_IMAGE"
data-testid="scanners-select"
/>
</template>
<template #branches>
<gl-form-group class="gl-ml-3 gl-mr-3 gl-mb-3!" data-testid="branches-group">
<protected-branches-selector
v-model="branchesToAdd"
:project-id="projectId"
:selected-branches-names="branchesToAdd"
/>
</gl-form-group>
</template>
<template #vulnerabilitiesAllowed>
<gl-form-input
v-model="vulnerabilitiesAllowed"
type="number"
class="gl-w-11! gl-mr-3 gl-ml-3"
:min="0"
data-testid="vulnerabilities-allowed-input"
/>
</template>
<template #severities>
<policy-rule-multi-select
v-model="severityLevelsToAdd"
class="gl-mr-3 gl-ml-3"
:item-type-name="$options.i18n.severityLevels"
:items="$options.SEVERITY_LEVELS"
data-testid="severities-select"
/>
</template>
<template #vulnerabilityStates>
<policy-rule-multi-select
v-model="vulnerabilityStates"
class="gl-ml-3"
:item-type-name="$options.i18n.vulnerabilityStates"
:items="$options.APPROVAL_VULNERABILITY_STATES"
data-testid="vulnerability-states-select"
/>
</template>
</gl-sprintf>
</gl-form>
<gl-button
icon="remove"
category="tertiary"
class="gl-absolute gl-top-3 gl-right-3"
:aria-label="__('Remove')"
data-testid="remove-rule"
@click="$emit('remove', $event)"
/>
</div>
</template>
......@@ -20,7 +20,8 @@ import PolicyEditorLayout from '../policy_editor_layout.vue';
import { assignSecurityPolicyProject, modifyPolicy } from '../utils';
import DimDisableContainer from '../dim_disable_container.vue';
import PolicyActionBuilder from './policy_action_builder.vue';
import { DEFAULT_SCAN_RESULT_POLICY, fromYaml, toYaml } from './lib';
import PolicyRuleBuilder from './policy_rule_builder.vue';
import { DEFAULT_SCAN_RESULT_POLICY, fromYaml, toYaml, buildRule } from './lib';
export default {
SECURITY_POLICY_ACTIONS,
......@@ -51,6 +52,7 @@ export default {
GlFormTextarea,
GlAlert,
PolicyActionBuilder,
PolicyRuleBuilder,
PolicyEditorLayout,
DimDisableContainer,
},
......@@ -121,6 +123,15 @@ export default {
updateAction(actionIndex, values) {
this.policy.actions.splice(actionIndex, 1, values);
},
addRule() {
this.policy.rules.push(buildRule());
},
removeRule(ruleIndex) {
this.policy.rules.splice(ruleIndex, 1);
},
updateRule(ruleIndex, values) {
this.policy.rules.splice(ruleIndex, 1, values);
},
handleError(error) {
if (error.message.toLowerCase().includes('graphql')) {
this.$emit('error', GRAPHQL_ERROR_MESSAGE);
......@@ -254,8 +265,17 @@ export default {
<div :class="`${$options.SHARED_FOR_DISABLED} gl-p-6`"></div>
</template>
<policy-rule-builder
v-for="(rule, index) in policy.rules"
:key="index"
class="gl-mb-4"
:init-rule="rule"
@changed="updateRule(index, $event)"
@remove="removeRule(index)"
/>
<div v-if="isWithinLimit" :class="`${$options.SHARED_FOR_DISABLED} gl-p-5 gl-mb-5`">
<gl-button variant="link" data-testid="add-rule" icon="plus" disabled>
<gl-button variant="link" data-testid="add-rule" icon="plus" @click="addRule">
{{ $options.i18n.addRule }}
</gl-button>
</div>
......
import { mount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import { nextTick } from 'vue';
import Api from 'ee/api';
import PolicyRuleBuilder from 'ee/threat_monitoring/components/policy_editor/scan_result_policy/policy_rule_builder.vue';
import ProtectedBranchesSelector from 'ee/vue_shared/components/branches_selector/protected_branches_selector.vue';
describe('PolicyRuleBuilder', () => {
let wrapper;
const PROTECTED_BRANCHES_MOCK = [{ id: 1, name: 'main' }];
const DEFAULT_RULE = {
type: 'scan_finding',
branches: [PROTECTED_BRANCHES_MOCK[0].name],
scanners: [],
vulnerabilities_allowed: 0,
severity_levels: [],
vulnerability_states: [],
};
const UPDATED_RULE = {
type: 'scan_finding',
branches: [PROTECTED_BRANCHES_MOCK[0].name],
scanners: ['dast'],
vulnerabilities_allowed: 1,
severity_levels: ['high'],
vulnerability_states: ['newly_detected'],
};
const factory = (propsData = {}) => {
wrapper = mount(PolicyRuleBuilder, {
propsData: {
initRule: DEFAULT_RULE,
...propsData,
},
provide: {
projectId: '1',
},
});
};
const findBranches = () => wrapper.findComponent(ProtectedBranchesSelector);
const findScanners = () => wrapper.find('[data-testid="scanners-select"]');
const findSeverities = () => wrapper.find('[data-testid="severities-select"]');
const findVulnStates = () => wrapper.find('[data-testid="vulnerability-states-select"]');
const findVulnAllowed = () => wrapper.find('[data-testid="vulnerabilities-allowed-input"]');
const findDeleteBtn = () => wrapper.findComponent(GlButton);
beforeEach(() => {
jest
.spyOn(Api, 'projectProtectedBranches')
.mockReturnValue(Promise.resolve(PROTECTED_BRANCHES_MOCK));
});
afterEach(() => {
wrapper.destroy();
});
describe('initial rendering', () => {
it('renders one field for each attribute of the rule', async () => {
factory();
await nextTick();
expect(findBranches().exists()).toBe(true);
expect(findScanners().exists()).toBe(true);
expect(findSeverities().exists()).toBe(true);
expect(findVulnStates().exists()).toBe(true);
expect(findVulnAllowed().exists()).toBe(true);
});
it('renders the delete buttom', async () => {
factory();
await nextTick();
expect(findDeleteBtn().exists()).toBe(true);
});
});
describe('when removing the rule', () => {
it('emits remove event', async () => {
factory();
await nextTick();
await findDeleteBtn().vm.$emit('click');
expect(wrapper.emitted().remove).toHaveLength(1);
});
});
describe('when editing any attribute of the rule', () => {
it.each`
currentComponent | newValue | expected
${findBranches} | ${PROTECTED_BRANCHES_MOCK[0]} | ${{ branches: UPDATED_RULE.branches }}
${findScanners} | ${UPDATED_RULE.scanners} | ${{ scanners: UPDATED_RULE.scanners }}
${findSeverities} | ${UPDATED_RULE.severity_levels} | ${{ severity_levels: UPDATED_RULE.severity_levels }}
${findVulnStates} | ${UPDATED_RULE.vulnerability_states} | ${{ vulnerability_states: UPDATED_RULE.vulnerability_states }}
${findVulnAllowed} | ${UPDATED_RULE.vulnerabilities_allowed} | ${{ vulnerabilities_allowed: UPDATED_RULE.vulnerabilities_allowed }}
`(
'triggers a changed event (by $currentComponent) with the updated rule',
async ({ currentComponent, newValue, expected }) => {
factory();
await nextTick();
await currentComponent().vm.$emit('input', newValue);
expect(wrapper.emitted().changed).toEqual([[expect.objectContaining(expected)]]);
},
);
});
});
......@@ -22,6 +22,7 @@ import {
} from 'ee/threat_monitoring/components/policy_editor/constants';
import DimDisableContainer from 'ee/threat_monitoring/components/policy_editor/dim_disable_container.vue';
import PolicyActionBuilder from 'ee/threat_monitoring/components/policy_editor/scan_result_policy/policy_action_builder.vue';
import PolicyRuleBuilder from 'ee/threat_monitoring/components/policy_editor/scan_result_policy/policy_rule_builder.vue';
jest.mock('~/lib/utils/url_utility', () => ({
joinPaths: jest.requireActual('~/lib/utils/url_utility').joinPaths,
......@@ -92,6 +93,7 @@ describe('ScanResultPolicyEditor', () => {
const findEnableToggle = () => wrapper.findComponent(GlToggle);
const findAllDisabledComponents = () => wrapper.findAllComponents(DimDisableContainer);
const findYamlPreview = () => wrapper.find('[data-testid="yaml-preview"]');
const findAllRuleBuilders = () => wrapper.findAllComponents(PolicyRuleBuilder);
afterEach(() => {
wrapper.destroy();
......@@ -111,10 +113,11 @@ describe('ScanResultPolicyEditor', () => {
expect(findPolicyEditorLayout().attributes('yamleditorvalue')).toBe(newManifest);
});
it('disables add rule button until feature is merged', async () => {
it('displays the inital rule and add rule button', async () => {
await factory();
expect(findAddRuleButton().props('disabled')).toBe(true);
expect(findAllRuleBuilders().length).toBe(1);
expect(findAddRuleButton().exists()).toBe(true);
});
it('displays alert for invalid yaml', async () => {
......@@ -198,6 +201,60 @@ describe('ScanResultPolicyEditor', () => {
);
},
);
it('adds a new rule', async () => {
const rulesCount = 1;
factory();
await nextTick();
expect(findAllRuleBuilders().length).toBe(rulesCount);
await findAddRuleButton().vm.$emit('click');
expect(findAllRuleBuilders()).toHaveLength(rulesCount + 1);
});
it('hides add button when the limit of five rules has been reached', async () => {
const limit = 5;
factory();
await nextTick();
await findAddRuleButton().vm.$emit('click');
await findAddRuleButton().vm.$emit('click');
await findAddRuleButton().vm.$emit('click');
await findAddRuleButton().vm.$emit('click');
expect(findAllRuleBuilders()).toHaveLength(limit);
expect(findAddRuleButton().exists()).toBe(false);
});
it('updates an existing rule', async () => {
const newValue = {
type: 'scan_finding',
branches: [],
scanners: [],
vulnerabilities_allowed: 1,
severity_levels: [],
vulnerability_states: [],
};
factory();
await nextTick();
await findAllRuleBuilders().at(0).vm.$emit('changed', newValue);
expect(wrapper.vm.policy.rules[0]).toEqual(newValue);
expect(findYamlPreview().html()).toMatch('vulnerabilities_allowed: 1');
});
it('deletes the initial rule', async () => {
const initialRuleCount = 1;
factory();
await nextTick();
expect(findAllRuleBuilders()).toHaveLength(initialRuleCount);
await findAllRuleBuilders().at(0).vm.$emit('remove', 0);
expect(findAllRuleBuilders()).toHaveLength(initialRuleCount - 1);
});
});
describe('when a user is not an owner of the project', () => {
......
......@@ -31847,12 +31847,24 @@ msgstr ""
msgid "Saving project."
msgstr ""
msgid "ScanResultPolicy|%{ifLabelStart}if%{ifLabelEnd} %{scanners} scan in an open merge request targeting the %{branches} branch(es) finds %{vulnerabilitiesAllowed} or more %{severities} vulnerabilities that are %{vulnerabilityStates}"
msgstr ""
msgid "ScanResultPolicy|%{thenLabelStart}Then%{thenLabelEnd} Require approval from %{approvalsRequired} of the following approvers: %{approvers}"
msgstr ""
msgid "ScanResultPolicy|add an approver"
msgstr ""
msgid "ScanResultPolicy|scanners"
msgstr ""
msgid "ScanResultPolicy|severity levels"
msgstr ""
msgid "ScanResultPolicy|vulnerability states"
msgstr ""
msgid "Scanner"
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment