Commit 231f07c4 authored by Mark Chao's avatar Mark Chao

Merge branch '295242-saved-scans-edit-mode' into 'master'

Add ability to edit existing on-demand DAST scans

See merge request gitlab-org/gitlab!50721
parents 4098fffe 29b044bd
...@@ -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)
...@@ -213,7 +220,7 @@ export default { ...@@ -213,7 +220,7 @@ export default {
}, },
}, },
methods: { methods: {
onSubmit(runAfterCreate = true, button = this.$options.saveAndRunScanBtnId) { onSubmit({ runAfterCreate = true, button = this.$options.saveAndRunScanBtnId } = {}) {
if (this.glFeatures.dastSavedScans) { if (this.glFeatures.dastSavedScans) {
this.form.showValidation = true; this.form.showValidation = true;
if (!this.form.state) { if (!this.form.state) {
...@@ -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="
...@@ -497,7 +505,7 @@ export default { ...@@ -497,7 +505,7 @@ export default {
data-testid="on-demand-scan-save-button" data-testid="on-demand-scan-save-button"
:disabled="isSaveButtonDisabled" :disabled="isSaveButtonDisabled"
:loading="loading === $options.saveScanBtnId" :loading="loading === $options.saveScanBtnId"
@click="onSubmit(false, $options.saveScanBtnId)" @click="onSubmit({ runAfterCreate: false, button: $options.saveScanBtnId })"
> >
{{ s__('OnDemandScans|Save scan') }} {{ s__('OnDemandScans|Save scan') }}
</gl-button> </gl-button>
......
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 { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
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 +11,6 @@ export default () => { ...@@ -10,7 +11,6 @@ export default () => {
const { const {
dastSiteValidationDocsPath, dastSiteValidationDocsPath,
emptyStateSvgPath,
projectPath, projectPath,
defaultBranch, defaultBranch,
scannerProfilesLibraryPath, scannerProfilesLibraryPath,
...@@ -18,6 +18,7 @@ export default () => { ...@@ -18,6 +18,7 @@ export default () => {
newSiteProfilePath, newSiteProfilePath,
newScannerProfilePath, newScannerProfilePath,
helpPagePath, helpPagePath,
dastScan,
} = el.dataset; } = el.dataset;
return new Vue({ return new Vue({
...@@ -31,12 +32,12 @@ export default () => { ...@@ -31,12 +32,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 ? convertObjectPropsToCamelCase(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({ edit: 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