Commit 8c0e6973 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '300973-add-external-approvals-to-projects' into 'master'

Add external approvals rules to project settings

See merge request gitlab-org/gitlab!57528
parents ad446f1e 9b03a623
<script>
import { GlIcon, GlPopover } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { __ } from '~/locale';
export default {
components: {
GlIcon,
GlPopover,
},
props: {
url: {
type: String,
required: true,
},
},
computed: {
iconId() {
return uniqueId('approval-icon-');
},
containerId() {
return uniqueId('approva-icon-container-');
},
},
i18n: {
title: __('Approval Gate'),
},
};
</script>
<template>
<div :id="containerId">
<gl-icon :id="iconId" name="api" />
<gl-popover
:target="iconId"
:container="containerId"
placement="top"
:title="$options.i18n.title"
triggers="hover focus"
:content="url"
/>
</div>
</template>
<script>
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
export default {
components: {
GlDropdown,
GlDropdownItem,
},
props: {
approverTypeOptions: {
type: Array,
required: true,
},
},
data() {
return {
selected: null,
};
},
computed: {
dropdownText() {
return this.selected.text;
},
},
created() {
const [firstOption] = this.approverTypeOptions;
this.onSelect(firstOption);
},
methods: {
isSelectedType(type) {
return this.selected.type === type;
},
onSelect(option) {
this.selected = option;
this.$emit('input', option.type);
},
},
};
</script>
<template>
<gl-dropdown class="gl-w-full gl-dropdown-menu-full-width" :text="dropdownText">
<gl-dropdown-item
v-for="option in approverTypeOptions"
:key="option.type"
:is-check-item="true"
:is-checked="isSelectedType(option.type)"
@click="onSelect(option)"
>
<span>{{ option.text }}</span>
</gl-dropdown-item>
</gl-dropdown>
</template>
......@@ -3,14 +3,24 @@ import { GlSprintf } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import { n__, s__, __ } from '~/locale';
import GlModalVuex from '~/vue_shared/components/gl_modal_vuex.vue';
import { RULE_TYPE_EXTERNAL_APPROVAL } from '../constants';
const i18n = {
cancelButtonText: __('Cancel'),
primaryButtonText: __('Remove approvers'),
modalTitle: __('Remove approvers?'),
removeWarningText: s__(
'ApprovalRuleRemove|You are about to remove the %{name} approver group which has %{nMembers}.',
),
regularRule: {
primaryButtonText: __('Remove approvers'),
modalTitle: __('Remove approvers?'),
removeWarningText: s__(
'ApprovalRuleRemove|You are about to remove the %{name} approver group which has %{nMembers}.',
),
},
externalRule: {
primaryButtonText: s__('ApprovalRuleRemove|Remove approval gate'),
modalTitle: s__('ApprovalRuleRemove|Remove approval gate?'),
removeWarningText: s__(
'ApprovalRuleRemove|You are about to remove the %{name} approval gate. Approval from this service is not revoked.',
),
},
};
export default {
......@@ -28,6 +38,9 @@ export default {
...mapState('deleteModal', {
rule: 'data',
}),
isExternalApprovalRule() {
return this.rule?.ruleType === RULE_TYPE_EXTERNAL_APPROVAL;
},
membersText() {
return n__(
'ApprovalRuleRemove|%d member',
......@@ -42,24 +55,38 @@ export default {
this.rule.approvers.length,
);
},
modalTitle() {
return this.isExternalApprovalRule
? i18n.externalRule.modalTitle
: i18n.regularRule.modalTitle;
},
modalText() {
return `${i18n.removeWarningText} ${this.revokeWarningText}`;
return this.isExternalApprovalRule
? i18n.externalRule.removeWarningText
: `${i18n.regularRule.removeWarningText} ${this.revokeWarningText}`;
},
primaryButtonProps() {
const text = this.isExternalApprovalRule
? i18n.externalRule.primaryButtonText
: i18n.regularRule.primaryButtonText;
return {
text,
attributes: [{ variant: 'danger' }],
};
},
},
methods: {
...mapActions(['deleteRule']),
...mapActions(['deleteRule', 'deleteExternalApprovalRule']),
submit() {
this.deleteRule(this.rule.id);
if (this.rule.externalUrl) {
this.deleteExternalApprovalRule(this.rule.id);
} else {
this.deleteRule(this.rule.id);
}
},
},
buttonActions: {
primary: {
text: i18n.primaryButtonText,
attributes: [{ variant: 'danger' }],
},
cancel: {
text: i18n.cancelButtonText,
},
cancelButtonProps: {
text: i18n.cancelButtonText,
},
i18n,
};
......@@ -69,9 +96,9 @@ export default {
<gl-modal-vuex
modal-module="deleteModal"
:modal-id="modalId"
:title="$options.i18n.modalTitle"
:action-primary="$options.buttonActions.primary"
:action-cancel="$options.buttonActions.cancel"
:title="modalTitle"
:action-primary="primaryButtonProps"
:action-cancel="$options.cancelButtonProps"
@ok.prevent="submit"
>
<p v-if="rule">
......@@ -82,9 +109,6 @@ export default {
<template #nMembers>
<strong>{{ membersText }}</strong>
</template>
<template #revokeWarning>
{{ revokeWarningText }}
</template>
</gl-sprintf>
</p>
</gl-modal-vuex>
......
......@@ -4,8 +4,13 @@ import RuleName from 'ee/approvals/components/rule_name.vue';
import { n__, sprintf } from '~/locale';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { RULE_TYPE_ANY_APPROVER, RULE_TYPE_REGULAR } from '../../constants';
import {
RULE_TYPE_EXTERNAL_APPROVAL,
RULE_TYPE_ANY_APPROVER,
RULE_TYPE_REGULAR,
} from '../../constants';
import ApprovalGateIcon from '../approval_gate_icon.vue';
import EmptyRule from '../empty_rule.vue';
import RuleInput from '../mr_edit/rule_input.vue';
import RuleBranches from '../rule_branches.vue';
......@@ -15,6 +20,7 @@ import UnconfiguredSecurityRules from '../security_configuration/unconfigured_se
export default {
components: {
ApprovalGateIcon,
RuleControls,
Rules,
UserAvatarList,
......@@ -95,6 +101,9 @@ export default {
return canEdit && (!allowMultiRule || !rule.hasSource);
},
isExternalApprovalRule({ ruleType }) {
return ruleType === RULE_TYPE_EXTERNAL_APPROVAL;
},
},
};
</script>
......@@ -132,13 +141,14 @@ export default {
class="js-members"
:class="settings.allowMultiRule ? 'd-none d-sm-table-cell' : null"
>
<user-avatar-list :items="rule.approvers" :img-size="24" empty-text="" />
<approval-gate-icon v-if="isExternalApprovalRule(rule)" :url="rule.externalUrl" />
<user-avatar-list v-else :items="rule.approvers" :img-size="24" empty-text="" />
</td>
<td v-if="settings.allowMultiRule" class="js-branches">
<rule-branches :rule="rule" />
</td>
<td class="js-approvals-required">
<rule-input :rule="rule" />
<rule-input v-if="!isExternalApprovalRule(rule)" :rule="rule" />
</td>
<td class="text-nowrap px-2 w-0 js-controls">
<rule-controls v-if="canEdit(rule)" :rule="rule" />
......
<script>
import { groupBy, isNumber } from 'lodash';
import { mapState, mapActions } from 'vuex';
import { sprintf, __ } from '~/locale';
import { TYPE_USER, TYPE_GROUP, TYPE_HIDDEN_GROUPS } from '../constants';
import { isSafeURL } from '~/lib/utils/url_utility';
import { sprintf, __, s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
TYPE_USER,
TYPE_GROUP,
TYPE_HIDDEN_GROUPS,
RULE_TYPE_EXTERNAL_APPROVAL,
RULE_TYPE_USER_OR_GROUP_APPROVER,
} from '../constants';
import ApproverTypeSelect from './approver_type_select.vue';
import ApproversList from './approvers_list.vue';
import ApproversSelect from './approvers_select.vue';
import BranchesSelect from './branches_select.vue';
......@@ -21,7 +30,9 @@ export default {
ApproversList,
ApproversSelect,
BranchesSelect,
ApproverTypeSelect,
},
mixins: [glFeatureFlagsMixin()],
props: {
initRule: {
type: Object,
......@@ -44,6 +55,7 @@ export default {
name: this.defaultRuleName,
approvalsRequired: 1,
minApprovalsRequired: 0,
externalUrl: null,
approvers: [],
approversToAdd: [],
branches: [],
......@@ -52,6 +64,7 @@ export default {
isFallback: false,
containsHiddenGroups: false,
serverValidationErrors: [],
ruleType: null,
...this.getInitialData(),
};
......@@ -59,6 +72,17 @@ export default {
},
computed: {
...mapState(['settings']),
showApproverTypeSelect() {
return (
this.glFeatures.ffComplianceApprovalGates &&
!this.isEditing &&
!this.isMrEdit &&
!READONLY_NAMES.includes(this.name)
);
},
isExternalApprovalRule() {
return this.ruleType === RULE_TYPE_EXTERNAL_APPROVAL;
},
rule() {
// If we are creating a new rule with a suggested approval name
return this.defaultRuleName ? null : this.initRule;
......@@ -85,16 +109,32 @@ export default {
const invalidObject = {
name: this.invalidName,
approvalsRequired: this.invalidApprovalsRequired,
approvers: this.invalidApprovers,
};
if (!this.isMrEdit) {
invalidObject.branches = this.invalidBranches;
}
if (this.isExternalApprovalRule) {
invalidObject.externalUrl = this.invalidApprovalGateUrl;
} else {
invalidObject.approvers = this.invalidApprovers;
invalidObject.approvalsRequired = this.invalidApprovalsRequired;
}
return invalidObject;
},
invalidApprovalGateUrl() {
let error = '';
if (this.serverValidationErrors.includes('External url has already been taken')) {
error = __('External url has already been taken');
} else if (!this.externalUrl || !isSafeURL(this.externalUrl)) {
error = __('Please provide a valid URL');
}
return error;
},
invalidName() {
let error = '';
......@@ -175,9 +215,24 @@ export default {
protectedBranchIds: this.branches,
};
},
isEditing() {
return Boolean(this.initRule);
},
externalRuleSubmissionData() {
const { id, name, protectedBranchIds } = this.submissionData;
return {
id,
name,
protectedBranchIds,
externalUrl: this.externalUrl,
};
},
showProtectedBranch() {
return !this.isMrEdit && this.settings.allowMultiRule;
},
approvalGateLabel() {
return this.isEditing ? this.$options.i18n.approvalGate : this.$options.i18n.addApprovalGate;
},
},
watch: {
approversToAdd(value) {
......@@ -188,7 +243,15 @@ export default {
},
},
methods: {
...mapActions(['putFallbackRule', 'postRule', 'putRule', 'deleteRule', 'postRegularRule']),
...mapActions([
'putFallbackRule',
'putExternalApprovalRule',
'postExternalApprovalRule',
'postRule',
'putRule',
'deleteRule',
'postRegularRule',
]),
addSelection() {
if (!this.approversToAdd.length) {
return;
......@@ -219,9 +282,13 @@ export default {
}
submission.catch((failureResponse) => {
this.serverValidationErrors = mapServerResponseToValidationErrors(
failureResponse?.response?.data?.message || {},
);
if (this.isExternalApprovalRule) {
this.serverValidationErrors = failureResponse?.response?.data?.message || [];
} else {
this.serverValidationErrors = mapServerResponseToValidationErrors(
failureResponse?.response?.data?.message || {},
);
}
});
return submission;
......@@ -230,12 +297,14 @@ export default {
* Submit the rule, by either put-ing or post-ing.
*/
submitRule() {
if (this.isExternalApprovalRule) {
const data = this.externalRuleSubmissionData;
return data.id ? this.putExternalApprovalRule(data) : this.postExternalApprovalRule(data);
}
const data = this.submissionData;
if (!this.settings.allowMultiRule && this.settings.prefix === 'mr-edit') {
return data.id ? this.putRule(data) : this.postRegularRule(data);
}
return data.id ? this.putRule(data) : this.postRule(data);
},
/**
......@@ -248,7 +317,7 @@ export default {
* Submit as a single rule. This is determined by the settings.
*/
submitSingleRule() {
if (!this.approvers.length) {
if (!this.approvers.length && !this.isExternalApprovalRule) {
return this.submitEmptySingleRule();
}
......@@ -280,6 +349,16 @@ export default {
};
}
if (this.initRule.ruleType === RULE_TYPE_EXTERNAL_APPROVAL) {
return {
name: this.initRule.name || '',
externalUrl: this.initRule.externalUrl,
branches: this.initRule.protectedBranches?.map((x) => x.id) || [],
ruleType: this.initRule.ruleType,
approvers: [],
};
}
const { containsHiddenGroups = false, removeHiddenGroups = false } = this.initRule;
const users = this.initRule.users.map((x) => ({ ...x, type: TYPE_USER }));
......@@ -290,6 +369,7 @@ export default {
name: this.initRule.name || '',
approvalsRequired: this.initRule.approvalsRequired || 0,
minApprovalsRequired: this.initRule.minApprovalsRequired || 0,
ruleType: this.initRule.ruleType,
containsHiddenGroups,
approvers: groups
.concat(users)
......@@ -300,6 +380,14 @@ export default {
};
},
},
i18n: {
approvalGate: s__('ApprovalRule|Approvel gate'),
addApprovalGate: s__('ApprovalRule|Add approvel gate'),
},
approverTypeOptions: [
{ type: RULE_TYPE_USER_OR_GROUP_APPROVER, text: s__('ApprovalRule|Users or groups') },
{ type: RULE_TYPE_EXTERNAL_APPROVAL, text: s__('ApprovalRule|Approval service API') },
],
};
</script>
......@@ -334,7 +422,14 @@ export default {
{{ __('Apply this approval rule to any branch or a specific protected branch.') }}
</small>
</div>
<div class="form-group gl-form-group">
<div v-if="showApproverTypeSelect" class="form-group gl-form-group">
<label class="col-form-label">{{ s__('ApprovalRule|Approver Type') }}</label>
<approver-type-select
v-model="ruleType"
:approver-type-options="$options.approverTypeOptions"
/>
</div>
<div v-if="!isExternalApprovalRule" class="form-group gl-form-group">
<label class="col-form-label">{{ s__('ApprovalRule|Approvals required') }}</label>
<input
v-model.number="approvalsRequired"
......@@ -347,7 +442,7 @@ export default {
/>
<span class="invalid-feedback">{{ validation.approvalsRequired }}</span>
</div>
<div class="form-group gl-form-group">
<div v-if="!isExternalApprovalRule" class="form-group gl-form-group">
<label class="col-form-label">{{ s__('ApprovalRule|Add approvers') }}</label>
<approvers-select
v-model="approversToAdd"
......@@ -359,7 +454,22 @@ export default {
/>
<span class="invalid-feedback">{{ validation.approvers }}</span>
</div>
<div class="bordered-box overflow-auto h-12em">
<div v-if="isExternalApprovalRule" class="form-group gl-form-group">
<label class="col-form-label">{{ approvalGateLabel }}</label>
<input
v-model="externalUrl"
:class="{ 'is-invalid': validation.externalUrl }"
class="gl-form-input form-control"
name="approval_gate_url"
type="url"
data-qa-selector="external_url_field"
/>
<span class="invalid-feedback">{{ validation.externalUrl }}</span>
<small class="form-text text-gl-muted">
{{ s__('ApprovalRule|Invoke an external API as part of the approvals') }}
</small>
</div>
<div v-if="!isExternalApprovalRule" class="bordered-box overflow-auto h-12em">
<approvers-list v-model="approvers" />
</div>
</form>
......
......@@ -17,6 +17,7 @@ export const RULE_TYPE_CODE_OWNER = 'code_owner';
export const RULE_TYPE_ANY_APPROVER = 'any_approver';
export const RULE_TYPE_EXTERNAL_APPROVAL = 'external_approval';
export const RULE_NAME_ANY_APPROVER = 'All Members';
export const RULE_TYPE_USER_OR_GROUP_APPROVER = 'user_or_group';
export const VULNERABILITY_CHECK_NAME = 'Vulnerability-Check';
export const LICENSE_CHECK_NAME = 'License-Check';
......
......@@ -70,7 +70,7 @@ export const mapExternalApprovalRuleResponse = (res) => ({
});
export const mapExternalApprovalResponse = (res) => ({
rules: withDefaultEmptyRule(res.map(mapExternalApprovalRuleResponse)),
rules: res.map(mapExternalApprovalRuleResponse),
});
export const mapApprovalSettingsResponse = (res) => ({
......
......@@ -10,6 +10,10 @@ module EE
before_action :log_archive_audit_event, only: [:archive]
before_action :log_unarchive_audit_event, only: [:unarchive]
before_action only: :edit do
push_frontend_feature_flag(:ff_compliance_approval_gates, project, default_enabled: :yaml)
end
before_action only: :show do
push_frontend_feature_flag(:cve_id_request_button, project)
end
......
......@@ -5,15 +5,16 @@ RSpec.describe 'Project settings > [EE] Merge Requests', :js do
include GitlabRoutingHelper
include FeatureApprovalHelper
let(:user) { create(:user) }
let(:project) { create(:project) }
let(:group) { create(:group) }
let(:group_member) { create(:user) }
let(:non_member) { create(:user) }
let!(:config_selector) { '.js-approval-rules' }
let!(:modal_selector) { '#project-settings-approvals-create-modal' }
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:group) { create(:group) }
let_it_be(:group_member) { create(:user) }
let_it_be(:non_member) { create(:user) }
let_it_be(:config_selector) { '.js-approval-rules' }
let_it_be(:modal_selector) { '#project-settings-approvals-create-modal' }
before do
stub_licensed_features(compliance_approval_gates: true)
sign_in(user)
project.add_maintainer(user)
group.add_developer(user)
......@@ -69,8 +70,8 @@ RSpec.describe 'Project settings > [EE] Merge Requests', :js do
end
context 'with an approver group' do
let(:non_group_approver) { create(:user) }
let!(:rule) { create(:approval_project_rule, project: project, groups: [group], users: [non_group_approver]) }
let_it_be(:non_group_approver) { create(:user) }
let_it_be(:rule) { create(:approval_project_rule, project: project, groups: [group], users: [non_group_approver]) }
before do
project.add_developer(non_group_approver)
......@@ -90,6 +91,64 @@ RSpec.describe 'Project settings > [EE] Merge Requests', :js do
end
end
it 'adds an approval gate' do
visit edit_project_path(project)
open_modal(text: 'Add approval rule', expand: false)
within('.modal-content') do
find('button', text: "Users or groups").click
find('button', text: "Approval service API").click
find('[data-qa-selector="rule_name_field"]').set('My new rule')
find('[data-qa-selector="external_url_field"]').set('https://api.gitlab.com')
click_button 'Add approval rule'
end
wait_for_requests
expect(first('.js-name')).to have_content('My new rule')
end
context 'with an approval gate' do
let_it_be(:rule) { create(:external_approval_rule, project: project) }
it 'updates the approval gate' do
visit edit_project_path(project)
expect(first('.js-name')).to have_content(rule.name)
open_modal(text: 'Edit', expand: false)
within('.modal-content') do
find('[data-qa-selector="rule_name_field"]').set('Something new')
click_button 'Update approval rule'
end
wait_for_requests
expect(first('.js-name')).to have_content('Something new')
end
it 'removes the approval gate' do
visit edit_project_path(project)
expect(first('.js-name')).to have_content(rule.name)
first('.js-controls').find('[data-testid="remove-icon"]').click
within('.modal-content') do
click_button 'Remove approval gate'
end
wait_for_requests
expect(first('.js-name')).not_to have_content(rule.name)
end
end
context 'issuable default templates feature not available' do
before do
stub_licensed_features(issuable_default_templates: false)
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Approvals ModalRuleRemove shows message 1`] = `
exports[`Approvals ModalRuleRemove matches the snapshot for external approval 1`] = `
<div
title="Remove approval gate?"
>
<p>
You are about to remove the
<strong>
API Gate
</strong>
approval gate. Approval from this service is not revoked.
</p>
</div>
`;
exports[`Approvals ModalRuleRemove matches the snapshot for multiple approvers 1`] = `
<div
title="Remove approvers?"
>
......@@ -18,7 +32,7 @@ exports[`Approvals ModalRuleRemove shows message 1`] = `
</div>
`;
exports[`Approvals ModalRuleRemove shows singular message 1`] = `
exports[`Approvals ModalRuleRemove matches the snapshot for singular approver 1`] = `
<div
title="Remove approvers?"
>
......
import { GlPopover, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import ApprovalGateIcon from 'ee/approvals/components/approval_gate_icon.vue';
jest.mock('lodash/uniqueId', () => (id) => `${id}mock`);
describe('ApprovalGateIcon', () => {
let wrapper;
const findPopover = () => wrapper.findComponent(GlPopover);
const findIcon = () => wrapper.findComponent(GlIcon);
const createComponent = () => {
return shallowMount(ApprovalGateIcon, {
propsData: {
url: 'https://gitlab.com/',
},
});
};
afterEach(() => {
wrapper.destroy();
});
beforeEach(() => {
wrapper = createComponent();
});
it('renders the icon', () => {
expect(findIcon().props('name')).toBe('api');
expect(findIcon().attributes('id')).toBe('approval-icon-mock');
});
it('renders the popover with the URL for the icon', () => {
expect(findPopover().exists()).toBe(true);
expect(findPopover().attributes()).toMatchObject({
content: 'https://gitlab.com/',
title: 'Approval Gate',
target: 'approval-icon-mock',
});
});
});
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import ApprovalTypeSelect from 'ee/approvals/components/approver_type_select.vue';
jest.mock('lodash/uniqueId', () => (id) => `${id}mock`);
const OPTIONS = [
{ type: 'x', text: 'foo' },
{ type: 'y', text: 'bar' },
];
describe('ApprovalTypeSelect', () => {
let wrapper;
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownItems = () => wrapper.findAll(GlDropdownItem);
const createComponent = () => {
return shallowMount(ApprovalTypeSelect, {
propsData: {
approverTypeOptions: OPTIONS,
},
});
};
afterEach(() => {
wrapper.destroy();
});
beforeEach(() => {
wrapper = createComponent();
});
it('should select the first option by default', () => {
expect(findDropdownItems().at(0).props('isChecked')).toBe(true);
});
it('renders the dropdown with the selected text', () => {
expect(findDropdown().props('text')).toBe(OPTIONS[0].text);
});
it('renders a dropdown item for each option', () => {
OPTIONS.forEach((option, idx) => {
expect(findDropdownItems().at(idx).text()).toBe(option.text);
});
});
it('should select an item when clicked', async () => {
const item = findDropdownItems().at(1);
expect(item.props('isChecked')).toBe(false);
item.vm.$emit('click');
await nextTick();
expect(item.props('isChecked')).toBe(true);
});
});
......@@ -4,6 +4,7 @@ import Vuex from 'vuex';
import ModalRuleRemove from 'ee/approvals/components/modal_rule_remove.vue';
import { stubComponent } from 'helpers/stub_component';
import GlModalVuex from '~/vue_shared/components/gl_modal_vuex.vue';
import { createExternalRule } from '../mocks';
const MODAL_MODULE = 'deleteModal';
const TEST_MODAL_ID = 'test-delete-modal-id';
......@@ -14,6 +15,11 @@ const TEST_RULE = {
.fill(1)
.map((x, id) => ({ id })),
};
const SINGLE_APPROVER = {
...TEST_RULE,
approvers: [{ id: 1 }],
};
const EXTERNAL_RULE = createExternalRule();
const localVue = createLocalVue();
localVue.use(Vuex);
......@@ -61,6 +67,7 @@ describe('Approvals ModalRuleRemove', () => {
};
actions = {
deleteRule: jest.fn(),
deleteExternalApprovalRule: jest.fn(),
};
});
......@@ -83,30 +90,31 @@ describe('Approvals ModalRuleRemove', () => {
);
});
it('shows message', () => {
factory();
expect(findModal().element).toMatchSnapshot();
});
it('shows singular message', () => {
deleteModalState.data = {
...TEST_RULE,
approvers: [{ id: 1 }],
};
it.each`
type | rule
${'multiple approvers'} | ${TEST_RULE}
${'singular approver'} | ${SINGLE_APPROVER}
${'external approval'} | ${EXTERNAL_RULE}
`('matches the snapshot for $type', ({ rule }) => {
deleteModalState.data = rule;
factory();
expect(findModal().element).toMatchSnapshot();
});
it('deletes rule when modal is submitted', () => {
it.each`
typeType | action | rule
${'regular'} | ${'deleteRule'} | ${TEST_RULE}
${'external'} | ${'deleteExternalApprovalRule'} | ${EXTERNAL_RULE}
`('calls $action when the modal is submitted for a $typeType rule', ({ action, rule }) => {
deleteModalState.data = rule;
factory();
expect(actions.deleteRule).not.toHaveBeenCalled();
expect(actions[action]).not.toHaveBeenCalled();
const modal = findModal();
modal.vm.$emit('ok', new Event('submit'));
expect(actions.deleteRule).toHaveBeenCalledWith(expect.anything(), TEST_RULE.id);
expect(actions[action]).toHaveBeenCalledWith(expect.anything(), rule.id);
});
});
import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import ApprovalGateIcon from 'ee/approvals/components/approval_gate_icon.vue';
import RuleInput from 'ee/approvals/components/mr_edit/rule_input.vue';
import ProjectRules from 'ee/approvals/components/project_settings/project_rules.vue';
import RuleName from 'ee/approvals/components/rule_name.vue';
......@@ -8,7 +9,7 @@ import UnconfiguredSecurityRules from 'ee/approvals/components/security_configur
import { createStoreOptions } from 'ee/approvals/stores';
import projectSettingsModule from 'ee/approvals/stores/modules/project_settings';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
import { createProjectRules } from '../../mocks';
import { createProjectRules, createExternalRule } from '../../mocks';
const TEST_RULES = createProjectRules();
......@@ -149,4 +150,26 @@ describe('Approvals ProjectRules', () => {
expect(wrapper.find(UnconfiguredSecurityRules).exists()).toBe(true);
});
});
describe('when the rule is external', () => {
const rule = createExternalRule();
beforeEach(() => {
store.modules.approvals.state.rules = [rule];
factory();
});
it('renders the approval gate component with URL', () => {
expect(wrapper.findComponent(ApprovalGateIcon).props('url')).toBe(rule.externalUrl);
});
it('does not render a user avatar component', () => {
expect(wrapper.findComponent(UserAvatarList).exists()).toBe(false);
});
it('does not render the approvals required input', () => {
expect(wrapper.findComponent(RuleInput).exists()).toBe(false);
});
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue';
import Vuex from 'vuex';
import ApproverTypeSelect from 'ee/approvals/components/approver_type_select.vue';
import ApproversList from 'ee/approvals/components/approvers_list.vue';
import ApproversSelect from 'ee/approvals/components/approvers_select.vue';
import BranchesSelect from 'ee/approvals/components/branches_select.vue';
import RuleForm from 'ee/approvals/components/rule_form.vue';
import { TYPE_USER, TYPE_GROUP, TYPE_HIDDEN_GROUPS } from 'ee/approvals/constants';
import {
TYPE_USER,
TYPE_GROUP,
TYPE_HIDDEN_GROUPS,
RULE_TYPE_EXTERNAL_APPROVAL,
} from 'ee/approvals/constants';
import { createStoreOptions } from 'ee/approvals/stores';
import projectSettingsModule from 'ee/approvals/stores/modules/project_settings';
import waitForPromises from 'helpers/wait_for_promises';
import { createExternalRule } from '../mocks';
const TEST_PROJECT_ID = '7';
const TEST_RULE = {
......@@ -27,6 +36,10 @@ const TEST_FALLBACK_RULE = {
approvalsRequired: 1,
isFallback: true,
};
const TEST_EXTERNAL_APPROVAL_RULE = {
...createExternalRule(),
protectedBranches: TEST_PROTECTED_BRANCHES,
};
const TEST_LOCKED_RULE_NAME = 'LOCKED_RULE';
const nameTakenError = {
response: {
......@@ -37,6 +50,13 @@ const nameTakenError = {
},
},
};
const urlTakenError = {
response: {
data: {
message: ['External url has already been taken'],
},
},
};
const localVue = createLocalVue();
localVue.use(Vuex);
......@@ -54,7 +74,11 @@ describe('EE Approvals RuleForm', () => {
store: new Vuex.Store(store),
localVue,
provide: {
glFeatures: { scopedApprovalRules: true, ...options.provide?.glFeatures },
glFeatures: {
ffComplianceApprovalGates: true,
scopedApprovalRules: true,
...options.provide?.glFeatures,
},
},
});
};
......@@ -71,6 +95,9 @@ describe('EE Approvals RuleForm', () => {
const findApproversValidation = () => findValidation(findApproversSelect(), true);
const findApproversList = () => wrapper.find(ApproversList);
const findBranchesSelect = () => wrapper.find(BranchesSelect);
const findApproverTypeSelect = () => wrapper.findComponent(ApproverTypeSelect);
const findExternalUrlInput = () => wrapper.find('input[name=approval_gate_url');
const findExternalUrlValidation = () => findValidation(findExternalUrlInput(), false);
const findBranchesValidation = () => findValidation(findBranchesSelect(), true);
const findValidations = () => [
findNameValidation(),
......@@ -85,12 +112,20 @@ describe('EE Approvals RuleForm', () => {
findBranchesValidation(),
];
const findValidationForExternal = () => [
findNameValidation(),
findExternalUrlValidation(),
findBranchesValidation(),
];
beforeEach(() => {
store = createStoreOptions(projectSettingsModule(), { projectId: TEST_PROJECT_ID });
['postRule', 'putRule', 'deleteRule', 'putFallbackRule'].forEach((actionName) => {
jest.spyOn(store.modules.approvals.actions, actionName).mockImplementation(() => {});
});
['postRule', 'putRule', 'deleteRule', 'putFallbackRule', 'postExternalApprovalRule'].forEach(
(actionName) => {
jest.spyOn(store.modules.approvals.actions, actionName).mockImplementation(() => {});
},
);
({ actions } = store.modules.approvals);
});
......@@ -181,6 +216,119 @@ describe('EE Approvals RuleForm', () => {
});
});
describe('when the rule is an external rule', () => {
describe('with initial rule', () => {
beforeEach(() => {
createComponent({
isMrEdit: false,
initRule: TEST_EXTERNAL_APPROVAL_RULE,
});
});
it('does not render the approver type select input', () => {
expect(findApproverTypeSelect().exists()).toBe(false);
});
it('on load, it populates the external URL', () => {
expect(findExternalUrlInput().element.value).toBe(
TEST_EXTERNAL_APPROVAL_RULE.externalUrl,
);
});
});
describe('without an initial rule', () => {
beforeEach(() => {
createComponent({
isMrEdit: false,
});
findApproverTypeSelect().vm.$emit('input', RULE_TYPE_EXTERNAL_APPROVAL);
});
it('renders the approver type select input', () => {
expect(findApproverTypeSelect().exists()).toBe(true);
});
it('renders the inputs for external rules', () => {
expect(findNameInput().exists()).toBe(true);
expect(findExternalUrlInput().exists()).toBe(true);
expect(findBranchesSelect().exists()).toBe(true);
});
it('does not render the user and group input fields', () => {
expect(findApprovalsRequiredInput().exists()).toBe(false);
expect(findApproversList().exists()).toBe(false);
expect(findApproversSelect().exists()).toBe(false);
});
it('at first, shows no validation', () => {
const inputs = findValidationForExternal();
const invalidInputs = inputs.filter((x) => !x.isValid);
const feedbacks = inputs.map((x) => x.feedback);
expect(invalidInputs.length).toBe(0);
expect(feedbacks.every((str) => !str.length)).toBe(true);
});
it('on submit, does not dispatch action', () => {
wrapper.vm.submit();
expect(actions.postExternalApprovalRule).not.toHaveBeenCalled();
});
it('on submit, shows name validation', async () => {
findExternalUrlInput().setValue('');
wrapper.vm.submit();
await nextTick();
expect(findExternalUrlValidation()).toEqual({
isValid: false,
feedback: 'Please provide a valid URL',
});
});
describe('with valid data', () => {
const branches = TEST_PROTECTED_BRANCHES.map((x) => x.id);
const expected = {
id: null,
name: 'Lorem',
externalUrl: 'https://gitlab.com/',
protectedBranchIds: branches,
};
beforeEach(() => {
findNameInput().setValue(expected.name);
findExternalUrlInput().setValue(expected.externalUrl);
wrapper.vm.branches = expected.protectedBranchIds;
});
it('on submit, posts external approval rule', () => {
wrapper.vm.submit();
expect(actions.postExternalApprovalRule).toHaveBeenCalledWith(
expect.anything(),
expected,
);
});
it('when submitted with a duplicate external URL, shows the "url already taken" validation', async () => {
store.state.settings.prefix = 'project-settings';
jest.spyOn(wrapper.vm, 'postExternalApprovalRule').mockRejectedValueOnce(urlTakenError);
wrapper.vm.submit();
await waitForPromises();
expect(findExternalUrlValidation()).toEqual({
isValid: false,
feedback: 'External url has already been taken',
});
});
});
});
});
describe('without initRule', () => {
beforeEach(() => {
createComponent();
......@@ -536,16 +684,17 @@ describe('EE Approvals RuleForm', () => {
describe('with approval suggestions', () => {
describe.each`
defaultRuleName | expectedDisabledAttribute
${'Vulnerability-Check'} | ${'disabled'}
${'License-Check'} | ${'disabled'}
${'Foo Bar Baz'} | ${undefined}
defaultRuleName | expectedDisabledAttribute | approverTypeSelect
${'Vulnerability-Check'} | ${'disabled'} | ${false}
${'License-Check'} | ${'disabled'} | ${false}
${'Foo Bar Baz'} | ${undefined} | ${true}
`(
'with defaultRuleName set to $defaultRuleName',
({ defaultRuleName, expectedDisabledAttribute }) => {
({ defaultRuleName, expectedDisabledAttribute, approverTypeSelect }) => {
beforeEach(() => {
createComponent({
initRule: null,
isMrEdit: false,
defaultRuleName,
});
});
......@@ -555,6 +704,12 @@ describe('EE Approvals RuleForm', () => {
} the name text field`, () => {
expect(findNameInput().attributes('disabled')).toBe(expectedDisabledAttribute);
});
it(`${
approverTypeSelect ? 'renders' : 'does not render'
} the approver type select`, () => {
expect(findApproverTypeSelect().exists()).toBe(approverTypeSelect);
});
},
);
});
......@@ -727,4 +882,23 @@ describe('EE Approvals RuleForm', () => {
});
});
});
describe('when the approval gates feature is disabled', () => {
it('does not render the approver type select input', async () => {
createComponent(
{ isMrEdit: false },
{
provide: {
glFeatures: {
ffComplianceApprovalGates: false,
},
},
},
);
await nextTick();
expect(findApproverTypeSelect().exists()).toBe(false);
});
});
});
export const createExternalRule = () => ({
id: 9,
name: 'API Gate',
externalUrl: 'https://gitlab.com',
ruleType: 'external_approval',
});
export const createProjectRules = () => [
{
id: 1,
......
......@@ -3982,6 +3982,9 @@ msgstr ""
msgid "Applying suggestions..."
msgstr ""
msgid "Approval Gate"
msgstr ""
msgid "Approval Status"
msgstr ""
......@@ -4004,6 +4007,15 @@ msgid_plural "ApprovalRuleRemove|Approvals from these members are not revoked."
msgstr[0] ""
msgstr[1] ""
msgid "ApprovalRuleRemove|Remove approval gate"
msgstr ""
msgid "ApprovalRuleRemove|Remove approval gate?"
msgstr ""
msgid "ApprovalRuleRemove|You are about to remove the %{name} approval gate. Approval from this service is not revoked."
msgstr ""
msgid "ApprovalRuleRemove|You are about to remove the %{name} approver group which has %{nMembers}."
msgstr ""
......@@ -4017,21 +4029,36 @@ msgid_plural "ApprovalRuleSummary|%{count} approvals required from %{membersCoun
msgstr[0] ""
msgstr[1] ""
msgid "ApprovalRule|Add approvel gate"
msgstr ""
msgid "ApprovalRule|Add approvers"
msgstr ""
msgid "ApprovalRule|Approval rules"
msgstr ""
msgid "ApprovalRule|Approval service API"
msgstr ""
msgid "ApprovalRule|Approvals required"
msgstr ""
msgid "ApprovalRule|Approvel gate"
msgstr ""
msgid "ApprovalRule|Approver Type"
msgstr ""
msgid "ApprovalRule|Approvers"
msgstr ""
msgid "ApprovalRule|Examples: QA, Security."
msgstr ""
msgid "ApprovalRule|Invoke an external API as part of the approvals"
msgstr ""
msgid "ApprovalRule|Name"
msgstr ""
......@@ -4041,6 +4068,9 @@ msgstr ""
msgid "ApprovalRule|Target branch"
msgstr ""
msgid "ApprovalRule|Users or groups"
msgstr ""
msgid "ApprovalStatusTooltip|Adheres to separation of duties"
msgstr ""
......@@ -13013,6 +13043,9 @@ msgstr ""
msgid "External storage authentication token"
msgstr ""
msgid "External url has already been taken"
msgstr ""
msgid "ExternalAuthorizationService|Classification label"
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