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