Commit b26cf332 authored by Simon Knox's avatar Simon Knox

Merge branch 'add_main_body_for_scan_result_policy_rule_mode' into 'master'

Add main body for scan result policy rule mode

See merge request gitlab-org/gitlab!80101
parents fc1c1aaa 45329daf
export const USER_TYPE = 'user';
const GROUP_TYPE = 'group';
/*
Return the ids for all approvers of the group type.
*/
export function groupIds(approvers) {
return approvers
.filter((approver) => approver.type === GROUP_TYPE)
.map((approver) => approver.id);
}
/*
Return the ids for all approvers of the user type.
*/
export function userIds(approvers) {
return approvers.filter((approver) => approver.type === USER_TYPE).map((approver) => approver.id);
}
/*
Group existing approvers into a single array.
*/
export function groupApprovers(existingApprovers) {
const approvers = [...existingApprovers];
const userUniqKeys = ['state', 'username'];
const groupUniqKeys = ['full_name', 'full_path'];
return approvers.map((approver) => {
const approverKeys = Object.keys(approver);
if (approverKeys.includes(...groupUniqKeys)) {
return { ...approver, type: GROUP_TYPE };
} else if (approverKeys.includes(...userUniqKeys)) {
return { ...approver, type: USER_TYPE };
}
return approver;
});
}
/*
Convert approvers into yaml fields (user_approvers, users_approvers_ids) in relation to action.
*/
export function decomposeApprovers(action, approvers) {
const newAction = { ...action };
delete newAction.group_approvers;
delete newAction.user_approvers;
return {
...newAction,
user_approvers_ids: userIds(approvers),
group_approvers_ids: groupIds(approvers),
};
}
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 });
return hasUnsupportedAttribute(policy) ? { error: true } : policy;
};
<script>
import {
GlSprintf,
GlForm,
GlFormInput,
GlModalDirective,
GlToken,
GlAvatarLabeled,
} from '@gitlab/ui';
import { s__ } from '~/locale';
import { AVATAR_SHAPE_OPTION_CIRCLE, AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
import { groupApprovers, decomposeApprovers, USER_TYPE } from './lib/actions';
export default {
components: {
GlSprintf,
GlForm,
GlFormInput,
GlToken,
GlAvatarLabeled,
},
directives: {
GlModalDirective,
},
inject: ['projectId'],
props: {
initAction: {
type: Object,
required: true,
},
existingApprovers: {
type: Array,
required: true,
},
},
data() {
return {
action: { ...this.initAction },
approvers: groupApprovers(this.existingApprovers),
};
},
watch: {
approvers(values) {
this.action = decomposeApprovers(this.action, values);
},
action: {
handler(values) {
this.$emit('changed', values);
},
deep: true,
},
},
methods: {
approvalsRequiredChanged(value) {
this.action.approvals_required = parseInt(value, 10);
},
removeApprover(removedApprover) {
this.approvers = this.approvers.filter(
(approver) => approver.type !== removedApprover.type || approver.id !== removedApprover.id,
);
},
avatarShape(approver) {
return this.isUser(approver) ? AVATAR_SHAPE_OPTION_CIRCLE : AVATAR_SHAPE_OPTION_RECT;
},
approverName(approver) {
return this.isUser(approver) ? approver.name : approver.full_path;
},
isUser(approver) {
return approver.type === USER_TYPE;
},
},
i18n: {
addAnApprover: s__('ScanResultPolicy|add an approver'),
},
humanizedTemplate: s__(
'ScanResultPolicy|%{thenLabelStart}Then%{thenLabelEnd} Require approval from %{approvalsRequired} of the following approvers: %{approvers}',
),
};
</script>
<template>
<div
class="gl-bg-gray-10 gl-border-solid gl-border-1 gl-border-gray-100 gl-rounded-base gl-px-5! gl-pt-5! gl-relative gl-pb-4"
>
<gl-form inline @submit.prevent>
<gl-sprintf :message="$options.humanizedTemplate">
<template #thenLabel="{ content }">
<label for="approvalRequired" class="text-uppercase gl-font-lg gl-mr-3">{{
content
}}</label>
</template>
<template #approvalsRequired>
<gl-form-input
:value="action.approvals_required"
type="number"
class="gl-w-11! gl-m-3"
:min="1"
data-testid="approvals-required-input"
@input="approvalsRequiredChanged"
/>
</template>
<template #approvers>
<gl-token
v-for="approver in approvers"
:key="approver.type + approver.id"
class="gl-ml-3"
@close="removeApprover(approver)"
>
<gl-avatar-labeled
:src="approver.avatar_url"
:size="24"
:shape="avatarShape(approver)"
:label="approverName(approver)"
:entity-name="approver.name"
:alt="approver.name"
/>
</gl-token>
</template>
</gl-sprintf>
</gl-form>
</div>
</template>
<script>
import { GlEmptyState } from '@gitlab/ui';
import {
GlEmptyState,
GlButton,
GlToggle,
GlFormGroup,
GlFormInput,
GlFormTextarea,
GlAlert,
} from '@gitlab/ui';
import { joinPaths, visitUrl, setUrlFragment } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
import {
EDITOR_MODES,
EDITOR_MODE_YAML,
SECURITY_POLICY_ACTIONS,
GRAPHQL_ERROR_MESSAGE,
PARSING_ERROR_MESSAGE,
} from '../constants';
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 {
SECURITY_POLICY_ACTIONS,
DEFAULT_EDITOR_MODE: EDITOR_MODE_YAML,
EDITOR_MODES: [EDITOR_MODES[1]],
EDITOR_MODE_YAML,
SHARED_FOR_DISABLED:
'gl-bg-gray-10 gl-border-solid gl-border-1 gl-border-gray-100 gl-rounded-base',
i18n: {
PARSING_ERROR_MESSAGE,
addRule: s__('SecurityOrchestration|Add rule'),
description: __('Description'),
name: __('Name'),
toggleLabel: s__('SecurityOrchestration|Policy status'),
rules: s__('SecurityOrchestration|Rules'),
createMergeRequest: __('Create via merge request'),
notOwnerButtonText: __('Learn more'),
notOwnerDescription: s__(
'SecurityOrchestration|Scan result policies can only be created by project owners.',
),
yamlPreview: s__('SecurityOrchestration|.yaml preview'),
actions: s__('SecurityOrchestration|Actions'),
},
components: {
GlEmptyState,
GlButton,
GlToggle,
GlFormGroup,
GlFormInput,
GlFormTextarea,
GlAlert,
PolicyActionBuilder,
PolicyEditorLayout,
DimDisableContainer,
},
inject: [
'disableScanPolicyUpdate',
......@@ -67,6 +94,8 @@ export default {
this.scanPolicyDocumentationPath,
'scan-result-policy-editor',
),
yamlEditorError: null,
mode: EDITOR_MODE_YAML,
};
},
computed: {
......@@ -78,8 +107,20 @@ export default {
? this.$options.SECURITY_POLICY_ACTIONS.REPLACE
: this.$options.SECURITY_POLICY_ACTIONS.APPEND;
},
policyYaml() {
return this.hasParsingError ? '' : toYaml(this.policy);
},
hasParsingError() {
return Boolean(this.yamlEditorError);
},
isWithinLimit() {
return this.policy.rules.length < 5;
},
},
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);
......@@ -102,13 +143,14 @@ export default {
try {
const assignedPolicyProject = await this.getSecurityPolicyProject();
const yamlValue =
this.mode === EDITOR_MODE_YAML ? this.yamlEditorValue : toYaml(this.policy);
const mergeRequest = await modifyPolicy({
action,
assignedPolicyProject,
name: this.originalName || fromYaml(this.yamlEditorValue)?.name,
projectPath: this.projectPath,
yamlEditorValue: this.yamlEditorValue,
yamlEditorValue: yamlValue,
});
this.redirectToMergeRequest({ mergeRequest, assignedPolicyProject });
......@@ -136,6 +178,23 @@ export default {
},
updateYaml(manifest) {
this.yamlEditorValue = manifest;
this.yamlEditorError = null;
try {
const newPolicy = fromYaml(manifest);
if (newPolicy.error) {
throw new Error(newPolicy.error);
}
this.policy = { ...this.policy, ...newPolicy };
} catch (error) {
this.yamlEditorError = error;
}
},
changeEditorMode(mode) {
this.mode = mode;
if (mode === EDITOR_MODE_YAML && !this.hasParsingError) {
this.yamlEditorValue = toYaml(this.policy);
}
},
},
};
......@@ -145,8 +204,7 @@ export default {
<policy-editor-layout
v-if="!disableScanPolicyUpdate"
:custom-save-button-text="$options.i18n.createMergeRequest"
:default-editor-mode="$options.DEFAULT_EDITOR_MODE"
:editor-modes="$options.EDITOR_MODES"
:default-editor-mode="$options.EDITOR_MODE_YAML"
:is-editing="isEditing"
:is-removing-policy="isRemovingPolicy"
:is-updating-policy="isCreatingMR"
......@@ -155,7 +213,84 @@ export default {
@remove-policy="handleModifyPolicy($options.SECURITY_POLICY_ACTIONS.REMOVE)"
@save-policy="handleModifyPolicy()"
@update-yaml="updateYaml"
/>
@update-editor-mode="changeEditorMode"
>
<template #rule-editor>
<gl-alert
v-if="hasParsingError"
data-testid="parsing-alert"
class="gl-mb-5"
:dismissible="false"
>
{{ $options.i18n.PARSING_ERROR_MESSAGE }}
</gl-alert>
<gl-form-group :label="$options.i18n.name" label-for="policyName">
<gl-form-input id="policyName" v-model="policy.name" :disabled="hasParsingError" />
</gl-form-group>
<gl-form-group :label="$options.i18n.description" label-for="policyDescription">
<gl-form-textarea
id="policyDescription"
v-model="policy.description"
:disabled="hasParsingError"
/>
</gl-form-group>
<gl-form-group :disabled="hasParsingError" data-testid="policy-enable">
<gl-toggle
v-model="policy.enabled"
:label="$options.i18n.toggleLabel"
:disabled="hasParsingError"
/>
</gl-form-group>
<dim-disable-container data-testid="rule-builder-container" :disabled="hasParsingError">
<template #title>
<h4>{{ $options.i18n.rules }}</h4>
</template>
<template #disabled>
<div :class="`${$options.SHARED_FOR_DISABLED} gl-p-6`"></div>
</template>
<div v-if="isWithinLimit" :class="`${$options.SHARED_FOR_DISABLED} gl-p-5 gl-mb-5`">
<gl-button variant="link" data-testid="add-rule" icon="plus" disabled>
{{ $options.i18n.addRule }}
</gl-button>
</div>
</dim-disable-container>
<dim-disable-container data-testid="action-container" :disabled="hasParsingError">
<template #title>
<h4>{{ $options.i18n.actions }}</h4>
</template>
<template #disabled>
<div :class="`${$options.SHARED_FOR_DISABLED} gl-p-6`"></div>
</template>
<policy-action-builder
v-for="(action, index) in policy.actions"
:key="index"
class="gl-mb-4"
:init-action="action"
:existing-approvers="scanResultPolicyApprovers"
@changed="updateAction(index, $event)"
/>
</dim-disable-container>
</template>
<template #rule-editor-preview>
<h5>{{ $options.i18n.yamlPreview }}</h5>
<pre
data-testid="yaml-preview"
class="gl-bg-white gl-border-none gl-p-0"
:class="{ 'gl-opacity-5': hasParsingError }"
>{{ policyYaml || yamlEditorValue }}</pre
>
</template>
</policy-editor-layout>
<gl-empty-state
v-else
:description="$options.i18n.notOwnerDescription"
......
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]);
});
});
import { fromYaml } from 'ee/threat_monitoring/components/policy_editor/scan_result_policy/lib';
const validManifest = `type: scan_result_policy
name: critical vulnerability CS approvals
description: critical severity level only for container scanning
enabled: true
rules:
- type: scan_finding
branches: []
scanners:
- container_scanning
vulnerabilities_allowed: 1
severity_levels:
- critical
vulnerability_states:
- newly_detected
actions:
- type: require_approval
approvals_required: 1
user_approvers:
- o.lecia.conner
group_approvers_ids:
- 343
`;
const invalidPrimaryKeys = `type: scan_result_policy
name: critical vulnerability CS approvals
description: critical severity level only for container scanning
invalidEnabledKey: false
rules:
- type: scan_finding
branches: []
scanners:
- container_scanning
vulnerabilities_allowed: 1
severity_levels:
- critical
vulnerability_states:
- newly_detected
actions:
- type: require_approval
approvals_required: 1
user_approvers:
- o.lecia.conner
group_approvers_ids:
- 343
`;
const invalidRuleKeys = `type: scan_result_policy
name: critical vulnerability CS approvals
description: critical severity level only for container scanning
enabled: true
rules:
- type: scan_finding
brunch: []
scanners:
- container_scanning
vulnerabilities_allowed: 1
severity_levels:
- critical
vulnerability_states:
- newly_detected
actions:
- type: require_approval
approvals_required: 1
user_approvers:
- o.lecia.conner
group_approvers_ids:
- 343
`;
const invalidActionKeys = `type: scan_result_policy
name: critical vulnerability CS approvals
description: critical severity level only for container scanning
enabled: true
rules:
- type: scan_finding
branches: []
scanners:
- container_scanning
vulnerabilities_allowed: 1
severity_levels:
- critical
vulnerability_states:
- newly_detected
actions:
- type: require_approval
approvals_required: 1
favorite_approvers:
- o.lecia.conner
group_approvers_ids:
- 343
`;
describe('fromYaml', () => {
it('returns policy as json with not error', () => {
expect(fromYaml(validManifest)).toStrictEqual({
actions: [
{
approvals_required: 1,
group_approvers_ids: [343],
type: 'require_approval',
user_approvers: ['o.lecia.conner'],
},
],
description: 'critical severity level only for container scanning',
enabled: true,
name: 'critical vulnerability CS approvals',
rules: [
{
branches: [],
scanners: ['container_scanning'],
severity_levels: ['critical'],
type: 'scan_finding',
vulnerabilities_allowed: 1,
vulnerability_states: ['newly_detected'],
},
],
type: 'scan_result_policy',
});
});
it.each([invalidPrimaryKeys, invalidRuleKeys, invalidActionKeys])(
'returns hash with error set to true',
({ invalidManifest }) => {
expect(fromYaml(invalidManifest)).toStrictEqual({ error: true });
},
);
});
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);
});
});
import { shallowMount } from '@vue/test-utils';
import { GlEmptyState } from '@gitlab/ui';
import { GlEmptyState, GlAlert, GlFormInput, GlFormTextarea, GlToggle } from '@gitlab/ui';
import { nextTick } from 'vue';
import waitForPromises from 'helpers/wait_for_promises';
import PolicyEditorLayout from 'ee/threat_monitoring/components/policy_editor/policy_editor_layout.vue';
......@@ -16,7 +16,12 @@ import {
import { visitUrl } from '~/lib/utils/url_utility';
import { modifyPolicy } from 'ee/threat_monitoring/components/policy_editor/utils';
import { SECURITY_POLICY_ACTIONS } from 'ee/threat_monitoring/components/policy_editor/constants';
import {
SECURITY_POLICY_ACTIONS,
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,
......@@ -45,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, {
......@@ -63,6 +68,7 @@ describe('ScanResultPolicyEditor', () => {
...provide,
},
});
nextTick();
};
const factoryWithExistingPolicy = () => {
......@@ -77,6 +83,15 @@ 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);
const findDescriptionTextArea = () => wrapper.findComponent(GlFormTextarea);
const findEnableToggle = () => wrapper.findComponent(GlToggle);
const findAllDisabledComponents = () => wrapper.findAllComponents(DimDisableContainer);
const findYamlPreview = () => wrapper.find('[data-testid="yaml-preview"]');
afterEach(() => {
wrapper.destroy();
......@@ -84,16 +99,76 @@ describe('ScanResultPolicyEditor', () => {
describe('default', () => {
it('updates the policy yaml when "update-yaml" is emitted', async () => {
factory();
await nextTick();
const newManifest = 'new yaml!';
await factory();
expect(findPolicyEditorLayout().attributes('yamleditorvalue')).toBe(
DEFAULT_SCAN_RESULT_POLICY,
);
await findPolicyEditorLayout().vm.$emit('update-yaml', newManifest);
expect(findPolicyEditorLayout().attributes('yamleditorvalue')).toBe(newManifest);
});
it('disables add rule button until feature is merged', async () => {
await factory();
expect(findAddRuleButton().props('disabled')).toBe(true);
});
it('displays alert for invalid yaml', async () => {
await factory();
expect(findAlert().exists()).toBe(false);
await findPolicyEditorLayout().vm.$emit('update-yaml', 'invalid manifest');
expect(findAlert().exists()).toBe(true);
});
it('disables all rule mode related components when the yaml is invalid', async () => {
await factory();
await findPolicyEditorLayout().vm.$emit('update-yaml', 'invalid manifest');
expect(findNameInput().attributes('disabled')).toBe('true');
expect(findDescriptionTextArea().attributes('disabled')).toBe('true');
expect(findEnableToggle().props('disabled')).toBe(true);
expect(findAllDisabledComponents().at(0).props('disabled')).toBe(true);
expect(findAllDisabledComponents().at(1).props('disabled')).toBe(true);
});
it('defaults to YAML mode', async () => {
await factory();
expect(findPolicyEditorLayout().attributes().defaulteditormode).toBe(EDITOR_MODE_YAML);
});
describe.each`
currentComponent | newValue | event
${findNameInput} | ${'new policy name'} | ${'input'}
${findDescriptionTextArea} | ${'new policy description'} | ${'input'}
${findEnableToggle} | ${true} | ${'change'}
`('triggering a change on $currentComponent', ({ currentComponent, newValue, event }) => {
it('updates YAML when switching modes', async () => {
await factory();
await currentComponent().vm.$emit(event, newValue);
await findPolicyEditorLayout().vm.$emit('update-editor-mode', EDITOR_MODE_YAML);
expect(findPolicyEditorLayout().attributes('yamleditorvalue')).toMatch(newValue.toString());
});
it('updates the yaml preview', async () => {
await factory();
await currentComponent().vm.$emit(event, newValue);
expect(findYamlPreview().html()).toMatch(newValue.toString());
});
});
it.each`
status | action | event | factoryFn | yamlEditorValue | currentlyAssignedPolicyProject
${'to save a new policy'} | ${SECURITY_POLICY_ACTIONS.APPEND} | ${'save-policy'} | ${factory} | ${DEFAULT_SCAN_RESULT_POLICY} | ${newlyCreatedPolicyProject}
......@@ -102,10 +177,12 @@ describe('ScanResultPolicyEditor', () => {
`(
'navigates to the new merge request when "modifyPolicy" is emitted $status',
async ({ action, event, factoryFn, yamlEditorValue, currentlyAssignedPolicyProject }) => {
factoryFn();
await nextTick();
await factoryFn();
findPolicyEditorLayout().vm.$emit(event);
await waitForPromises();
expect(modifyPolicy).toHaveBeenCalledWith({
action,
assignedPolicyProject: currentlyAssignedPolicyProject,
......@@ -125,7 +202,8 @@ describe('ScanResultPolicyEditor', () => {
describe('when a user is not an owner of the project', () => {
it('displays the empty state with the appropriate properties', async () => {
factory({ provide: { disableScanPolicyUpdate: true } });
await factory({ provide: { disableScanPolicyUpdate: true } });
const emptyState = findEmptyState();
expect(emptyState.props('primaryButtonLink')).toMatch(scanPolicyDocumentationPath);
......@@ -133,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);
});
});
});
......@@ -210,11 +210,11 @@ rules:
- main
scanners:
- container_scanning
vulnerability_allowed: 1
vulnerabilities_allowed: 1
severity_levels:
- critical
vulnerability_states:
- newly_added
- newly_detected
actions:
- type: require_approval
approvals_required: 1
......@@ -232,9 +232,9 @@ export const mockScanResultObject = {
type: 'scan_finding',
branches: ['main'],
scanners: ['container_scanning'],
vulnerability_allowed: 1,
vulnerabilities_allowed: 1,
severity_levels: ['critical'],
vulnerability_states: ['newly_added'],
vulnerability_states: ['newly_detected'],
},
],
actions: [
......
......@@ -31723,6 +31723,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 ""
......
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