Commit 9b7c462e authored by Savas Vedova's avatar Savas Vedova Committed by Miguel Rincon

Submit vulnerability form

This commit is part of a commit series which introduces a form
to manually create a vulnerability. It adds the submit vulnerability
button and handles the form submission.
parent 4211bf92
mutation vulnerabilityCreate($input: VulnerabilityCreateInput!) {
vulnerabilityCreate(input: $input) {
errors
vulnerability {
id
vulnerabilityPath
}
}
}
import { __ } from '~/locale';
export const REQUIRED_FIELD = __('This field is required.');
<script>
import { GlForm } from '@gitlab/ui';
import { GlForm, GlButton, GlAlert } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { s__ } from '~/locale';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPE_PROJECT } from '~/graphql_shared/constants';
import { redirectTo } from '~/lib/utils/url_utility';
import createVulnerabilityMutation from 'ee/security_dashboard/graphql/mutations/vulnerability_create.mutation.graphql';
import SectionDetails from './section_details.vue';
import SectionIdentifiers from './section_identifiers.vue';
import SectionName from './section_name.vue';
......@@ -10,11 +15,14 @@ export default {
name: 'NewVulnerabilityForm',
components: {
GlForm,
GlButton,
GlAlert,
SectionDetails,
SectionIdentifiers,
SectionName,
SectionSolution,
},
inject: ['projectId'],
data() {
return {
form: {
......@@ -25,18 +33,165 @@ export default {
detectionMethod: '',
identifiers: [],
},
validation: {
severity: null,
status: null,
name: null,
identifiers: [],
},
submitting: false,
errors: [],
};
},
computed: {
shouldShowAlert() {
return this.errors.length > 0;
},
},
watch: {
shouldShowAlert(newValue) {
if (newValue) {
this.scrollTop();
}
},
},
methods: {
scrollTop() {
this.$nextTick(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
},
async submitForm() {
this.errors = this.validateFormValues();
if (this.errors.length > 0) {
if (this.shouldShowAlert) {
this.scrollTop();
}
return;
}
this.submitting = true;
try {
const { data } = await this.$apollo.mutate({
mutation: createVulnerabilityMutation,
variables: {
input: {
project: convertToGraphQLId(TYPE_PROJECT, this.projectId),
name: this.form.vulnerabilityName,
description: this.form.vulnerabilityDesc,
severity: this.form.severity.toUpperCase(),
state: this.form.status.toUpperCase(),
identifiers: this.form.identifiers,
solution: this.form.solution,
// The scanner needs to be hardcoded because of two reasons:
// 1. It's a required field in the backend.
// 2. We expect that the manually created vulnerabilities are the ones that the scanners cannot catch.
// So most likely the scanner will be left out even if we present an option to choose which one.
scanner: {
id: 'gitlab-manual-vulnerability-report',
name: 'manually-created-vulnerability',
url: 'https://gitlab.com',
version: '1.0',
vendor: {
name: 'GitLab',
},
},
},
},
});
if (data.vulnerabilityCreate.vulnerability?.vulnerabilityPath) {
redirectTo(data.vulnerabilityCreate.vulnerability.vulnerabilityPath);
return;
}
if (data.vulnerabilityCreate.errors) {
this.errors = data.vulnerabilityCreate.errors;
} else {
throw new Error(this.$options.i18n.submitError);
}
} catch (error) {
this.errors = [this.$options.i18n.submitError];
Sentry.captureException({ error, component: this.$options.name });
}
this.submitting = false;
},
validateFormValues() {
const errors = [];
const {
vulnerabilityName,
vulnerabilityState,
vulnerabilitySeverity,
vulnerabilityIdentifiers,
} = this.$options.i18n.errors;
this.validation = {};
if (!this.form.vulnerabilityName) {
this.validation.name = false;
errors.push(vulnerabilityName);
}
if (!this.form.status) {
this.validation.status = false;
errors.push(vulnerabilityState);
}
if (!this.form.severity) {
this.validation.severity = false;
errors.push(vulnerabilitySeverity);
}
if (!this.form.identifiers?.length) {
this.validation.identifiers = [{ identifierCode: false, identifierUrl: false }];
errors.push(vulnerabilityIdentifiers);
} else {
this.validation.identifiers = [];
this.validation.identifiers = this.form.identifiers.map((item) => ({
identifierCode: Boolean(item.name),
identifierUrl: Boolean(item.url),
}));
if (this.validation.identifiers.find((i) => !i.identifierUrl || !i.identifierCode)) {
errors.push(vulnerabilityIdentifiers);
}
}
return errors;
},
updateFormValues(values) {
this.form = { ...this.form, ...values };
// If there are previous errors, revalidate the form.
if (this.errors.length) {
this.validateFormValues();
}
},
dismissAlert() {
this.errors = [];
},
},
i18n: {
title: s__('VulnerabilityManagement|Add vulnerability finding'),
submitVulnerability: s__('VulnerabilityManagement|Submit vulnerability'),
submitError: s__('VulnerabilityManagement|Something went wrong while creating vulnerability'),
description: s__(
'VulnerabilityManagement|Manually add a vulnerability entry into the vulnerability report.',
),
errors: {
vulnerabilityName: s__('VulnerabilityManagement|Name is a required field'),
vulnerabilitySeverity: s__('VulnerabilityManagement|Severity is a required field'),
vulnerabilityState: s__('VulnerabilityManagement|Status is a required field'),
vulnerabilityIdentifiers: s__('VulnerabilityManagement|At least one identifier is required'),
},
},
};
</script>
......@@ -51,11 +206,28 @@ export default {
{{ $options.i18n.description }}
</p>
</header>
<gl-form class="gl-p-4 gl-w-85p" @submit.prevent>
<section-name @change="updateFormValues" />
<section-details @change="updateFormValues" />
<section-identifiers @change="updateFormValues" />
<section-solution @change="updateFormValues" />
<gl-form @submit.prevent="submitForm">
<gl-alert v-if="shouldShowAlert" variant="danger" dismissible @dismiss="dismissAlert">
<ul v-if="errors.length > 1" class="gl-mb-0 gl-pl-5">
<li v-for="error in errors" :key="error">{{ error }}</li>
</ul>
<span v-else>{{ errors[0] }}</span>
</gl-alert>
<div class="gl-p-4 gl-w-85p">
<section-name :validation-state="validation" @change="updateFormValues" />
<section-details :validation-state="validation" @change="updateFormValues" />
<section-identifiers :validation-state="validation" @change="updateFormValues" />
<section-solution @change="updateFormValues" />
</div>
<div class="gl-mt-5 gl-pt-5 gl-border-t-gray-100 gl-border-t-solid gl-border-t-1">
<gl-button
type="submit"
variant="confirm"
class="js-no-auto-disable"
:disabled="submitting"
>{{ $options.i18n.submitVulnerability }}</gl-button
>
</div>
</gl-form>
</div>
</template>
......@@ -11,6 +11,7 @@ import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_ba
import { VULNERABILITY_STATE_OBJECTS } from 'ee/vulnerabilities/constants';
import { SEVERITY_LEVELS, DETECTION_METHODS } from 'ee/security_dashboard/store/constants';
import { s__, __ } from '~/locale';
import * as i18n from './i18n';
export default {
components: {
......@@ -22,6 +23,13 @@ export default {
GlFormRadioGroup,
SeverityBadge,
},
props: {
validationState: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
// Note: The cvss field is disabled during the MVC because the backend implementation
......@@ -33,6 +41,8 @@ export default {
statusId: '',
severity: '',
detectionMethod: -1,
severityState: null,
statusState: null,
};
},
computed: {
......@@ -98,6 +108,7 @@ export default {
critical: [9.0, 10.0],
},
i18n: {
requiredField: i18n.REQUIRED_FIELD,
title: s__('Vulnerability|Details'),
description: s__(
'Vulnerability|Information related how the vulnerability was discovered and its impact to the system.',
......@@ -152,6 +163,8 @@ export default {
<div class="gl-display-flex gl-mb-6">
<gl-form-group
:label="$options.i18n.severity.label"
:state="validationState.severity"
:invalid-feedback="$options.i18n.requiredField"
label-for="form-severity"
class="gl-mr-6 gl-mb-0"
>
......@@ -175,7 +188,11 @@ export default {
<gl-form-input id="form-cvss" v-model="cvss" class="gl-mb-2" type="text" />
</gl-form-group>
</div>
<gl-form-group :label="$options.i18n.status.label">
<gl-form-group
:label="$options.i18n.status.label"
:state="validationState.status"
:invalid-feedback="$options.i18n.requiredField"
>
<p>{{ $options.i18n.status.description }}</p>
<gl-form-radio-group :checked="statusId" @change="emitChanges">
<label
......
<script>
import { GlFormGroup, GlFormInput, GlButton } from '@gitlab/ui';
import { s__ } from '~/locale';
import * as i18n from './i18n';
export default {
components: {
......@@ -8,25 +9,52 @@ export default {
GlFormInput,
GlButton,
},
id: 0,
props: {
validationState: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
identifiers: [{ identifierCode: '', identifierUrl: '', id: this.$options.id }],
identifiers: [{ identifierCode: '', identifierUrl: '' }],
};
},
methods: {
emitChanges() {
this.$emit('change', { identifiers: this.identifiers });
this.$emit('change', {
identifiers: this.identifiers.map((i) => ({
name: i.identifierCode,
url: i.identifierUrl,
})),
});
},
addIdentifier() {
this.$options.id += 1;
this.identifiers.push({ identifierCode: '', identifierUrl: '', id: this.$options.id });
this.identifiers.push({ identifierCode: '', identifierUrl: '' });
},
removeIdentifier(index) {
this.identifiers.splice(index, 1);
this.emitChanges();
},
removeIdentifier(id) {
this.identifiers = this.identifiers.filter((i) => i.id !== id);
// null is when the user didn't input anything yet
// false when the user provided invalid input and
// true when the validation passes
validationStateIdentifierUrl(index) {
return this.validationState.identifiers?.[index]?.identifierUrl ?? null;
},
validationStateIdentifierCode(index) {
return this.validationState.identifiers?.[index]?.identifierCode ?? null;
},
rowHasError(index) {
return (
this.validationStateIdentifierUrl(index) === false ||
this.validationStateIdentifierCode(index) === false
);
},
},
i18n: {
requiredField: i18n.REQUIRED_FIELD,
title: s__('Vulnerability|Identifiers'),
description: s__(
'Vulnerability|Enter the associated CVE or CWE entries for this vulnerability.',
......@@ -50,41 +78,48 @@ export default {
</p>
</header>
<div
v-for="identifier in identifiers"
:key="identifier.id"
v-for="(identifier, index) in identifiers"
:key="index"
data-testid="identifier-row"
class="gl-display-flex gl-mb-6"
>
<gl-form-group
:label="$options.i18n.identifierCode"
:label-for="`form-identifier-code-${identifier.id}`"
:label-for="`form-identifier-code-${index}`"
:state="validationStateIdentifierCode(index)"
:invalid-feedback="$options.i18n.requiredField"
class="gl-mr-6 gl-mb-0"
>
<gl-form-input
:id="`form-identifier-code-${identifier.id}`"
v-model="identifier.identifierCode"
:id="`form-identifier-code-${index}`"
v-model.trim="identifier.identifierCode"
:state="validationStateIdentifierCode(index)"
type="text"
@change="emitChanges"
/>
</gl-form-group>
<gl-form-group
:label="$options.i18n.identifierUrl"
:label-for="`form-identifier-url-${identifier.id}`"
:state="validationStateIdentifierUrl(index)"
:invalid-feedback="$options.i18n.requiredField"
:label-for="`form-identifier-url-${index}`"
class="gl-flex-grow-1 gl-mb-0"
>
<gl-form-input
:id="`form-identifier-url-${identifier.id}`"
v-model="identifier.identifierUrl"
:id="`form-identifier-url-${index}`"
v-model.trim="identifier.identifierUrl"
:state="validationStateIdentifierUrl(index)"
type="text"
@change="emitChanges"
/>
</gl-form-group>
<gl-button
v-if="identifier.id > 0"
class="gl-align-self-end gl-ml-4 gl-shadow-none!"
v-if="index > 0"
class="gl-ml-4 gl-shadow-none!"
:class="rowHasError(index) ? 'gl-align-self-center' : 'gl-align-self-end'"
icon="remove"
:aria-label="$options.i18n.removeIdentifierRow"
@click="removeIdentifier(identifier.id)"
@click="removeIdentifier(index)"
/>
<!--
The first row does not contain a remove button and this creates
......@@ -98,12 +133,8 @@ export default {
icon="remove"
/>
</div>
<gl-button
data-testid="add-identifier-row"
category="secondary"
variant="confirm"
@click="addIdentifier"
>{{ $options.i18n.addIdentifier }}</gl-button
>
<gl-button category="secondary" variant="confirm" @click="addIdentifier">{{
$options.i18n.addIdentifier
}}</gl-button>
</section>
</template>
......@@ -2,6 +2,7 @@
import { GlFormGroup, GlFormInput, GlFormTextarea } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import * as i18n from './i18n';
export default {
components: {
......@@ -11,6 +12,13 @@ export default {
MarkdownField,
},
inject: ['markdownDocsPath', 'markdownPreviewPath'],
props: {
validationState: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
isSubmitting: false,
......@@ -27,6 +35,7 @@ export default {
},
},
i18n: {
requiredField: i18n.REQUIRED_FIELD,
vulnerabilityName: {
label: __('Name'),
description: s__(
......@@ -47,12 +56,15 @@ export default {
<gl-form-group
:label="$options.i18n.vulnerabilityName.label"
:description="$options.i18n.vulnerabilityName.description"
:state="validationState.name"
:invalid-feedback="$options.i18n.requiredField"
label-for="form-vulnerability-name"
class="gl-mb-6"
>
<gl-form-input
id="form-vulnerability-name"
v-model="vulnerabilityName"
v-model.trim="vulnerabilityName"
:state="validationState.name"
type="text"
@change="emitChanges"
/>
......@@ -76,7 +88,7 @@ export default {
<template #textarea>
<gl-form-textarea
id="form-vulnerability-desc"
v-model="vulnerabilityDesc"
v-model.trim="vulnerabilityDesc"
rows="8"
class="gl-shadow-none! gl-px-0! gl-py-4! gl-h-auto!"
:aria-label="$options.i18n.vulnerabilityDesc.description"
......
......@@ -13,6 +13,7 @@ export default (el) => {
provide: {
markdownDocsPath: el.dataset.markdownDocsPath,
markdownPreviewPath: el.dataset.markdownPreviewPath,
projectId: el.dataset.projectId,
},
render: (h) => h(App),
});
......
......@@ -4,4 +4,6 @@
- page_title _("Add vulnerability finding")
- add_page_specific_style 'page_bundles/security_dashboard'
#js-vulnerability-new{ data: { markdown_docs_path: help_page_path('user/markdown'), markdown_preview_path: preview_markdown_path(@project) } }
#js-vulnerability-new{ data: { markdown_docs_path: help_page_path('user/markdown'),
markdown_preview_path: preview_markdown_path(@project),
project_id: @project.id } }
import { nextTick } from 'vue';
import { GlDropdown, GlDropdownItem, GlFormRadio } from '@gitlab/ui';
import { GlFormGroup, GlDropdown, GlDropdownItem, GlFormRadio } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import SectionDetails from 'ee/vulnerabilities/components/new_vulnerability/section_details.vue';
import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue';
......@@ -7,6 +7,8 @@ import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_ba
describe('New vulnerability - Section Details', () => {
let wrapper;
const findFormGroup = (at) => wrapper.findAllComponents(GlFormGroup).at(at);
const findDetectionMethodItem = (at) =>
wrapper.findAllComponents(GlDropdown).at(0).findAllComponents(GlDropdownItem).at(at);
......@@ -78,4 +80,28 @@ describe('New vulnerability - Section Details', () => {
status: value,
});
});
it('does not display invalid state by default', () => {
expect(findFormGroup(1).attributes('aria-invalid')).toBeUndefined();
expect(findFormGroup(0).attributes('aria-invalid')).toBeUndefined();
});
it('handles form validation', async () => {
wrapper.setProps({
validationState: {
severity: false,
status: false,
},
});
await nextTick();
// severity input
expect(wrapper.findAllByRole('alert').at(0).text()).toBe('This field is required.');
expect(findFormGroup(1).attributes('aria-invalid')).toBe('true');
// status input
expect(wrapper.findAllByRole('alert').at(1).text()).toBe('This field is required.');
expect(findFormGroup(2).attributes('aria-invalid')).toBe('true');
});
});
import { nextTick } from 'vue';
import { GlFormGroup } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import SectionIdentifiers from 'ee/vulnerabilities/components/new_vulnerability/section_identifiers.vue';
......@@ -6,7 +7,11 @@ describe('New vulnerability - Section Identifiers', () => {
let wrapper;
const createWrapper = () => {
return mountExtended(SectionIdentifiers);
return mountExtended(SectionIdentifiers, {
propsData: {
validationState: { identifiers: [{ identifierCode: false }] },
},
});
};
beforeEach(() => {
......@@ -18,28 +23,56 @@ describe('New vulnerability - Section Identifiers', () => {
});
const findIdentifierRows = () => wrapper.findAllByTestId('identifier-row');
const findFormGroup = (index) => wrapper.findAllComponents(GlFormGroup).at(index);
const findIdentifierCodeInput = () => wrapper.findByLabelText('Identifier code');
const findIdentifierUrlInput = () => wrapper.findByLabelText('Identifier URL');
it('does not display a warning when the validation state is emtpy', async () => {
wrapper.setProps({
validationState: {
identifiers: [],
},
});
await nextTick();
expect(findFormGroup(1).attributes('aria-invalid')).toBeUndefined();
expect(findFormGroup(0).attributes('aria-invalid')).toBeUndefined();
});
describe.each`
labelText
${'Identifier code'}
${'Identifier URL'}
`('for input $labelText', ({ labelText }) => {
it(`displays the input with the correct label: "${labelText}"`, () => {
expect(wrapper.findByLabelText(labelText).exists()).toBe(true);
it('displays a warning when the validation fails', async () => {
wrapper.setProps({
validationState: {
identifiers: [{ identifierCode: false, identifierUrl: false }],
},
});
it('emits change even when input changes', () => {
wrapper.findByLabelText(labelText).trigger('change');
expect(wrapper.emitted('change')[0][0]).toEqual({
identifiers: [{ identifierCode: '', identifierUrl: '', id: 0 }],
});
await nextTick();
expect(findFormGroup(0).attributes('aria-invalid')).toBe('true');
expect(wrapper.findAllByRole('alert').at(0).text()).toBe('This field is required.');
expect(findFormGroup(1).attributes('aria-invalid')).toBe('true');
expect(wrapper.findAllByRole('alert').at(1).text()).toBe('This field is required.');
});
it('emits change event when input changes', () => {
const codeInput = findIdentifierCodeInput();
const urlInput = findIdentifierUrlInput();
codeInput.setValue('cve-23');
urlInput.setValue('https://gitlab.com');
codeInput.trigger('change');
expect(wrapper.emitted('change')[0][0]).toEqual({
identifiers: [{ name: 'cve-23', url: 'https://gitlab.com' }],
});
});
it('adds and removes identifier rows', async () => {
expect(findIdentifierRows()).toHaveLength(1);
wrapper.findByTestId('add-identifier-row').trigger('click');
wrapper.findByRole('button', { name: 'Add another identifier' }).trigger('click');
await nextTick();
expect(findIdentifierRows()).toHaveLength(2);
......
import { GlFormInput, GlFormTextarea } from '@gitlab/ui';
import { nextTick } from 'vue';
import { GlFormGroup, GlFormInput, GlFormTextarea } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import SectionName from 'ee/vulnerabilities/components/new_vulnerability/section_name.vue';
......@@ -9,6 +10,8 @@ describe('New vulnerability - Section Name', () => {
let wrapper;
const findFormGroup = (index) => wrapper.findAllComponents(GlFormGroup).at(index);
const createWrapper = () => {
return mountExtended(SectionName, {
provide: {
......@@ -50,14 +53,33 @@ describe('New vulnerability - Section Name', () => {
});
it.each`
field | component | value
${'Name'} | ${GlFormInput} | ${{ vulnerabilityName: 'CVE 2021', vulnerabilityDesc: '' }}
${'Description'} | ${GlFormTextarea} | ${{ vulnerabilityName: '', vulnerabilityDesc: 'Password leak' }}
`('emits the changes: $field ', async ({ component, value }) => {
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
// eslint-disable-next-line no-restricted-syntax
wrapper.setData(value);
wrapper.findComponent(component).vm.$emit('change', value);
expect(wrapper.emitted('change')[0][0]).toEqual(value);
field | component | fieldKey | fieldValue
${'Name'} | ${GlFormInput} | ${'vulnerabilityName'} | ${'CVE 2021'}
${'Description'} | ${GlFormTextarea} | ${'vulnerabilityDesc'} | ${'Password leak'}
`('emits the changes: $field ', async ({ component, fieldKey, fieldValue }) => {
wrapper.findComponent(component).setValue(fieldValue);
wrapper.findComponent(component).vm.$emit('change', fieldValue);
expect(wrapper.emitted('change')[0][0]).toEqual({
vulnerabilityName: '',
vulnerabilityDesc: '',
[fieldKey]: fieldValue,
});
});
it('does not display invalid state by default', () => {
expect(findFormGroup(0).attributes('aria-invalid')).toBeUndefined();
});
it('handles form validation', async () => {
wrapper.setProps({
validationState: {
name: false,
},
});
await nextTick();
expect(wrapper.findByRole('alert').text()).toBe('This field is required.');
expect(findFormGroup(0).attributes('aria-invalid')).toBe('true');
});
});
......@@ -40167,6 +40167,9 @@ msgstr ""
msgid "VulnerabilityManagement|An unverified non-confirmed finding"
msgstr ""
msgid "VulnerabilityManagement|At least one identifier is required"
msgstr ""
msgid "VulnerabilityManagement|Change status"
msgstr ""
......@@ -40182,6 +40185,9 @@ msgstr ""
msgid "VulnerabilityManagement|Manually add a vulnerability entry into the vulnerability report."
msgstr ""
msgid "VulnerabilityManagement|Name is a required field"
msgstr ""
msgid "VulnerabilityManagement|Needs triage"
msgstr ""
......@@ -40197,6 +40203,12 @@ msgstr ""
msgid "VulnerabilityManagement|Select a method"
msgstr ""
msgid "VulnerabilityManagement|Severity is a required field"
msgstr ""
msgid "VulnerabilityManagement|Something went wrong while creating vulnerability"
msgstr ""
msgid "VulnerabilityManagement|Something went wrong while trying to delete the comment. Please try again later."
msgstr ""
......@@ -40221,6 +40233,12 @@ msgstr ""
msgid "VulnerabilityManagement|Something went wrong, could not update vulnerability state."
msgstr ""
msgid "VulnerabilityManagement|Status is a required field"
msgstr ""
msgid "VulnerabilityManagement|Submit vulnerability"
msgstr ""
msgid "VulnerabilityManagement|Summary, detailed description, steps to reproduce, etc."
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