Add ability to edit existing scans

This adds the ability to edit existing on-demnand DAST scans by exposing
a new dastScan prop on the on-demand scans form which is used to
populate the fields. It also implements the logic required to trigger
the dastScanUpdate mutation instead of dastScanCreate when in edit mode.
parent 5b88d65c
......@@ -18,6 +18,7 @@ import {
} from 'ee/security_configuration/dast_scanner_profiles/constants';
import { DAST_SITE_VALIDATION_STATUS } from 'ee/security_configuration/dast_site_validation/constants';
import { initFormField } from 'ee/security_configuration/utils';
import { s__ } from '~/locale';
import validation from '~/vue_shared/directives/validation';
import * as Sentry from '~/sentry/wrapper';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
......@@ -32,6 +33,7 @@ import {
SITE_PROFILES_EXTENDED_QUERY,
} from '../settings';
import dastScanCreateMutation from '../graphql/dast_scan_create.mutation.graphql';
import dastScanUpdateMutation from '../graphql/dast_scan_update.mutation.graphql';
import dastOnDemandScanCreateMutation from '../graphql/dast_on_demand_scan_create.mutation.graphql';
import ProfileSelectorSummaryCell from './profile_selector/summary_cell.vue';
import ScannerProfileSelector from './profile_selector/scanner_profile_selector.vue';
......@@ -111,20 +113,13 @@ export default {
required: false,
default: '',
},
dastScan: {
type: Object,
required: false,
default: null,
},
inject: {
scannerProfilesLibraryPath: {
default: '',
},
siteProfilesLibraryPath: {
default: '',
},
newScannerProfilePath: {
default: '',
},
newSiteProfilePath: {
default: '',
},
inject: {
dastSiteValidationDocsPath: {
default: '',
},
......@@ -136,8 +131,12 @@ export default {
showValidation: false,
state: false,
fields: {
name: initFormField({ value: '' }),
description: initFormField({ value: '', required: false, skipValidation: true }),
name: initFormField({ value: this.dastScan?.name || '' }),
description: initFormField({
value: this.dastScan?.description || '',
required: false,
skipValidation: true,
}),
},
},
}
......@@ -146,8 +145,8 @@ export default {
...savedScansFields,
scannerProfiles: [],
siteProfiles: [],
selectedScannerProfileId: null,
selectedSiteProfileId: null,
selectedScannerProfileId: this.dastScan?.scannerProfileId || null,
selectedSiteProfileId: this.dastScan?.siteProfileId || null,
loading: false,
errorType: null,
errors: [],
......@@ -155,6 +154,14 @@ export default {
};
},
computed: {
isEdit() {
return Boolean(this.dastScan?.id);
},
title() {
return this.isEdit
? s__('OnDemandScans|Edit on-demand DAST scan')
: s__('OnDemandScans|New on-demand DAST scan');
},
selectedScannerProfile() {
return this.selectedScannerProfileId
? this.scannerProfiles.find(({ id }) => id === this.selectedScannerProfileId)
......@@ -231,10 +238,11 @@ export default {
dastSiteProfileId: this.selectedSiteProfile.id,
};
if (this.glFeatures.dastSavedScans) {
mutation = dastScanCreateMutation;
reponseType = 'dastScanCreate';
mutation = this.isEdit ? dastScanUpdateMutation : dastScanCreateMutation;
reponseType = this.isEdit ? 'dastScanUpdate' : 'dastScanCreate';
input = {
...input,
...(this.isEdit ? { id: this.dastScan.id } : {}),
name: this.form.fields.name.value,
description: this.form.fields.description.value,
runAfterCreate,
......@@ -283,7 +291,7 @@ export default {
<template>
<gl-form novalidate @submit.prevent="onSubmit()">
<header class="gl-mb-6">
<h2>{{ s__('OnDemandScans|New on-demand DAST scan') }}</h2>
<h2>{{ title }}</h2>
<p>
<gl-sprintf
:message="
......
mutation dastScanUpdate(
$id: DastScanID!
$fullPath: ID!
$name: String!
$description: String
$dastSiteProfileId: DastSiteProfileID!
$dastScannerProfileID: DastScannerProfileID!
$runAfterCreate: Boolean
) {
dastScanUpdate(
input: {
id: $id
fullPath: $fullPath
name: $name
description: $description
dastSiteProfileId: $dastSiteProfileId
dastScannerProfileID: $dastScannerProfileID
runAfterCreate: $runAfterCreate
}
) @client {
dastScan {
editPath
}
pipelineUrl
errors
}
}
import Vue from 'vue';
import apolloProvider from './graphql/provider';
import OnDemandScansApp from './components/on_demand_scans_app.vue';
import OnDemandScansForm from './components/on_demand_scans_form.vue';
export default () => {
const el = document.querySelector('#js-on-demand-scans-app');
......@@ -10,7 +10,6 @@ export default () => {
const {
dastSiteValidationDocsPath,
emptyStateSvgPath,
projectPath,
defaultBranch,
scannerProfilesLibraryPath,
......@@ -18,6 +17,7 @@ export default () => {
newSiteProfilePath,
newScannerProfilePath,
helpPagePath,
dastScan,
} = el.dataset;
return new Vue({
......@@ -31,12 +31,12 @@ export default () => {
dastSiteValidationDocsPath,
},
render(h) {
return h(OnDemandScansApp, {
return h(OnDemandScansForm, {
props: {
helpPagePath,
emptyStateSvgPath,
projectPath,
defaultBranch,
dastScan: dastScan ? JSON.parse(dastScan) : null,
},
});
},
......
......@@ -22,6 +22,13 @@ module Projects
def edit
not_found unless Feature.enabled?(:dast_saved_scans, @project, default_enabled: :yaml)
@dast_scan = {
id: 1,
name: "My saved DAST scan",
description: "My scan's description",
scannerProfileId: "gid://gitlab/DastScannerProfile/5",
siteProfileId: "gid://gitlab/DastSiteProfile/15"
}
end
end
end
- breadcrumb_title s_('OnDemandScans|Edit on-demand DAST scan')
- page_title s_('OnDemandScans|Edit on-demand DAST scan')
#js-on-demand-scans-app{ data: on_demand_scans_data(@project) }
#js-on-demand-scans-app{ data: on_demand_scans_data(@project).merge({dast_scan: @dast_scan.to_json}) }
......@@ -7,6 +7,7 @@ import OnDemandScansForm from 'ee/on_demand_scans/components/on_demand_scans_for
import ScannerProfileSelector from 'ee/on_demand_scans/components/profile_selector/scanner_profile_selector.vue';
import SiteProfileSelector from 'ee/on_demand_scans/components/profile_selector/site_profile_selector.vue';
import dastScanCreateMutation from 'ee/on_demand_scans/graphql/dast_scan_create.mutation.graphql';
import dastScanUpdateMutation from 'ee/on_demand_scans/graphql/dast_scan_update.mutation.graphql';
import dastOnDemandScanCreateMutation from 'ee/on_demand_scans/graphql/dast_on_demand_scan_create.mutation.graphql';
import dastScannerProfilesQuery from 'ee/security_configuration/dast_profiles/graphql/dast_scanner_profiles.query.graphql';
import dastSiteProfilesQuery from 'ee/security_configuration/dast_profiles/graphql/dast_site_profiles.query.graphql';
......@@ -33,6 +34,13 @@ const pipelineUrl = `/${projectPath}/pipelines/123`;
const editPath = `/${projectPath}/on_demand_scans/1/edit`;
const [passiveScannerProfile, activeScannerProfile] = scannerProfiles;
const [nonValidatedSiteProfile, validatedSiteProfile] = siteProfiles;
const dastScan = {
id: 1,
name: 'My daily scan',
description: 'Tests for SQL injections',
scannerProfileId: passiveScannerProfile.id,
siteProfileId: validatedSiteProfile.id,
};
jest.mock('~/lib/utils/url_utility', () => ({
isAbsolute: jest.requireActual('~/lib/utils/url_utility').isAbsolute,
......@@ -51,6 +59,9 @@ describe('OnDemandScansForm', () => {
const findForm = () => subject.find(GlForm);
const findByTestId = (testId) => subject.find(`[data-testid="${testId}"]`);
const findNameInput = () => findByTestId('dast-scan-name-input');
const findDescriptionInput = () => findByTestId('dast-scan-description-input');
const findScannerProfilesSelector = () => subject.find(ScannerProfileSelector);
const findSiteProfilesSelector = () => subject.find(SiteProfileSelector);
const findAlert = () => findByTestId('on-demand-scan-error');
const findProfilesConflictAlert = () => findByTestId('on-demand-scans-profiles-conflict-alert');
const findSubmitButton = () => findByTestId('on-demand-scan-submit-button');
......@@ -58,13 +69,19 @@ describe('OnDemandScansForm', () => {
const setValidFormData = () => {
findNameInput().vm.$emit('input', 'My daily scan');
subject.find(ScannerProfileSelector).vm.$emit('input', passiveScannerProfile.id);
subject.find(SiteProfileSelector).vm.$emit('input', nonValidatedSiteProfile.id);
findScannerProfilesSelector().vm.$emit('input', passiveScannerProfile.id);
findSiteProfilesSelector().vm.$emit('input', nonValidatedSiteProfile.id);
return subject.vm.$nextTick();
};
const setupSuccess = () => {
const setupSuccess = (edit = false) => {
jest.spyOn(subject.vm.$apollo, 'mutate').mockResolvedValue({
data: { dastScanCreate: { dastScan: { editPath }, pipelineUrl, errors: [] } },
data: {
[edit ? 'dastScanUpdate' : 'dastScanCreate']: {
dastScan: { editPath },
pipelineUrl,
errors: [],
},
},
});
return setValidFormData();
};
......@@ -121,11 +138,18 @@ describe('OnDemandScansForm', () => {
dastSavedScans: true,
},
},
stubs: {
GlFormInput: GlFormInputStub,
},
},
{ ...options, localVue, apolloProvider },
{
data() {
return { ...options.data };
return {
scannerProfiles,
siteProfiles,
...options.data,
};
},
},
),
......@@ -141,7 +165,7 @@ describe('OnDemandScansForm', () => {
it('renders properly', () => {
mountSubject();
expect(subject.html()).not.toBe('');
expect(subject.text()).toContain('New on-demand DAST scan');
});
it.each`
......@@ -168,16 +192,32 @@ describe('OnDemandScansForm', () => {
},
);
describe('submit button', () => {
let submitButton;
describe('when editing an existing scan', () => {
beforeEach(() => {
mountShallowSubject({
data: {
scannerProfiles,
siteProfiles,
propsData: {
dastScan,
},
});
});
it('sets the title properly', () => {
expect(subject.text()).toContain('Edit on-demand DAST scan');
});
it('populates the fields with passed values', () => {
expect(findNameInput().attributes('value')).toBe(dastScan.name);
expect(findDescriptionInput().attributes('value')).toBe(dastScan.description);
expect(findScannerProfilesSelector().attributes('value')).toBe(dastScan.scannerProfileId);
expect(findSiteProfilesSelector().attributes('value')).toBe(dastScan.siteProfileId);
});
});
describe('submit button', () => {
let submitButton;
beforeEach(() => {
mountShallowSubject();
submitButton = findSubmitButton();
});
......@@ -193,18 +233,6 @@ describe('OnDemandScansForm', () => {
});
describe('submission', () => {
beforeEach(() => {
mountShallowSubject({
data: {
scannerProfiles,
siteProfiles,
},
stubs: {
GlFormInput: GlFormInputStub,
},
});
});
describe.each`
action | actionFunction | submitButtonLoading | saveButtonLoading | runAfterCreate | redirectPath
${'submit'} | ${submitForm} | ${true} | ${false} | ${true} | ${pipelineUrl}
......@@ -220,6 +248,7 @@ describe('OnDemandScansForm', () => {
}) => {
describe('with valid form data', () => {
beforeEach(async () => {
mountShallowSubject();
await setupSuccess();
actionFunction();
});
......@@ -233,7 +262,7 @@ describe('OnDemandScansForm', () => {
expect(saveButton.props('disabled')).toBe(!saveButtonLoading);
});
it(`triggers GraphQL mutation with runAfterCreate set to ${runAfterCreate}`, async () => {
it(`triggers dastScanCreateMutation mutation with runAfterCreate set to ${runAfterCreate}`, async () => {
expect(subject.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: dastScanCreateMutation,
variables: {
......@@ -258,7 +287,37 @@ describe('OnDemandScansForm', () => {
});
});
describe('when editing an existing scan', () => {
beforeEach(async () => {
mountShallowSubject({
propsData: {
dastScan,
},
});
await setupSuccess(true);
actionFunction();
});
it(`triggers dastScanUpdateMutation mutation with runAfterCreate set to ${runAfterCreate}`, async () => {
expect(subject.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: dastScanUpdateMutation,
variables: {
input: {
id: 1,
name: 'My daily scan',
description: 'Tests for SQL injections',
dastScannerProfileId: passiveScannerProfile.id,
dastSiteProfileId: nonValidatedSiteProfile.id,
fullPath: projectPath,
runAfterCreate,
},
},
});
});
});
it('does not run any mutation if name is empty', () => {
mountShallowSubject();
setValidFormData();
findNameInput().vm.$emit('input', '');
actionFunction();
......@@ -270,6 +329,7 @@ describe('OnDemandScansForm', () => {
describe('on top-level error', () => {
beforeEach(async () => {
mountShallowSubject();
jest.spyOn(subject.vm.$apollo, 'mutate').mockRejectedValue();
await setValidFormData();
submitForm();
......@@ -290,6 +350,7 @@ describe('OnDemandScansForm', () => {
const errors = ['error#1', 'error#2', 'error#3'];
beforeEach(async () => {
mountShallowSubject();
jest
.spyOn(subject.vm.$apollo, 'mutate')
.mockResolvedValue({ data: { dastScanCreate: { pipelineUrl: null, errors } } });
......@@ -315,13 +376,6 @@ describe('OnDemandScansForm', () => {
describe('dastSavedScans feature flag disabled', () => {
beforeEach(async () => {
mountShallowSubject({
data: {
scannerProfiles,
siteProfiles,
},
stubs: {
GlFormInput: GlFormInputStub,
},
provide: {
glFeatures: {
dastSavedScans: false,
......@@ -370,12 +424,7 @@ describe('OnDemandScansForm', () => {
? `warns about conflicting profiles when user selects ${description}`
: `does not report any conflict when user selects ${description}`,
async () => {
mountShallowSubject({
data: {
scannerProfiles,
siteProfiles,
},
});
mountShallowSubject();
await setFormData();
expect(findProfilesConflictAlert().exists()).toBe(hasConflict);
......@@ -391,10 +440,6 @@ describe('OnDemandScansForm', () => {
securityOnDemandScansSiteValidation: false,
},
},
data: {
scannerProfiles,
siteProfiles,
},
});
return setFormData();
});
......@@ -443,9 +488,6 @@ describe('OnDemandScansForm', () => {
securityDastSiteProfilesAdditionalFields: true,
},
},
data: {
siteProfiles,
},
});
});
......
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