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