Commit 45329daf authored by Zamir Martins's avatar Zamir Martins Committed by Simon Knox

Add main body for scan result policy rule mode

to be further extended with policy rule and
action builders.

EE: true
parent 15637332
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: [
......
......@@ -31738,6 +31738,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