From 228798284c942b56576ec207e56e83892c59554e Mon Sep 17 00:00:00 2001 From: Zamir Martins Filho Date: Mon, 7 Feb 2022 15:50:38 -0500 Subject: [PATCH 1/4] Add main body for scan result policy rule mode to be further extended with policy rule and action builders. EE: true --- .../scan_result_policy/lib/from_yaml.js | 46 +++++- .../scan_result_policy_editor.vue | 144 ++++++++++++++++-- .../scan_result_policy/lib/from_yaml_spec.js | 128 ++++++++++++++++ .../scan_result_policy_editor_spec.js | 68 ++++++++- .../threat_monitoring/mocks/mock_data.js | 8 +- 5 files changed, 378 insertions(+), 16 deletions(-) create mode 100644 ee/spec/frontend/threat_monitoring/components/policy_editor/scan_result_policy/lib/from_yaml_spec.js diff --git a/ee/app/assets/javascripts/threat_monitoring/components/policy_editor/scan_result_policy/lib/from_yaml.js b/ee/app/assets/javascripts/threat_monitoring/components/policy_editor/scan_result_policy/lib/from_yaml.js index b93cf76c241f14..6110d71890c482 100644 --- a/ee/app/assets/javascripts/threat_monitoring/components/policy_editor/scan_result_policy/lib/from_yaml.js +++ b/ee/app/assets/javascripts/threat_monitoring/components/policy_editor/scan_result_policy/lib/from_yaml.js @@ -1,8 +1,52 @@ import { safeLoad } from 'js-yaml'; +/** + * Checks for parameters unsupported by the scan result policy "Rule Mode" + * @param {String} manifest YAML of scan result policy + * @returns {Boolean} whether the YAML is valid to be parsed into "Rule Mode" + */ +const hasUnsupportedAttribute = (manifest) => { + const primaryKeys = ['type', 'name', 'description', 'enabled', 'rules', 'actions']; + const rulesKeys = [ + 'type', + 'branches', + 'scanners', + 'vulnerabilities_allowed', + 'severity_levels', + 'vulnerability_states', + ]; + const actionsKeys = [ + 'type', + 'approvals_required', + 'user_approvers', + 'group_approvers', + 'user_approvers_ids', + 'group_approvers_ids', + ]; + + let isUnsupported = false; + const hasInvalidKey = (object, allowedValues) => { + return !Object.keys(object).every((item) => allowedValues.includes(item)); + }; + + isUnsupported = hasInvalidKey(manifest, primaryKeys); + + if (manifest?.rules && !isUnsupported) { + isUnsupported = manifest.rules.find((rule) => hasInvalidKey(rule, rulesKeys)); + } + if (manifest?.actions && !isUnsupported) { + isUnsupported = manifest.actions.find((action) => hasInvalidKey(action, actionsKeys)); + } + + return isUnsupported; +}; + /* Construct a policy object expected by the policy editor from a yaml manifest. */ export const fromYaml = (manifest) => { - return safeLoad(manifest, { json: true }); + const policy = safeLoad(manifest, { json: true }); + if (hasUnsupportedAttribute(policy)) return { error: true }; + + return policy; }; 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 811e5ccec3d699..5d8865488433bb 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 @@ -1,31 +1,54 @@ + + 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 b5eb31e20efe53..f11806712d019b 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 @@ -19,6 +19,7 @@ import { 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'; export default { @@ -49,6 +50,7 @@ export default { GlFormInput, GlFormTextarea, GlAlert, + PolicyActionBuilder, PolicyEditorLayout, DimDisableContainer, }, @@ -116,6 +118,9 @@ export default { }, }, methods: { + updateAction(actionIndex, values) { + this.policy.actions.splice(actionIndex, 1, values); + }, handleError(error) { if (error.message.toLowerCase().includes('graphql')) { this.$emit('error', GRAPHQL_ERROR_MESSAGE); @@ -264,6 +269,15 @@ export default { + + diff --git a/ee/spec/frontend/threat_monitoring/components/policy_editor/scan_result_policy/lib/actions_spec.js b/ee/spec/frontend/threat_monitoring/components/policy_editor/scan_result_policy/lib/actions_spec.js new file mode 100644 index 00000000000000..26729159fce146 --- /dev/null +++ b/ee/spec/frontend/threat_monitoring/components/policy_editor/scan_result_policy/lib/actions_spec.js @@ -0,0 +1,148 @@ +import { + groupIds, + userIds, + groupApprovers, + decomposeApprovers, +} from 'ee/threat_monitoring/components/policy_editor/scan_result_policy/lib/actions'; + +// As returned by endpoints based on API::Entities::UserBasic +const userApprover = { + id: 1, + name: null, + state: null, + username: null, + avatar_url: null, + web_url: null, +}; + +// As returned by endpoints based on API::Entities::PublicGroupDetails +const groupApprover = { + id: 2, + name: null, + full_name: null, + full_path: null, + avatar_url: null, + web_url: null, +}; + +const unknownApprover = { id: 3, name: null }; + +const allApprovers = [userApprover, groupApprover]; + +const groupedApprovers = groupApprovers(allApprovers); + +describe('groupApprovers', () => { + describe('with mixed approvers', () => { + it('returns a copy of the input values with their proper type attribute', () => { + expect(groupApprovers(allApprovers)).toStrictEqual([ + { + avatar_url: null, + id: userApprover.id, + name: null, + state: null, + type: 'user', + username: null, + web_url: null, + }, + { + avatar_url: null, + full_name: null, + full_path: null, + id: groupApprover.id, + name: null, + type: 'group', + web_url: null, + }, + ]); + }); + + it('sets types depending on whether the approver is a group or a user', () => { + const approvers = groupApprovers(allApprovers); + expect(approvers.find((approver) => approver.id === userApprover.id)).toEqual( + expect.objectContaining({ type: 'user' }), + ); + expect(approvers.find((approver) => approver.id === groupApprover.id)).toEqual( + expect.objectContaining({ type: 'group' }), + ); + }); + }); + + it('sets group as a type for group related approvers', () => { + expect(groupApprovers([groupApprover])).toStrictEqual([ + { + avatar_url: null, + full_name: null, + full_path: null, + id: groupApprover.id, + name: null, + type: 'group', + web_url: null, + }, + ]); + }); + + it('sets user as a type for user related approvers', () => { + expect(groupApprovers([userApprover])).toStrictEqual([ + { + avatar_url: null, + id: userApprover.id, + name: null, + state: null, + type: 'user', + username: null, + web_url: null, + }, + ]); + }); + + it('does not set a type if neither group or user keys are present', () => { + expect(groupApprovers([unknownApprover])).toStrictEqual([ + { id: unknownApprover.id, name: null }, + ]); + }); +}); + +describe('decomposeApprovers', () => { + it('returns a copy of approvers adding id fields for both group and users', () => { + expect(decomposeApprovers({}, groupedApprovers)).toStrictEqual({ + group_approvers_ids: [groupApprover.id], + user_approvers_ids: [userApprover.id], + }); + }); + + it('removes group_approvers and user_approvers keys only keeping the id fields', () => { + expect( + decomposeApprovers({ user_approvers: null, group_approvers: null }, groupedApprovers), + ).toStrictEqual({ + group_approvers_ids: [groupApprover.id], + user_approvers_ids: [userApprover.id], + }); + }); + + it('preserves any other keys in addition to the id fields', () => { + expect(decomposeApprovers({ existingKey: null }, groupedApprovers)).toStrictEqual({ + group_approvers_ids: [groupApprover.id], + user_approvers_ids: [userApprover.id], + existingKey: null, + }); + }); + + it('returns empty id fields if there is only unknown types', () => { + expect(decomposeApprovers({}, [unknownApprover])).toStrictEqual({ + group_approvers_ids: [], + user_approvers_ids: [], + }); + }); +}); + +describe('userIds', () => { + it('returns only approver with type set to user', () => { + expect(userIds(groupedApprovers)).toStrictEqual([userApprover.id]); + }); +}); + +describe('groupIds', () => { + it('returns only approver with type set to group', () => { + expect(groupIds(groupedApprovers)).toStrictEqual([groupApprover.id]); + }); +}); diff --git a/ee/spec/frontend/threat_monitoring/components/policy_editor/scan_result_policy/policy_action_builder_spec.js b/ee/spec/frontend/threat_monitoring/components/policy_editor/scan_result_policy/policy_action_builder_spec.js new file mode 100644 index 00000000000000..7ea45de359046b --- /dev/null +++ b/ee/spec/frontend/threat_monitoring/components/policy_editor/scan_result_policy/policy_action_builder_spec.js @@ -0,0 +1,97 @@ +import { GlFormInput, GlToken } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import PolicyActionBuilder from 'ee/threat_monitoring/components/policy_editor/scan_result_policy/policy_action_builder.vue'; + +const APPROVER_1 = { + id: 1, + name: 'name', + state: 'active', + username: 'username', + web_url: '', + avatar_url: '', +}; + +const APPROVER_2 = { + id: 2, + name: 'name2', + state: 'active', + username: 'username2', + web_url: '', + avatar_url: '', +}; + +const APPROVERS = [APPROVER_1, APPROVER_2]; + +const APPROVERS_IDS = APPROVERS.map((approver) => approver.id); + +const ACTION = { + approvals_required: 1, + user_approvers_ids: APPROVERS_IDS, +}; + +describe('PolicyActionBuilder', () => { + let wrapper; + + const factory = (propsData = {}) => { + wrapper = mount(PolicyActionBuilder, { + propsData: { + initAction: ACTION, + existingApprovers: APPROVERS, + ...propsData, + }, + provide: { + projectId: '1', + }, + }); + }; + + const findApprovalsRequiredInput = () => wrapper.findComponent(GlFormInput); + const findAllGlTokens = () => wrapper.findAllComponents(GlToken); + + it('renders approvals required form input, gl-tokens', async () => { + factory(); + await nextTick(); + + expect(findApprovalsRequiredInput().exists()).toBe(true); + expect(findAllGlTokens().length).toBe(APPROVERS.length); + }); + + it('triggers an update when changing approvals required', async () => { + factory(); + await nextTick(); + + const approvalRequestPlusOne = ACTION.approvals_required + 1; + const formInput = findApprovalsRequiredInput(); + + await formInput.vm.$emit('input', approvalRequestPlusOne); + + expect(wrapper.emitted().changed).toEqual([ + [{ approvals_required: approvalRequestPlusOne, user_approvers_ids: APPROVERS_IDS }], + ]); + }); + + it('removes one approver when triggering a gl-token', async () => { + factory(); + await nextTick(); + + const allGlTokens = findAllGlTokens(); + const glToken = allGlTokens.at(0); + const approversLengthMinusOne = APPROVERS.length - 1; + + expect(allGlTokens.length).toBe(APPROVERS.length); + + await glToken.vm.$emit('close', { ...APPROVER_1, type: 'user' }); + + expect(wrapper.emitted().changed).toEqual([ + [ + { + approvals_required: ACTION.approvals_required, + user_approvers_ids: [APPROVER_2.id], + group_approvers_ids: [], + }, + ], + ]); + expect(findAllGlTokens()).toHaveLength(approversLengthMinusOne); + }); +}); 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 eb000a656b3184..610f99bfc81f49 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 @@ -21,6 +21,7 @@ import { EDITOR_MODE_YAML, } 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'; jest.mock('~/lib/utils/url_utility', () => ({ joinPaths: jest.requireActual('~/lib/utils/url_utility').joinPaths, @@ -49,7 +50,7 @@ describe('ScanResultPolicyEditor', () => { branch: 'main', fullPath: 'path/to/existing-project', }; - const scanResultPolicyApprovers = []; + const scanResultPolicyApprovers = [{ id: 1, username: 'username', state: 'active' }]; const factory = ({ propsData = {}, provide = {} } = {}) => { wrapper = shallowMount(ScanResultPolicyEditor, { @@ -82,6 +83,8 @@ describe('ScanResultPolicyEditor', () => { const findEmptyState = () => wrapper.findComponent(GlEmptyState); const findPolicyEditorLayout = () => wrapper.findComponent(PolicyEditorLayout); + const findPolicyActionBuilder = () => wrapper.findComponent(PolicyActionBuilder); + const findAllPolicyActionBuilders = () => wrapper.findAllComponents(PolicyActionBuilder); const findAddRuleButton = () => wrapper.find('[data-testid="add-rule"]'); const findAlert = () => wrapper.findComponent(GlAlert); const findNameInput = () => wrapper.findComponent(GlFormInput); @@ -208,4 +211,27 @@ describe('ScanResultPolicyEditor', () => { expect(emptyState.props('svgPath')).toBe(policyEditorEmptyStateSvgPath); }); }); + + describe('with policy action builder', () => { + it('renders a single policy action builder', async () => { + factory(); + + await nextTick(); + + expect(findAllPolicyActionBuilders()).toHaveLength(1); + expect(findPolicyActionBuilder().props('existingApprovers')).toEqual( + scanResultPolicyApprovers, + ); + }); + + it('updates policy action when edited', async () => { + const UPDATED_ACTION = { type: 'required_approval', group_approvers_ids: [1] }; + factory(); + + await nextTick(); + await findPolicyActionBuilder().vm.$emit('changed', UPDATED_ACTION); + + expect(findPolicyActionBuilder().props('initAction')).toEqual(UPDATED_ACTION); + }); + }); }); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 44d04b63afced2..23ffb2c1bdd399 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -31699,6 +31699,12 @@ msgstr "" msgid "Saving project." msgstr "" +msgid "ScanResultPolicy|%{thenLabelStart}Then%{thenLabelEnd} Require approval from %{approvalsRequired} of the following approvers: %{approvers}" +msgstr "" + +msgid "ScanResultPolicy|add an approver" +msgstr "" + msgid "Scanner" msgstr "" -- GitLab