diff --git a/ee/app/assets/javascripts/security_dashboard/graphql/mutations/vulnerability_create.mutation.graphql b/ee/app/assets/javascripts/security_dashboard/graphql/mutations/vulnerability_create.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..c562e809cbf23ad90e75df94976b804f75733210 --- /dev/null +++ b/ee/app/assets/javascripts/security_dashboard/graphql/mutations/vulnerability_create.mutation.graphql @@ -0,0 +1,9 @@ +mutation vulnerabilityCreate($input: VulnerabilityCreateInput!) { + vulnerabilityCreate(input: $input) { + errors + vulnerability { + id + vulnerabilityPath + } + } +} diff --git a/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/i18n.js b/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/i18n.js new file mode 100644 index 0000000000000000000000000000000000000000..fa52eef7f76a4d9873fee96456472a52d4368c0e --- /dev/null +++ b/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/i18n.js @@ -0,0 +1,3 @@ +import { __ } from '~/locale'; + +export const REQUIRED_FIELD = __('This field is required.'); diff --git a/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/new_vulnerability.vue b/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/new_vulnerability.vue index 896aef742095049cc375e9f5e75c1e6e3e04ab2a..ba054a8778fa695eb3371b81cc346afe12fc763b 100644 --- a/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/new_vulnerability.vue +++ b/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/new_vulnerability.vue @@ -1,6 +1,11 @@ <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> diff --git a/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/section_details.vue b/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/section_details.vue index 145d35b911848fb25ef8cc75465374175b0edcd6..d3f306671ab06d469055b92fc76dbd3c261e6ef7 100644 --- a/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/section_details.vue +++ b/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/section_details.vue @@ -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 diff --git a/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/section_identifiers.vue b/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/section_identifiers.vue index 4eede6ff61b182ce9f54a7f0b23fd60453f34405..7cc4f2c19fa07301332bcdf88b655be0649f8594 100644 --- a/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/section_identifiers.vue +++ b/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/section_identifiers.vue @@ -1,6 +1,7 @@ <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> diff --git a/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/section_name.vue b/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/section_name.vue index 3799691b615d2b55310667c84ac7bb11403c7f1c..d5c04410328f442277786e76bc8d9c9fae6cd71a 100644 --- a/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/section_name.vue +++ b/ee/app/assets/javascripts/vulnerabilities/components/new_vulnerability/section_name.vue @@ -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" diff --git a/ee/app/assets/javascripts/vulnerabilities/new_vulnerability_init.js b/ee/app/assets/javascripts/vulnerabilities/new_vulnerability_init.js index e156d6bb7271c36c25dbf4e6110ac84900e20b8b..a7a99d70180204293b013b11313fedeb15b5e420 100644 --- a/ee/app/assets/javascripts/vulnerabilities/new_vulnerability_init.js +++ b/ee/app/assets/javascripts/vulnerabilities/new_vulnerability_init.js @@ -13,6 +13,7 @@ export default (el) => { provide: { markdownDocsPath: el.dataset.markdownDocsPath, markdownPreviewPath: el.dataset.markdownPreviewPath, + projectId: el.dataset.projectId, }, render: (h) => h(App), }); diff --git a/ee/app/views/projects/security/vulnerabilities/new.html.haml b/ee/app/views/projects/security/vulnerabilities/new.html.haml index 80b404661af21a41e147dc4fac5418c4e734d815..18b35f0f4a1a00d43e6f2480a882d2c331e3d3d3 100644 --- a/ee/app/views/projects/security/vulnerabilities/new.html.haml +++ b/ee/app/views/projects/security/vulnerabilities/new.html.haml @@ -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 } } diff --git a/ee/spec/frontend/vulnerabilities/new_vulnerability/new_vulnerability_spec.js b/ee/spec/frontend/vulnerabilities/new_vulnerability/new_vulnerability_spec.js index 0a2068b75b93ac9fce085cbf9b4a7a2d4ea807e0..e41c3cbdf2191fea2f88888e8f6a92e231db401d 100644 --- a/ee/spec/frontend/vulnerabilities/new_vulnerability/new_vulnerability_spec.js +++ b/ee/spec/frontend/vulnerabilities/new_vulnerability/new_vulnerability_spec.js @@ -1,54 +1,246 @@ -import { GlForm } from '@gitlab/ui'; +import Vue, { nextTick } from 'vue'; +import { GlForm, GlAlert, GlButton } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import * as Sentry from '@sentry/browser'; +import { redirectTo } from '~/lib/utils/url_utility'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createVulnerabilityMutation from 'ee/security_dashboard/graphql/mutations/vulnerability_create.mutation.graphql'; import NewVulnerability from 'ee/vulnerabilities/components/new_vulnerability/new_vulnerability.vue'; import SectionName from 'ee/vulnerabilities/components/new_vulnerability/section_name.vue'; import SectionIdentifiers from 'ee/vulnerabilities/components/new_vulnerability/section_identifiers.vue'; import SectionDetails from 'ee/vulnerabilities/components/new_vulnerability/section_details.vue'; import SectionSolution from 'ee/vulnerabilities/components/new_vulnerability/section_solution.vue'; +Vue.use(VueApollo); + +jest.mock('@sentry/browser'); + +jest.mock('~/lib/utils/url_utility', () => ({ + redirectTo: jest.fn(), +})); + describe('New vulnerability component', () => { + const projectId = '22'; let wrapper; + const inputs = { + sectionName: { + vulnerabilityName: 'CVE 2050', + vulnerabilityDesc: 'Password leak', + }, + sectionDetails: { + severity: 'low', + detectionMethod: 2, + status: 'confirmed', + }, + sectionIdentifiers: { + identifiers: [ + { + name: 'CWE-94', + url: 'https://cwe.mitre.org/data/definitions/94.html', + }, + ], + }, + sectionSolution: { + solution: 'This is the solution of the vulnerability.', + }, + }; + + const findForm = () => wrapper.findComponent(GlForm); + const findAlert = () => wrapper.findComponent(GlAlert); const findSectionName = () => wrapper.findComponent(SectionName); const findSectionDetails = () => wrapper.findComponent(SectionDetails); const findSectionSolution = () => wrapper.findComponent(SectionSolution); const findSectionIdentifiers = () => wrapper.findComponent(SectionIdentifiers); + const findSubmitButton = () => wrapper.findComponent(GlButton); - const createWrapper = () => { - return shallowMountExtended(NewVulnerability); + const createWrapper = ({ apolloProvider } = {}) => { + return shallowMountExtended(NewVulnerability, { + apolloProvider, + provide: { + projectId, + }, + }); }; - beforeEach(() => { - wrapper = createWrapper(); - }); - afterEach(() => { wrapper.destroy(); }); - it('should render the page title and description', () => { - expect(wrapper.findByRole('heading', { name: 'Add vulnerability finding' }).exists()).toBe( - true, - ); - expect(wrapper.findByTestId('page-description').text()).toBe( - 'Manually add a vulnerability entry into the vulnerability report.', - ); - }); + describe('page structure', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('should render the page title and description', () => { + expect(wrapper.findByRole('heading', { name: 'Add vulnerability finding' }).exists()).toBe( + true, + ); + expect(wrapper.findByTestId('page-description').text()).toBe( + 'Manually add a vulnerability entry into the vulnerability report.', + ); + }); + + it('contains a form', () => { + expect(wrapper.findComponent(GlForm).exists()).toBe(true); + }); + + it.each` + section | selector | fields + ${'Name and Description'} | ${findSectionName} | ${inputs.sectionName} + ${'Details'} | ${findSectionDetails} | ${inputs.sectionDetails} + ${'Identifiers'} | ${findSectionIdentifiers} | ${inputs.sectionIdentifiers} + ${'Solution'} | ${findSectionSolution} | ${inputs.sectionSolution} + `('mounts the section $section and reacts on the change event', ({ selector, fields }) => { + const section = selector(); + expect(section.exists()).toBe(true); + section.vm.$emit('change', fields); + expect(wrapper.vm.form).toMatchObject(fields); + }); - it('contains a form', () => { - expect(wrapper.findComponent(GlForm).exists()).toBe(true); + it('contains a submit button', () => { + expect(findSubmitButton().exists()).toBe(true); + }); }); - it.each` - section | selector | fields - ${'Name and Description'} | ${findSectionName} | ${{ vulnerabilityName: 'CVE 2050', vulnerabilityDesc: 'Password leak' }} - ${'Details'} | ${findSectionDetails} | ${{ severity: 'low', detectionMethod: 2, status: 'confirmed' }} - ${'Identifiers'} | ${findSectionIdentifiers} | ${{ identifiers: [{ identifierCode: 'CWE-94', IdentifierUrl: 'https://cwe.mitre.org/data/definitions/94.html' }] }} - ${'Solution'} | ${findSectionSolution} | ${{ solution: 'This is the solution of the vulnerability.' }} - `('mounts the section $section and reacts on the change event', ({ selector, fields }) => { - const section = selector(); - expect(section.exists()).toBe(true); - section.vm.$emit('change', fields); - expect(wrapper.vm.form).toMatchObject(fields); + describe('form submission', () => { + const updateFormValuesAndSubmitForm = async () => { + findSectionName().vm.$emit('change', inputs.sectionName); + findSectionIdentifiers().vm.$emit('change', inputs.sectionIdentifiers); + findSectionDetails().vm.$emit('change', inputs.sectionDetails); + findSectionSolution().vm.$emit('change', inputs.sectionSolution); + findForm().vm.$emit('submit', { preventDefault: jest.fn() }); + await waitForPromises(); + }; + + it('handles form validation', async () => { + const validationState = { + severity: false, + status: false, + name: false, + identifiers: [ + { + identifierCode: false, + identifierUrl: false, + }, + ], + }; + + const apolloProvider = createMockApollo([ + [createVulnerabilityMutation, jest.fn().mockResolvedValue()], + ]); + + wrapper = createWrapper({ apolloProvider }); + const mutateSpy = jest.spyOn(wrapper.vm.$apollo, 'mutate'); + findForm().vm.$emit('submit', { preventDefault: jest.fn() }); + await waitForPromises(); + expect(mutateSpy).not.toHaveBeenCalled(); + expect(findSectionDetails().props('validationState')).toEqual(validationState); + expect(findSectionName().props('validationState')).toEqual(validationState); + expect(findSectionIdentifiers().props('validationState')).toEqual(validationState); + }); + + it('submits the form successfully', async () => { + const apolloProvider = createMockApollo([ + [ + createVulnerabilityMutation, + jest.fn().mockResolvedValue({ + data: { + vulnerabilityCreate: { + vulnerability: { + id: 'gid://gitlab/Vulnerability/20345379', + vulnerabilityPath: '/path/to/vulnerability/20345379', + }, + errors: null, + }, + }, + }), + ], + ]); + + wrapper = createWrapper({ apolloProvider }); + const mutateSpy = jest.spyOn(wrapper.vm.$apollo, 'mutate'); + await updateFormValuesAndSubmitForm(); + + expect(mutateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + description: 'Password leak', + identifiers: [ + { + url: 'https://cwe.mitre.org/data/definitions/94.html', + name: 'CWE-94', + }, + ], + scanner: { + id: 'gitlab-manual-vulnerability-report', + name: 'manually-created-vulnerability', + url: 'https://gitlab.com', + version: '1.0', + vendor: { + name: 'GitLab', + }, + }, + name: 'CVE 2050', + project: 'gid://gitlab/Project/22', + severity: 'LOW', + solution: 'This is the solution of the vulnerability.', + state: 'CONFIRMED', + }, + }, + }), + ); + + expect(redirectTo).toHaveBeenCalledWith('/path/to/vulnerability/20345379'); + expect(findAlert().exists()).toBe(false); + }); + + it('handles form submission error and displays alert component when there are errors', async () => { + const apolloProvider = createMockApollo([ + [ + createVulnerabilityMutation, + jest.fn().mockResolvedValue({ + data: { + vulnerabilityCreate: { + vulnerability: null, + errors: [{ message: 'Something went wrong' }], + }, + }, + }), + ], + ]); + + wrapper = createWrapper({ apolloProvider }); + await updateFormValuesAndSubmitForm(); + expect(redirectTo).not.toHaveBeenCalled(); + expect(Sentry.captureException).not.toHaveBeenCalled(); + await nextTick(); + expect(findAlert().exists()).toBe(true); + }); + + it('handles form submission error and logs to sentry when the error is unknown', async () => { + const apolloProvider = createMockApollo([ + [ + createVulnerabilityMutation, + jest.fn().mockRejectedValue({ + data: { + vulnerabilityCreate: { + vulnerability: null, + }, + }, + }), + ], + ]); + + wrapper = createWrapper({ apolloProvider }); + await updateFormValuesAndSubmitForm(); + expect(redirectTo).not.toHaveBeenCalled(); + expect(Sentry.captureException).toHaveBeenCalled(); + await nextTick(); + expect(findAlert().exists()).toBe(true); + }); }); }); diff --git a/ee/spec/frontend/vulnerabilities/new_vulnerability/section_details_spec.js b/ee/spec/frontend/vulnerabilities/new_vulnerability/section_details_spec.js index 0a5957ab58fb6dfaf91284392b180e6f7e69a732..5b1705705fa0c0c85c2cf7c7433d85aeed1009b1 100644 --- a/ee/spec/frontend/vulnerabilities/new_vulnerability/section_details_spec.js +++ b/ee/spec/frontend/vulnerabilities/new_vulnerability/section_details_spec.js @@ -1,5 +1,5 @@ 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'); + }); }); diff --git a/ee/spec/frontend/vulnerabilities/new_vulnerability/section_identifiers_spec.js b/ee/spec/frontend/vulnerabilities/new_vulnerability/section_identifiers_spec.js index 895ddbc8f42468bcff7bbfedcfa9e0e177ebb0d7..a7758233b750d0207058b8d3ac3977e29570e295 100644 --- a/ee/spec/frontend/vulnerabilities/new_vulnerability/section_identifiers_spec.js +++ b/ee/spec/frontend/vulnerabilities/new_vulnerability/section_identifiers_spec.js @@ -1,4 +1,5 @@ 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); diff --git a/ee/spec/frontend/vulnerabilities/new_vulnerability/section_name_spec.js b/ee/spec/frontend/vulnerabilities/new_vulnerability/section_name_spec.js index f37526b2cee6c5cc9b2da0c2773718ede471f30e..7ba5cd1a9b45a4b9902fe45e5083695409ae3314 100644 --- a/ee/spec/frontend/vulnerabilities/new_vulnerability/section_name_spec.js +++ b/ee/spec/frontend/vulnerabilities/new_vulnerability/section_name_spec.js @@ -1,4 +1,5 @@ -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'); }); }); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 3a9358c4e088250a2e9a0d35a9a01c7282620891..9f4ddb47b76ff21b73f1539b5ff50d6196b58150 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 ""