Commit 4a96f195 authored by Zamir Martins's avatar Zamir Martins Committed by Natalia Tepluhina

Add policy rule builder for scan result policy

as an increment to the main body. It will be
followed by a MR covering the policy action
builder.

EE: true
parent 632a455d
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', () => {
......
......@@ -31841,12 +31841,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