Commit 6ca40aa2 authored by Mark Florian's avatar Mark Florian

Merge branch '229825-implement-rows' into 'master'

Resolve "Display security approval rules when creating a new project - Implement Vulnerability-Check/License-Check rows"

Closes #229825

See merge request gitlab-org/gitlab!38992
parents 2a402a38 c6f1ebda
......@@ -44,6 +44,10 @@ class ProjectsController < Projects::ApplicationController
push_frontend_feature_flag(:service_desk_custom_address, @project)
end
before_action only: [:edit] do
push_frontend_feature_flag(:approval_suggestions, @project)
end
layout :determine_layout
def index
......
......@@ -25,7 +25,12 @@ export default {
rule: 'data',
}),
title() {
return this.rule ? __('Update approval rule') : __('Add approval rule');
return !this.rule || this.defaultRuleName
? __('Add approval rule')
: __('Update approval rule');
},
defaultRuleName() {
return this.rule?.defaultRuleName;
},
},
methods: {
......@@ -47,6 +52,11 @@ export default {
size="sm"
@ok.prevent="submit"
>
<rule-form ref="form" :init-rule="rule" :is-mr-edit="isMrEdit" />
<rule-form
ref="form"
:init-rule="rule"
:is-mr-edit="isMrEdit"
:default-rule-name="defaultRuleName"
/>
</gl-modal-vuex>
</template>
<script>
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { mapState, mapActions } from 'vuex';
import { n__, sprintf } from '~/locale';
import { RULE_TYPE_ANY_APPROVER, RULE_TYPE_REGULAR } from '../../constants';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
import Rules from '../rules.vue';
import RuleControls from '../rule_controls.vue';
import EmptyRule from '../empty_rule.vue';
import RuleInput from '../mr_edit/rule_input.vue';
import RuleBranches from '../rule_branches.vue';
import UnconfiguredSecurityRules from '../security_configuration/unconfigured_security_rules.vue';
export default {
components: {
......@@ -17,7 +20,10 @@ export default {
EmptyRule,
RuleInput,
RuleBranches,
UnconfiguredSecurityRules,
},
// TODO: Remove feature flag in https://gitlab.com/gitlab-org/gitlab/-/issues/235114
mixins: [glFeatureFlagsMixin()],
computed: {
...mapState(['settings']),
...mapState({
......@@ -92,45 +98,52 @@ export default {
</script>
<template>
<rules :rules="rules">
<template #thead="{ name, members, approvalsRequired, branches }">
<tr class="d-none d-sm-table-row">
<th class="w-25">{{ hasNamedRule ? name : members }}</th>
<th :class="settings.allowMultiRule ? 'w-50 d-none d-sm-table-cell' : 'w-75'">
<span v-if="hasNamedRule">{{ members }}</span>
</th>
<th v-if="settings.allowMultiRule">{{ branches }}</th>
<th>{{ approvalsRequired }}</th>
<th></th>
</tr>
</template>
<template #tbody="{ rules }">
<template v-for="(rule, index) in rules">
<empty-rule
v-if="rule.ruleType === 'any_approver'"
:key="index"
:rule="rule"
:allow-multi-rule="settings.allowMultiRule"
:is-mr-edit="false"
:eligible-approvers-docs-path="settings.eligibleApproversDocsPath"
:can-edit="canEdit(rule)"
/>
<tr v-else :key="index">
<td class="js-name">{{ rule.name }}</td>
<td class="js-members" :class="settings.allowMultiRule ? 'd-none d-sm-table-cell' : null">
<user-avatar-list :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" />
</td>
<td class="text-nowrap px-2 w-0 js-controls">
<rule-controls v-if="canEdit(rule)" :rule="rule" />
</td>
<div>
<rules :rules="rules">
<template #thead="{ name, members, approvalsRequired, branches }">
<tr class="d-none d-sm-table-row">
<th class="w-25">{{ hasNamedRule ? name : members }}</th>
<th :class="settings.allowMultiRule ? 'w-50 d-none d-sm-table-cell' : 'w-75'">
<span v-if="hasNamedRule">{{ members }}</span>
</th>
<th v-if="settings.allowMultiRule">{{ branches }}</th>
<th>{{ approvalsRequired }}</th>
<th></th>
</tr>
</template>
</template>
</rules>
<template #tbody="{ rules }">
<template v-for="(rule, index) in rules">
<empty-rule
v-if="rule.ruleType === 'any_approver'"
:key="index"
:rule="rule"
:allow-multi-rule="settings.allowMultiRule"
:is-mr-edit="false"
:eligible-approvers-docs-path="settings.eligibleApproversDocsPath"
:can-edit="canEdit(rule)"
/>
<tr v-else :key="index">
<td class="js-name">{{ rule.name }}</td>
<td
class="js-members"
:class="settings.allowMultiRule ? 'd-none d-sm-table-cell' : null"
>
<user-avatar-list :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" />
</td>
<td class="text-nowrap px-2 w-0 js-controls">
<rule-controls v-if="canEdit(rule)" :rule="rule" />
</td>
</tr>
</template>
</template>
</rules>
<!-- TODO: Remove feature flag in https://gitlab.com/gitlab-org/gitlab/-/issues/235114 -->
<unconfigured-security-rules v-if="glFeatures.approvalSuggestions" />
</div>
</template>
<script>
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { mapState, mapActions } from 'vuex';
import { groupBy, isNumber } from 'lodash';
import { sprintf, __ } from '~/locale';
......@@ -9,7 +10,8 @@ import { TYPE_USER, TYPE_GROUP, TYPE_HIDDEN_GROUPS } from '../constants';
const DEFAULT_NAME = 'Default';
const DEFAULT_NAME_FOR_LICENSE_REPORT = 'License-Check';
const READONLY_NAMES = [DEFAULT_NAME_FOR_LICENSE_REPORT];
const DEFAULT_NAME_FOR_VULNERABILITY_CHECK = 'Vulnerability-Check';
const READONLY_NAMES = [DEFAULT_NAME_FOR_LICENSE_REPORT, DEFAULT_NAME_FOR_VULNERABILITY_CHECK];
export default {
components: {
......@@ -17,6 +19,8 @@ export default {
ApproversSelect,
BranchesSelect,
},
// TODO: Remove feature flag in https://gitlab.com/gitlab-org/gitlab/-/issues/235114
mixins: [glFeatureFlagsMixin()],
props: {
initRule: {
type: Object,
......@@ -28,9 +32,14 @@ export default {
default: true,
required: false,
},
defaultRuleName: {
type: String,
required: false,
default: '',
},
},
data() {
return {
const defaults = {
name: '',
approvalsRequired: 1,
minApprovalsRequired: 0,
......@@ -43,9 +52,19 @@ export default {
containsHiddenGroups: false,
...this.getInitialData(),
};
// TODO: Remove feature flag in https://gitlab.com/gitlab-org/gitlab/-/issues/235114
if (this.glFeatures.approvalSuggestions) {
return { ...defaults, name: this.defaultRuleName || defaults.name };
}
return defaults;
},
computed: {
...mapState(['settings']),
rule() {
// If we are creating a new rule with a suggested approval name
return this.defaultRuleName ? null : this.initRule;
},
approversByType() {
return groupBy(this.approvers, x => x.type);
},
......@@ -132,6 +151,12 @@ export default {
return !this.settings.lockedApprovalsRuleName;
},
isNameDisabled() {
// TODO: Remove feature flag in https://gitlab.com/gitlab-org/gitlab/-/issues/235114
if (this.glFeatures.approvalSuggestions) {
return (
Boolean(this.isPersisted || this.defaultRuleName) && READONLY_NAMES.includes(this.name)
);
}
return this.isPersisted && READONLY_NAMES.includes(this.name);
},
removeHiddenGroups() {
......@@ -232,7 +257,7 @@ export default {
return this.isValid;
},
getInitialData() {
if (!this.initRule) {
if (!this.initRule || this.defaultRuleName) {
return {};
}
......@@ -309,7 +334,7 @@ export default {
v-model="branchesToAdd"
:project-id="settings.projectId"
:is-invalid="!!validation.branches"
:init-rule="initRule"
:init-rule="rule"
/>
<div class="invalid-feedback">{{ validation.branches }}</div>
</div>
......
<script>
import { GlButton, GlLink, GlSprintf } from '@gitlab/ui';
export default {
components: {
GlButton,
GlLink,
GlSprintf,
},
props: {
rule: {
type: Object,
required: true,
},
},
};
</script>
<template>
<tr>
<!-- Suggested approval rule creation row -->
<template v-if="rule.hasConfiguredJob">
<td class="js-name" colspan="4">
<div>{{ rule.name }}</div>
<div class="gl-text-gray-500">
<gl-sprintf :message="rule.enableDescription">
<template #link="{ content }">
<gl-link :href="rule.docsPath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</div>
</td>
<td class="gl-px-2! gl-text-right">
<gl-button @click="$emit('enable')">
{{ __('Enable') }}
</gl-button>
</td>
</template>
<!-- Approval rule suggestion when lacking appropriate CI job for the rule -->
<td v-else class="js-name" colspan="5">
<div>{{ rule.name }}</div>
<div class="gl-text-gray-500">
<gl-sprintf :message="rule.description">
<template #link="{ content }">
<gl-link :href="rule.docsPath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</div>
</td>
</tr>
</template>
<script>
import { camelCase } from 'lodash';
import { mapState, mapActions } from 'vuex';
import { GlSkeletonLoading } from '@gitlab/ui';
import { LICENSE_CHECK_NAME, VULNERABILITY_CHECK_NAME, JOB_TYPES } from 'ee/approvals/constants';
import { s__ } from '~/locale';
import UnconfiguredSecurityRule from './unconfigured_security_rule.vue';
export default {
components: {
UnconfiguredSecurityRule,
GlSkeletonLoading,
},
inject: {
vulnerabilityCheckHelpPagePath: {
from: 'vulnerabilityCheckHelpPagePath',
default: '',
},
licenseCheckHelpPagePath: {
from: 'licenseCheckHelpPagePath',
default: '',
},
},
featureTypes: {
vulnerabilityCheck: [
JOB_TYPES.SAST,
JOB_TYPES.DAST,
JOB_TYPES.DEPENDENCY_SCANNING,
JOB_TYPES.SECRET_DETECTION,
JOB_TYPES.COVERAGE_FUZZING,
],
licenseCheck: [JOB_TYPES.LICENSE_SCANNING],
},
computed: {
...mapState('securityConfiguration', ['configuration']),
...mapState({
rules: state => state.approvals.rules,
isApprovalsLoading: state => state.approvals.isLoading,
isSecurityConfigurationLoading: state => state.securityConfiguration.isLoading,
}),
isRulesLoading() {
return this.isApprovalsLoading || this.isSecurityConfigurationLoading;
},
securityRules() {
return [
{
name: VULNERABILITY_CHECK_NAME,
description: s__(
'SecurityApprovals|One or more of the security scanners must be enabled. %{linkStart}More information%{linkEnd}',
),
enableDescription: s__(
'SecurityApprovals|Requires approval for vulnerabilties of Critical, High, or Unknown severity. %{linkStart}More information%{linkEnd}',
),
docsPath: this.vulnerabilityCheckHelpPagePath,
},
{
name: LICENSE_CHECK_NAME,
description: s__(
'SecurityApprovals|License Scanning must be enabled. %{linkStart}More information%{linkEnd}',
),
enableDescription: s__(
'SecurityApprovals|Requires license policy rules for licenses of Allowed, or Denied. %{linkStart}More information%{linkEnd}',
),
docsPath: this.licenseCheckHelpPagePath,
},
];
},
unconfiguredRules() {
return this.securityRules.reduce((filtered, securityRule) => {
const hasApprovalRuleDefined = this.hasApprovalRuleDefined(securityRule);
const hasConfiguredJob = this.hasConfiguredJob(securityRule);
if (!hasApprovalRuleDefined || !hasConfiguredJob) {
filtered.push({ ...securityRule, hasConfiguredJob });
}
return filtered;
}, []);
},
},
created() {
this.fetchSecurityConfiguration();
},
methods: {
...mapActions('securityConfiguration', ['fetchSecurityConfiguration']),
...mapActions({ openCreateModal: 'createModal/open' }),
hasApprovalRuleDefined(matchRule) {
return this.rules.some(rule => {
return matchRule.name === rule.name;
});
},
hasConfiguredJob(matchRule) {
const { features = [] } = this.configuration;
return this.$options.featureTypes[camelCase(matchRule.name)].some(featureType => {
return features.some(feature => {
return feature.type === featureType && feature.configured;
});
});
},
},
};
</script>
<template>
<table class="table m-0">
<tbody>
<tr v-if="isRulesLoading">
<td colspan="3">
<gl-skeleton-loading :lines="3" />
</td>
</tr>
<unconfigured-security-rule
v-for="rule in unconfiguredRules"
v-else
:key="rule.name"
:rule="rule"
@enable="openCreateModal({ defaultRuleName: rule.name })"
/>
</tbody>
</table>
</template>
......@@ -14,6 +14,15 @@ export const RULE_NAME_ANY_APPROVER = 'All Members';
export const VULNERABILITY_CHECK_NAME = 'Vulnerability-Check';
export const LICENSE_CHECK_NAME = 'License-Check';
export const JOB_TYPES = {
SAST: 'sast',
DAST: 'dast',
DEPENDENCY_SCANNING: 'dependency_scanning',
SECRET_DETECTION: 'secret_detection',
COVERAGE_FUZZING: 'coverage_fuzzing',
LICENSE_SCANNING: 'license_scanning',
};
export const APPROVAL_RULE_CONFIGS = {
[VULNERABILITY_CHECK_NAME]: {
title: __('Vulnerability-Check'),
......
......@@ -12,6 +12,8 @@ export default function mountProjectSettingsApprovals(el) {
return null;
}
const { vulnerabilityCheckHelpPagePath, licenseCheckHelpPagePath } = el.dataset;
const store = createStore(projectSettingsModule(), {
...el.dataset,
prefix: 'project-settings',
......@@ -22,6 +24,10 @@ export default function mountProjectSettingsApprovals(el) {
return new Vue({
el,
store,
provide: {
vulnerabilityCheckHelpPagePath,
licenseCheckHelpPagePath,
},
render(h) {
return h(ProjectSettingsApp);
},
......
import Vuex from 'vuex';
import modalModule from '~/vuex_shared/modules/modal';
import securityConfigurationModule from 'ee/security_configuration/modules/configuration';
import state from './state';
export const createStoreOptions = (approvalsModule, settings) => ({
......@@ -8,6 +9,9 @@ export const createStoreOptions = (approvalsModule, settings) => ({
...(approvalsModule ? { approvals: approvalsModule } : {}),
createModal: modalModule(),
deleteModal: modalModule(),
securityConfiguration: securityConfigurationModule({
securityConfigurationPath: settings?.securityConfigurationPath || '',
}),
},
});
......
......@@ -2,9 +2,7 @@ import * as Sentry from '@sentry/browser';
import axios from '~/lib/utils/axios_utils';
import * as types from './mutation_types';
export const setSecurityConfigurationEndpoint = ({ commit }, endpoint) =>
commit(types.SET_SECURITY_CONFIGURATION_ENDPOINT, endpoint);
// eslint-disable-next-line import/prefer-default-export
export const fetchSecurityConfiguration = ({ commit, state }) => {
if (!state.securityConfigurationPath) {
return commit(types.RECEIVE_SECURITY_CONFIGURATION_ERROR);
......
import state from './state';
import createState from './state';
import mutations from './mutations';
import * as actions from './actions';
export default {
export default ({ securityConfigurationPath = '' }) => ({
namespaced: true,
state,
state: createState({ securityConfigurationPath }),
mutations,
actions,
};
});
export const SET_SECURITY_CONFIGURATION_ENDPOINT = 'SET_SECURITY_CONFIGURATION_ENDPOINT';
export const REQUEST_SECURITY_CONFIGURATION = 'REQUEST_SECURITY_CONFIGURATION';
export const RECEIVE_SECURITY_CONFIGURATION_SUCCESS = 'RECEIVE_SECURITY_CONFIGURATION_SUCCESS';
export const RECEIVE_SECURITY_CONFIGURATION_ERROR = 'RECEIVE_SECURITY_CONFIGURATION_ERROR';
import * as types from './mutation_types';
export default {
[types.SET_SECURITY_CONFIGURATION_ENDPOINT](state, payload) {
state.securityConfigurationPath = payload;
},
[types.REQUEST_SECURITY_CONFIGURATION](state) {
state.isLoading = true;
state.errorLoading = false;
......
export default () => ({
securityConfigurationPath: '',
export default ({ securityConfigurationPath }) => ({
securityConfigurationPath,
isLoading: false,
errorLoading: false,
configuration: {},
......
......@@ -94,6 +94,24 @@ module EE
{ date: date }
end
def approvals_app_data(project = @project)
{ data: { 'project_id': project.id,
'can_edit': can_modify_approvers.to_s,
'project_path': expose_path(api_v4_projects_path(id: project.id)),
'settings_path': expose_path(api_v4_projects_approval_settings_path(id: project.id)),
'rules_path': expose_path(api_v4_projects_approval_settings_rules_path(id: project.id)),
'allow_multi_rule': project.multiple_approval_rules_available?.to_s,
'eligible_approvers_docs_path': help_page_path('user/project/merge_requests/merge_request_approvals', anchor: 'eligible-approvers'),
'security_approvals_help_page_path': help_page_path('user/application_security/index.md', anchor: 'security-approvals-in-merge-requests-ultimate'),
'security_configuration_path': project_security_configuration_path(project),
'vulnerability_check_help_page_path': help_page_path('user/application_security/index', anchor: 'enabling-security-approvals-within-a-project'),
'license_check_help_page_path': help_page_path('user/application_security/index', anchor: 'enabling-license-approvals-within-a-project') } }
end
def can_modify_approvers(project = @project)
can?(current_user, :modify_approvers_rules, project)
end
def permanent_delete_message(project)
message = _('This action will %{strongOpen}permanently delete%{strongClose} %{codeOpen}%{project}%{codeClose} %{strongOpen}immediately%{strongClose}, including its repositories and all content: issues, merge requests, etc.')
html_escape(message) % remove_message_data(project)
......
- can_override_approvers = project.can_override_approvers?
- can_modify_approvers = can?(current_user, :modify_approvers_rules, @project)
- can_modify_merge_request_author_settings = can?(current_user, :modify_merge_request_author_setting, @project)
- can_modify_merge_request_committer_settings = can?(current_user, :modify_merge_request_committer_setting, @project)
.form-group
= form.label :approver_ids, class: 'label-bold' do
= _("Approval rules")
#js-mr-approvals-settings{ data: { 'project_id': @project.id,
'can_edit': can_modify_approvers.to_s,
'project_path': expose_path(api_v4_projects_path(id: @project.id)),
'settings_path': expose_path(api_v4_projects_approval_settings_path(id: @project.id)),
'rules_path': expose_path(api_v4_projects_approval_settings_rules_path(id: @project.id)),
'allow_multi_rule': @project.multiple_approval_rules_available?.to_s,
'eligible_approvers_docs_path': help_page_path('user/project/merge_requests/merge_request_approvals', anchor: 'eligible-approvers'),
'security_approvals_help_page_path': help_page_path('user/application_security/index.md', anchor: 'security-approvals-in-merge-requests-ultimate')} }
#js-mr-approvals-settings{ approvals_app_data }
.text-center.gl-mt-3
= sprite_icon('spinner', size: 24, css_class: 'gl-spinner')
......
......@@ -14,6 +14,11 @@ localVue.use(Vuex);
describe('Approvals ModalRuleCreate', () => {
let createModalState;
let wrapper;
let modal;
let form;
const findModal = () => wrapper.find(GlModalVuex);
const findForm = () => wrapper.find(RuleForm);
const factory = (options = {}) => {
const store = new Vuex.Store({
......@@ -49,15 +54,13 @@ describe('Approvals ModalRuleCreate', () => {
describe('without data', () => {
beforeEach(() => {
createModalState.data = null;
factory();
modal = findModal();
form = findForm();
});
it('renders modal', () => {
factory();
const modal = wrapper.find(GlModalVuex);
expect(modal.exists()).toBe(true);
expect(modal.props('modalModule')).toEqual(MODAL_MODULE);
expect(modal.props('modalId')).toEqual(TEST_MODAL_ID);
expect(modal.attributes('title')).toEqual('Add approval rule');
......@@ -65,22 +68,12 @@ describe('Approvals ModalRuleCreate', () => {
});
it('renders form', () => {
factory();
const modal = wrapper.find(GlModalVuex);
const form = modal.find(RuleForm);
expect(form.exists()).toBe(true);
expect(form.props('initRule')).toEqual(null);
});
it('when modal emits ok, submits form', () => {
factory();
const form = wrapper.find(RuleForm);
form.vm.submit = jest.fn();
const modal = wrapper.find(GlModalVuex);
modal.vm.$emit('ok', new Event('ok'));
expect(form.vm.submit).toHaveBeenCalled();
......@@ -90,27 +83,50 @@ describe('Approvals ModalRuleCreate', () => {
describe('with data', () => {
beforeEach(() => {
createModalState.data = TEST_RULE;
factory();
modal = findModal();
form = findForm();
});
it('renders modal', () => {
factory();
const modal = wrapper.find(GlModalVuex);
expect(modal.exists()).toBe(true);
expect(modal.attributes('title')).toEqual('Update approval rule');
expect(modal.attributes('ok-title')).toEqual('Update approval rule');
});
it('renders form', () => {
factory();
expect(form.exists()).toBe(true);
expect(form.props('initRule')).toEqual(TEST_RULE);
});
});
const modal = wrapper.find(GlModalVuex);
const form = modal.find(RuleForm);
describe('with approvalSuggestions feature flag', () => {
beforeEach(() => {
createModalState.data = { ...TEST_RULE, defaultRuleName: 'Vulnerability-Check' };
factory({
provide: {
glFeatures: { approvalSuggestions: true },
},
});
modal = findModal();
form = findForm();
});
it('renders add rule modal', () => {
expect(modal.exists()).toBe(true);
expect(modal.attributes('title')).toEqual('Add approval rule');
expect(modal.attributes('ok-title')).toEqual('Add approval rule');
});
it('renders form with defaultRuleName', () => {
expect(form.props().defaultRuleName).toBe('Vulnerability-Check');
expect(form.exists()).toBe(true);
expect(form.props('initRule')).toEqual(TEST_RULE);
});
it('renders the form when passing in an existing rule', () => {
expect(form.exists()).toBe(true);
expect(form.props('initRule')).toEqual(createModalState.data);
});
});
});
......@@ -5,6 +5,7 @@ import projectSettingsModule from 'ee/approvals/stores/modules/project_settings'
import ProjectRules from 'ee/approvals/components/project_settings/project_rules.vue';
import RuleInput from 'ee/approvals/components/mr_edit/rule_input.vue';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
import UnconfiguredSecurityRules from 'ee/approvals/components/security_configuration/unconfigured_security_rules.vue';
import { createProjectRules } from '../../mocks';
const TEST_RULES = createProjectRules();
......@@ -29,11 +30,12 @@ describe('Approvals ProjectRules', () => {
let wrapper;
let store;
const factory = (props = {}) => {
const factory = (props = {}, options = {}) => {
wrapper = mount(localVue.extend(ProjectRules), {
propsData: props,
store: new Vuex.Store(store),
localVue,
...options,
});
};
......@@ -121,5 +123,38 @@ describe('Approvals ProjectRules', () => {
expect(nameCell.find('.js-help').exists()).toBeFalsy();
});
it('should not render the unconfigured-security-rules component', () => {
expect(wrapper.contains(UnconfiguredSecurityRules)).toBe(false);
});
});
describe.each([true, false])(
'when the approvalSuggestions feature flag is %p',
approvalSuggestions => {
beforeEach(() => {
const rules = createProjectRules();
rules[0].name = 'Vulnerability-Check';
store.modules.approvals.state.rules = rules;
store.state.settings.allowMultiRule = true;
});
beforeEach(() => {
factory(
{},
{
provide: {
glFeatures: { approvalSuggestions },
},
},
);
});
it(`should ${
approvalSuggestions ? '' : 'not'
} render the unconfigured-security-rules component`, () => {
expect(wrapper.contains(UnconfiguredSecurityRules)).toBe(approvalSuggestions);
});
},
);
});
......@@ -39,13 +39,13 @@ describe('EE Approvals RuleForm', () => {
let store;
let actions;
const createComponent = (props = {}) => {
const createComponent = (props = {}, options = {}) => {
wrapper = shallowMount(localVue.extend(RuleForm), {
propsData: props,
store: new Vuex.Store(store),
localVue,
provide: {
glFeatures: { scopedApprovalRules: true },
glFeatures: { scopedApprovalRules: true, ...options.provide?.glFeatures },
},
});
};
......@@ -482,6 +482,38 @@ describe('EE Approvals RuleForm', () => {
});
});
describe('with approvalSuggestions enabled', () => {
describe.each`
defaultRuleName | expectedDisabledAttribute
${'Vulnerability-Check'} | ${'disabled'}
${'License-Check'} | ${'disabled'}
${'Foo Bar Baz'} | ${undefined}
`(
'with defaultRuleName set to $defaultRuleName',
({ defaultRuleName, expectedDisabledAttribute }) => {
beforeEach(() => {
createComponent(
{
initRule: null,
defaultRuleName,
},
{
provide: {
glFeatures: { approvalSuggestions: true },
},
},
);
});
it(`it ${
expectedDisabledAttribute ? 'disables' : 'does not disable'
} the name text field`, () => {
expect(findNameInput().attributes('disabled')).toBe(expectedDisabledAttribute);
});
},
);
});
describe('with new License-Check rule', () => {
beforeEach(() => {
createComponent({
......@@ -494,6 +526,18 @@ describe('EE Approvals RuleForm', () => {
});
});
describe('with new Vulnerability-Check rule', () => {
beforeEach(() => {
createComponent({
initRule: { ...TEST_RULE, id: null, name: 'Vulnerability-Check' },
});
});
it('does not disable the name text field', () => {
expect(findNameInput().attributes('disabled')).toBe(undefined);
});
});
describe('with editing the License-Check rule', () => {
beforeEach(() => {
createComponent({
......@@ -505,6 +549,18 @@ describe('EE Approvals RuleForm', () => {
expect(findNameInput().attributes('disabled')).toBe('disabled');
});
});
describe('with editing the Vulnerability-Check rule', () => {
beforeEach(() => {
createComponent({
initRule: { ...TEST_RULE, name: 'Vulnerability-Check' },
});
});
it('disables the name text field', () => {
expect(findNameInput().attributes('disabled')).toBe('disabled');
});
});
});
describe('when allow only single rule', () => {
......
import Vuex from 'vuex';
import { LICENSE_CHECK_NAME, VULNERABILITY_CHECK_NAME } from 'ee/approvals/constants';
import UnconfiguredSecurityRule from 'ee/approvals/components/security_configuration/unconfigured_security_rule.vue';
import { mount, createLocalVue } from '@vue/test-utils';
import { GlSprintf, GlButton } from '@gitlab/ui';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('UnconfiguredSecurityRule component', () => {
let wrapper;
let description;
const findDescription = () => wrapper.find(GlSprintf);
const findButton = () => wrapper.find(GlButton);
const vulnCheckRule = {
name: VULNERABILITY_CHECK_NAME,
description: 'vuln-check description without enable button',
enableDescription: 'vuln-check description with enable button',
docsPath: 'docs/vuln-check',
};
const licenseCheckRule = {
name: LICENSE_CHECK_NAME,
description: 'license-check description without enable button',
enableDescription: 'license-check description with enable button',
docsPath: 'docs/license-check',
};
const createWrapper = (props = {}, options = {}) => {
wrapper = mount(UnconfiguredSecurityRule, {
localVue,
propsData: {
...props,
},
...options,
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe.each`
rule | ruleName | descriptionText
${licenseCheckRule} | ${licenseCheckRule.name} | ${licenseCheckRule.enableDescription}
${vulnCheckRule} | ${vulnCheckRule.name} | ${vulnCheckRule.enableDescription}
`('with a configured job that is eligible for $ruleName', ({ rule, descriptionText }) => {
beforeEach(() => {
createWrapper({
rule: { ...rule, hasConfiguredJob: true },
});
description = findDescription();
});
it('should render the row with the enable decription and enable button', () => {
expect(description.exists()).toBe(true);
expect(description.text()).toBe(descriptionText);
expect(findButton().exists()).toBe(true);
});
it('should emit the "enable" event when the button is clicked', () => {
findButton().trigger('click');
expect(wrapper.emitted('enable')).toEqual([[]]);
});
});
describe.each`
rule | ruleName | descriptionText
${licenseCheckRule} | ${licenseCheckRule.name} | ${licenseCheckRule.description}
${vulnCheckRule} | ${vulnCheckRule.name} | ${vulnCheckRule.description}
`('with a unconfigured job that is eligible for $ruleName', ({ rule, descriptionText }) => {
beforeEach(() => {
createWrapper({
rule: { ...rule, hasConfiguredJob: false },
});
description = findDescription();
});
it('should render the row with the decription and no button', () => {
expect(description.exists()).toBe(true);
expect(description.text()).toBe(descriptionText);
expect(findButton().exists()).toBe(false);
});
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlSkeletonLoading } from '@gitlab/ui';
import UnconfiguredSecurityRules from 'ee/approvals/components/security_configuration/unconfigured_security_rules.vue';
import UnconfiguredSecurityRule from 'ee/approvals/components/security_configuration/unconfigured_security_rule.vue';
import { createStoreOptions } from 'ee/approvals/stores';
import projectSettingsModule from 'ee/approvals/stores/modules/project_settings';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('UnconfiguredSecurityRules component', () => {
let wrapper;
let store;
const TEST_PROJECT_ID = '7';
const createWrapper = (props = {}) => {
wrapper = shallowMount(UnconfiguredSecurityRules, {
localVue,
store,
propsData: {
...props,
},
provide: {
vulnerabilityCheckHelpPagePath: '',
licenseCheckHelpPagePath: '',
},
});
};
beforeEach(() => {
store = new Vuex.Store(
createStoreOptions(projectSettingsModule(), { projectId: TEST_PROJECT_ID }),
);
jest.spyOn(store, 'dispatch');
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when created ', () => {
beforeEach(() => {
createWrapper();
});
it('should fetch the security configuration', () => {
expect(store.dispatch).toHaveBeenCalledWith(
'securityConfiguration/fetchSecurityConfiguration',
undefined,
);
});
it('should render a unconfigured-security-rule component for every security rule ', () => {
expect(wrapper.findAll(UnconfiguredSecurityRule).length).toBe(2);
});
});
describe.each`
approvalsLoading | securityConfigurationLoading | shouldRender
${false} | ${false} | ${false}
${true} | ${false} | ${true}
${false} | ${true} | ${true}
${true} | ${true} | ${true}
`(
'while approvalsLoading is $approvalsLoading and securityConfigurationLoading is $securityConfigurationLoading',
({ approvalsLoading, securityConfigurationLoading, shouldRender }) => {
beforeEach(() => {
createWrapper();
store.state.approvals.isLoading = approvalsLoading;
store.state.securityConfiguration.isLoading = securityConfigurationLoading;
});
it(`should ${shouldRender ? '' : 'not'} render the loading skeleton`, () => {
expect(wrapper.contains(GlSkeletonLoading)).toBe(shouldRender);
});
},
);
});
......@@ -11,25 +11,8 @@ describe('security configuration module actions', () => {
let state;
beforeEach(() => {
state = createState();
});
describe('setSecurityConfigurationEndpoint', () => {
const securityConfigurationPath = 123;
it('should commit the SET_SECURITY_CONFIGURATION_ENDPOINT mutation', async () => {
await testAction(
actions.setSecurityConfigurationEndpoint,
securityConfigurationPath,
state,
[
{
type: types.SET_SECURITY_CONFIGURATION_ENDPOINT,
payload: securityConfigurationPath,
},
],
[],
);
state = createState({
securityConfigurationPath: `${TEST_HOST}/-/security/configuration.json`,
});
});
......@@ -38,7 +21,6 @@ describe('security configuration module actions', () => {
const configuration = {};
beforeEach(() => {
state.securityConfigurationPath = `${TEST_HOST}/-/security/configuration.json`;
mock = new MockAdapter(axios);
});
......
......@@ -8,15 +8,6 @@ describe('security configuration module mutations', () => {
state = {};
});
describe('SET_SECURITY_CONFIGURATION_ENDPOINT', () => {
const securityConfigurationPath = 123;
it(`should set the securityConfigurationPath to ${securityConfigurationPath}`, () => {
mutations[types.SET_SECURITY_CONFIGURATION_ENDPOINT](state, securityConfigurationPath);
expect(state.securityConfigurationPath).toBe(securityConfigurationPath);
});
});
describe('REQUEST_SECURITY_CONFIGURATION', () => {
it('should set the isLoading to true', () => {
mutations[types.REQUEST_SECURITY_CONFIGURATION](state);
......
......@@ -21513,6 +21513,18 @@ msgstr ""
msgid "Security report is out of date. Run %{newPipelineLinkStart}a new pipeline%{newPipelineLinkEnd} for the target branch (%{targetBranchName})"
msgstr ""
msgid "SecurityApprovals|License Scanning must be enabled. %{linkStart}More information%{linkEnd}"
msgstr ""
msgid "SecurityApprovals|One or more of the security scanners must be enabled. %{linkStart}More information%{linkEnd}"
msgstr ""
msgid "SecurityApprovals|Requires approval for vulnerabilties of Critical, High, or Unknown severity. %{linkStart}More information%{linkEnd}"
msgstr ""
msgid "SecurityApprovals|Requires license policy rules for licenses of Allowed, or Denied. %{linkStart}More information%{linkEnd}"
msgstr ""
msgid "SecurityConfiguration|An error occurred while creating the merge request."
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