diff --git a/ee/app/assets/javascripts/threat_monitoring/components/policy_editor/scan_result_policy/lib/index.js b/ee/app/assets/javascripts/threat_monitoring/components/policy_editor/scan_result_policy/lib/index.js
index df2c462e6aa1e8fab1e44836b908a24b180cf846..a5a148d7b8749cb2d1d4b4e1c7fd33f5f2488e21 100644
--- a/ee/app/assets/javascripts/threat_monitoring/components/policy_editor/scan_result_policy/lib/index.js
+++ b/ee/app/assets/javascripts/threat_monitoring/components/policy_editor/scan_result_policy/lib/index.js
@@ -1,5 +1,6 @@
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
diff --git a/ee/app/assets/javascripts/threat_monitoring/components/policy_editor/scan_result_policy/lib/rules.js b/ee/app/assets/javascripts/threat_monitoring/components/policy_editor/scan_result_policy/lib/rules.js
new file mode 100644
index 0000000000000000000000000000000000000000..74452c3e1e7f293c46206db622add0b8e3267def
--- /dev/null
+++ b/ee/app/assets/javascripts/threat_monitoring/components/policy_editor/scan_result_policy/lib/rules.js
@@ -0,0 +1,13 @@
+/*
+ Construct a new rule object.
+*/
+export function buildRule() {
+ return {
+ type: 'scan_finding',
+ branches: [],
+ scanners: [],
+ vulnerabilities_allowed: 0,
+ severity_levels: [],
+ vulnerability_states: [],
+ };
+}
diff --git a/ee/app/assets/javascripts/threat_monitoring/components/policy_editor/scan_result_policy/policy_rule_builder.vue b/ee/app/assets/javascripts/threat_monitoring/components/policy_editor/scan_result_policy/policy_rule_builder.vue
new file mode 100644
index 0000000000000000000000000000000000000000..5f488745cb15f83d1509aa0484c7df29366e6673
--- /dev/null
+++ b/ee/app/assets/javascripts/threat_monitoring/components/policy_editor/scan_result_policy/policy_rule_builder.vue
@@ -0,0 +1,166 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ee/app/assets/javascripts/threat_monitoring/components/policy_editor/scan_result_policy/scan_result_policy_editor.vue b/ee/app/assets/javascripts/threat_monitoring/components/policy_editor/scan_result_policy/scan_result_policy_editor.vue
index f11806712d019bb26cd4c3a04463687d22f4d3c4..74f1cd9686477d9e558a675973f5734333f419b4 100644
--- a/ee/app/assets/javascripts/threat_monitoring/components/policy_editor/scan_result_policy/scan_result_policy_editor.vue
+++ b/ee/app/assets/javascripts/threat_monitoring/components/policy_editor/scan_result_policy/scan_result_policy_editor.vue
@@ -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 {
+
+
-
+
{{ $options.i18n.addRule }}
diff --git a/ee/spec/frontend/threat_monitoring/components/policy_editor/scan_result_policy/lib/policy_rule_builder_spec.js b/ee/spec/frontend/threat_monitoring/components/policy_editor/scan_result_policy/lib/policy_rule_builder_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..d2dbe771eec25fba0cc8998b692a1f324a16ff6e
--- /dev/null
+++ b/ee/spec/frontend/threat_monitoring/components/policy_editor/scan_result_policy/lib/policy_rule_builder_spec.js
@@ -0,0 +1,109 @@
+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)]]);
+ },
+ );
+ });
+});
diff --git a/ee/spec/frontend/threat_monitoring/components/policy_editor/scan_result_policy/scan_result_policy_editor_spec.js b/ee/spec/frontend/threat_monitoring/components/policy_editor/scan_result_policy/scan_result_policy_editor_spec.js
index 610f99bfc81f49bfe85bfed25e4b6fdaa1a46fb4..3fc086ab042c2c5b1beea85e5e3c50970b3e4ec2 100644
--- a/ee/spec/frontend/threat_monitoring/components/policy_editor/scan_result_policy/scan_result_policy_editor_spec.js
+++ b/ee/spec/frontend/threat_monitoring/components/policy_editor/scan_result_policy/scan_result_policy_editor_spec.js
@@ -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', () => {
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 37f8a3fd7b453994e303bb03a505f47ac5b7a7a9..8ee83196f8f8e4d73603d39e75b7a03c6c923eb8 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -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 ""