Commit 7dc2c2da authored by Dheeraj Joshi's avatar Dheeraj Joshi

Allow editing a DAST scanner profile

This change would allow editing an existing
On-demand scans scanner profile
parent 9c4d9f98
......@@ -3092,6 +3092,11 @@ enum DastScanTypeEnum {
Represents a DAST scanner profile.
"""
type DastScannerProfile {
"""
Relative web path to the edit page of a scanner profile
"""
editPath: String
"""
ID of the DAST scanner profile
"""
......
......@@ -8402,6 +8402,20 @@
"name": "DastScannerProfile",
"description": "Represents a DAST scanner profile.",
"fields": [
{
"name": "editPath",
"description": "Relative web path to the edit page of a scanner profile",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "globalId",
"description": "ID of the DAST scanner profile",
......@@ -527,6 +527,7 @@ Represents a DAST scanner profile.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `editPath` | String | Relative web path to the edit page of a scanner profile |
| `globalId` | DastScannerProfileID! | ID of the DAST scanner profile |
| `id` **{warning-solid}** | ID! | **Deprecated:** Use `global_id`. Deprecated in 13.4 |
| `profileName` | String | Name of the DAST scanner profile |
......
......@@ -154,21 +154,6 @@ export default {
@click="prepareProfileDeletion(item.id)"
/>
<gl-button v-if="item.editPath" :href="item.editPath">{{ __('Edit') }}</gl-button>
<!--
NOTE: The tooltip and `disable` on the button is temporary until the edit feature has been implemented
further details: https://gitlab.com/groups/gitlab-org/-/epics/3786 (iteration outline)
-->
<span
v-else
v-gl-tooltip.hover
:title="
s__(
'DastProfiles|Edit feature will come soon. Please create a new profile if changes needed',
)
"
>
<gl-button disabled>{{ __('Edit') }}</gl-button>
</span>
</div>
</template>
......
......@@ -24,6 +24,7 @@ query DastScannerProfiles(
profileName
spiderTimeout
targetTimeout
editPath
}
}
}
......
......@@ -17,6 +17,7 @@ import { __, s__ } from '~/locale';
import { redirectTo } from '~/lib/utils/url_utility';
import { serializeFormObject, isEmptyValue } from '~/lib/utils/forms';
import dastScannerProfileCreateMutation from '../graphql/dast_scanner_profile_create.mutation.graphql';
import dastScannerProfileUpdateMutation from '../graphql/dast_scanner_profile_update.mutation.graphql';
const initField = (value, isRequired = false) => ({
value,
......@@ -55,12 +56,19 @@ export default {
type: String,
required: true,
},
profile: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
const { name = '', spiderTimeout = '', targetTimeout = '' } = this.profile;
const form = {
profileName: initField('', true),
spiderTimeout: initField('', true),
targetTimeout: initField('', true),
profileName: initField(name, true),
spiderTimeout: initField(spiderTimeout, true),
targetTimeout: initField(targetTimeout, true),
};
return {
......@@ -79,6 +87,35 @@ export default {
max: TARGET_TIMEOUT_MAX,
},
computed: {
isEdit() {
return Boolean(this.profile.id);
},
i18n() {
const { isEdit } = this;
return {
title: isEdit
? s__('DastProfiles|Edit scanner profile')
: s__('DastProfiles|New scanner profile'),
errorMessage: isEdit
? s__('DastProfiles|Could not update the scanner profile. Please try again.')
: s__('DastProfiles|Could not create the scanner profile. Please try again.'),
modal: {
title: isEdit
? s__('DastProfiles|Do you want to discard your changes?')
: s__('DastProfiles|Do you want to discard this scanner profile?'),
okTitle: __('Discard'),
cancelTitle: __('Cancel'),
},
tooltips: {
spiderTimeout: s__(
'DastProfiles|The maximum number of minutes allowed for the spider to traverse the site.',
),
targetTimeout: s__(
'DastProfiles|The maximum number of seconds allowed for the site under test to respond to a request.',
),
},
};
},
formTouched() {
return !isEqual(serializeFormObject(this.form), this.initialFormValues);
},
......@@ -122,22 +159,33 @@ export default {
const variables = {
projectFullPath: this.projectFullPath,
...(this.isEdit ? { id: this.profile.id } : {}),
...serializeFormObject(this.form),
};
this.$apollo
.mutate({
mutation: dastScannerProfileCreateMutation,
mutation: this.isEdit
? dastScannerProfileUpdateMutation
: dastScannerProfileCreateMutation,
variables,
})
.then(({ data: { dastScannerProfileCreate: { errors = [] } } }) => {
if (errors.length > 0) {
this.showErrors(errors);
this.loading = false;
} else {
redirectTo(this.profilesLibraryPath);
}
})
.then(
({
data: {
[this.isEdit ? 'dastScannerProfileUpdate' : 'dastScannerProfileCreate']: {
errors = [],
},
},
}) => {
if (errors.length > 0) {
this.showErrors(errors);
this.loading = false;
} else {
redirectTo(this.profilesLibraryPath);
}
},
)
.catch(e => {
Sentry.captureException(e);
this.showErrors();
......@@ -164,24 +212,13 @@ export default {
},
},
modalId: 'deleteDastProfileModal',
i18n: {
modalTitle: s__('DastProfiles|Do you want to discard this scanner profile?'),
modalOkTitle: __('Discard'),
modalCancelTitle: __('Cancel'),
spiderTimeoutTooltip: s__(
'DastProfiles|The maximum number of minutes allowed for the spider to traverse the site.',
),
targetTimeoutTooltip: s__(
'DastProfiles|The maximum number of seconds allowed for the site under test to respond to a request.',
),
},
};
</script>
<template>
<gl-form @submit.prevent="onSubmit">
<h2 class="gl-mb-6">
{{ s__('DastProfiles|New scanner profile') }}
{{ i18n.title }}
</h2>
<gl-alert v-if="showAlert" variant="danger" class="gl-mb-5" @dismiss="hideErrors">
......@@ -214,7 +251,7 @@ export default {
v-gl-tooltip.hover
name="information-o"
class="gl-vertical-align-text-bottom gl-text-gray-400 gl-ml-2"
:title="$options.i18n.spiderTimeoutTooltip"
:title="i18n.tooltips.spiderTimeout"
/>
</template>
<gl-form-input-group
......@@ -246,7 +283,7 @@ export default {
v-gl-tooltip.hover
name="information-o"
class="gl-vertical-align-text-bottom gl-text-gray-400 gl-ml-2"
:title="$options.i18n.targetTimeoutTooltip"
:title="i18n.tooltips.targetTimeout"
/>
</template>
<gl-form-input-group
......@@ -291,9 +328,9 @@ export default {
<gl-modal
:ref="$options.modalId"
:modal-id="$options.modalId"
:title="$options.i18n.modalTitle"
:ok-title="$options.i18n.modalOkTitle"
:cancel-title="$options.i18n.modalCancelTitle"
:title="i18n.modal.title"
:ok-title="i18n.modal.okTitle"
:cancel-title="i18n.modal.cancelTitle"
ok-variant="danger"
body-class="gl-display-none"
data-testid="dast-scanner-profile-form-cancel-modal"
......
import Vue from 'vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import apolloProvider from './graphql/provider';
import DastScannerProfileForm from './components/dast_scanner_profile_form.vue';
......@@ -15,6 +16,10 @@ export default () => {
profilesLibraryPath,
};
if (el.dataset.scannerProfile) {
props.profile = convertObjectPropsToCamelCase(JSON.parse(el.dataset.scannerProfile));
}
return new Vue({
el,
apolloProvider,
......
mutation dastScannerProfileUpdate(
$id: DastScannerProfileID!
$projectFullPath: ID!
$profileName: String!
$spiderTimeout: Int!
$targetTimeout: Int!
) {
dastScannerProfileUpdate(
input: {
id: $id
fullPath: $projectFullPath
profileName: $profileName
spiderTimeout: $spiderTimeout
targetTimeout: $targetTimeout
}
) {
id
errors
}
}
import initDastScannerProfileForm from 'ee/dast_scanner_profiles/dast_scanner_profiles_bundle';
document.addEventListener('DOMContentLoaded', initDastScannerProfileForm);
......@@ -6,5 +6,11 @@ module Projects
def new
end
def edit
@scanner_profile = @project
.dast_scanner_profiles
.find(params[:id])
end
end
end
......@@ -24,5 +24,11 @@ module Types
field :target_timeout, GraphQL::INT_TYPE, null: true,
description: 'The maximum number of seconds allowed for the site under test to respond to a request'
field :edit_path, GraphQL::STRING_TYPE, null: true,
description: 'Relative web path to the edit page of a scanner profile',
resolve: -> (obj, _args, _ctx) do
Rails.application.routes.url_helpers.edit_project_dast_scanner_profile_path(obj.project, obj)
end
end
end
......@@ -165,6 +165,7 @@ module EE
projects/dast_site_profiles#new
projects/dast_site_profiles#edit
projects/dast_scanner_profiles#new
projects/dast_scanner_profiles#edit
projects/dependencies#index
projects/licenses#index
projects/threat_monitoring#show
......@@ -186,6 +187,7 @@ module EE
projects/dast_site_profiles#new
projects/dast_site_profiles#edit
projects/dast_scanner_profiles#new
projects/dast_scanner_profiles#edit
]
end
......
- add_to_breadcrumbs s_('OnDemandScans|On-demand Scans'), project_on_demand_scans_path(@project)
- add_to_breadcrumbs s_('DastProfiles|Manage profiles'), project_profiles_path(@project, anchor: 'scanner-profiles')
- breadcrumb_title s_('DastProfiles|Edit scanner profile')
- page_title s_('DastProfiles|Edit scanner profile')
.js-dast-scanner-profile-form{ data: { project_full_path: @project.path_with_namespace,
profiles_library_path: project_profiles_path(@project, anchor: 'scanner-profiles'),
scanner_profile: { id: @scanner_profile.to_global_id.to_s, name: @scanner_profile.name, spider_timeout: @scanner_profile.spider_timeout, target_timeout: @scanner_profile.target_timeout }.to_json } }
......@@ -94,7 +94,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
scope :profiles do
root 'dast_profiles#index', as: 'profiles'
resources :dast_site_profiles, only: [:new, :edit]
resources :dast_scanner_profiles, only: [:new]
resources :dast_scanner_profiles, only: [:new, :edit]
end
end
......
......@@ -121,7 +121,7 @@ describe('EE - DastProfilesList', () => {
id: 3,
profileName: 'Profile 2',
targetUrl: 'http://example-2.com',
editPath: '',
editPath: '/3/edit',
validationStatus: 'Pending',
},
];
......@@ -155,19 +155,9 @@ describe('EE - DastProfilesList', () => {
expect(validationStatusCell.innerText).toContain(profile.validationStatus);
expect(within(actionsCell).getByRole('button', { name: /delete/i })).not.toBe(null);
if (profile.editPath) {
const editLink = within(actionsCell).getByRole('link', { name: /edit/i });
expect(editLink).not.toBe(null);
expect(editLink.getAttribute('href')).toBe(profile.editPath);
} else {
const editButton = within(actionsCell).getByRole('button', { name: /edit/i });
const helpText = within(actionsCell).getByTitle(
/edit feature will come soon. please create a new profile if changes needed/i,
);
expect(helpText).not.toBe(null);
expect(editButton).not.toBe(null);
expect(editButton.getAttribute('disabled')).not.toBe(null);
}
const editLink = within(actionsCell).getByRole('link', { name: /edit/i });
expect(editLink).not.toBe(null);
expect(editLink.getAttribute('href')).toBe(profile.editPath);
});
});
......
import merge from 'lodash/merge';
import { within } from '@testing-library/dom';
import { mount, shallowMount } from '@vue/test-utils';
import { GlAlert, GlForm, GlModal } from '@gitlab/ui';
import { TEST_HOST } from 'helpers/test_constants';
import DastScannerProfileForm from 'ee/dast_scanner_profiles/components/dast_scanner_profile_form.vue';
import dastScannerProfileCreateMutation from 'ee/dast_scanner_profiles/graphql/dast_scanner_profile_create.mutation.graphql';
import dastScannerProfileUpdateMutation from 'ee/dast_scanner_profiles/graphql/dast_scanner_profile_update.mutation.graphql';
import { redirectTo } from '~/lib/utils/url_utility';
jest.mock('~/lib/utils/url_utility', () => ({
......@@ -24,6 +26,8 @@ const defaultProps = {
describe('DAST Scanner Profile', () => {
let wrapper;
const withinComponent = () => within(wrapper.element);
const findForm = () => wrapper.find(GlForm);
const findProfileNameInput = () => wrapper.find('[data-testid="profile-name-input"]');
const findSpiderTimeoutInput = () => wrapper.find('[data-testid="spider-timeout-input"]');
......@@ -131,118 +135,139 @@ describe('DAST Scanner Profile', () => {
});
});
describe('submission', () => {
const createdProfileId = 30203;
describe('on success', () => {
beforeEach(() => {
createComponent();
jest
.spyOn(wrapper.vm.$apollo, 'mutate')
.mockResolvedValue({ data: { dastScannerProfileCreate: { id: createdProfileId } } });
findProfileNameInput().vm.$emit('input', profileName);
findSpiderTimeoutInput().vm.$emit('input', spiderTimeout);
findTargetTimeoutInput().vm.$emit('input', targetTimeout);
submitForm();
describe.each`
title | profile | mutation | mutationVars | mutationKind
${'New scanner profile'} | ${{}} | ${dastScannerProfileCreateMutation} | ${{}} | ${'dastScannerProfileCreate'}
${'Edit scanner profile'} | ${{ id: 1, name: 'foo', spiderTimeout: 2, targetTimeout: 12 }} | ${dastScannerProfileUpdateMutation} | ${{ id: 1 }} | ${'dastScannerProfileUpdate'}
`('$title', ({ profile, title, mutation, mutationVars, mutationKind }) => {
beforeEach(() => {
createFullComponent({
propsData: {
profile,
},
});
});
it('sets loading state', () => {
expect(findSubmitButton().props('loading')).toBe(true);
});
it('sets the correct title', () => {
expect(withinComponent().getByRole('heading', { name: title })).not.toBeNull();
});
it('triggers GraphQL mutation', () => {
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: dastScannerProfileCreateMutation,
variables: {
profileName,
spiderTimeout,
targetTimeout,
projectFullPath,
},
it('populates the fields with the data passed in via the profile prop', () => {
expect(findProfileNameInput().element.value).toBe(profile?.name ?? '');
});
describe('submission', () => {
const createdProfileId = 30203;
describe('on success', () => {
beforeEach(() => {
jest
.spyOn(wrapper.vm.$apollo, 'mutate')
.mockResolvedValue({ data: { [mutationKind]: { id: createdProfileId } } });
findProfileNameInput().vm.$emit('input', profileName);
findSpiderTimeoutInput().vm.$emit('input', spiderTimeout);
findTargetTimeoutInput().vm.$emit('input', targetTimeout);
submitForm();
});
});
it('redirects to the profiles library', () => {
expect(redirectTo).toHaveBeenCalledWith(profilesLibraryPath);
});
it('sets loading state', () => {
expect(findSubmitButton().props('loading')).toBe(true);
});
it('does not show an alert', () => {
expect(findAlert().exists()).toBe(false);
});
});
it('triggers GraphQL mutation', () => {
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation,
variables: {
profileName,
spiderTimeout,
targetTimeout,
projectFullPath,
...mutationVars,
},
});
});
describe('on top-level error', () => {
beforeEach(() => {
createComponent();
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue();
const input = findTargetTimeoutInput();
input.vm.$emit('input', targetTimeout);
submitForm();
});
it('redirects to the profiles library', () => {
expect(redirectTo).toHaveBeenCalledWith(profilesLibraryPath);
});
it('resets loading state', () => {
expect(findSubmitButton().props('loading')).toBe(false);
it('does not show an alert', () => {
expect(findAlert().exists()).toBe(false);
});
});
it('shows an error alert', () => {
expect(findAlert().exists()).toBe(true);
});
});
describe('on top-level error', () => {
beforeEach(() => {
createComponent();
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue();
const input = findTargetTimeoutInput();
input.vm.$emit('input', targetTimeout);
submitForm();
});
describe('on errors as data', () => {
const errors = ['Name is already taken', 'Value should be Int', 'error#3'];
it('resets loading state', () => {
expect(findSubmitButton().props('loading')).toBe(false);
});
beforeEach(() => {
createComponent();
jest
.spyOn(wrapper.vm.$apollo, 'mutate')
.mockResolvedValue({ data: { dastScannerProfileCreate: { pipelineUrl: null, errors } } });
const input = findSpiderTimeoutInput();
input.vm.$emit('input', spiderTimeout);
submitForm();
it('shows an error alert', () => {
expect(findAlert().exists()).toBe(true);
});
});
it('resets loading state', () => {
expect(findSubmitButton().props('loading')).toBe(false);
});
describe('on errors as data', () => {
const errors = ['Name is already taken', 'Value should be Int', 'error#3'];
it('shows an alert with the returned errors', () => {
const alert = findAlert();
beforeEach(() => {
jest
.spyOn(wrapper.vm.$apollo, 'mutate')
.mockResolvedValue({ data: { [mutationKind]: { errors } } });
const input = findSpiderTimeoutInput();
input.vm.$emit('input', spiderTimeout);
submitForm();
});
expect(alert.exists()).toBe(true);
errors.forEach(error => {
expect(alert.text()).toContain(error);
it('resets loading state', () => {
expect(findSubmitButton().props('loading')).toBe(false);
});
});
});
});
describe('cancellation', () => {
beforeEach(() => {
createFullComponent();
});
it('shows an alert with the returned errors', () => {
const alert = findAlert();
describe('form empty', () => {
it('redirects to the profiles library', () => {
findCancelButton().vm.$emit('click');
expect(redirectTo).toHaveBeenCalledWith(profilesLibraryPath);
expect(alert.exists()).toBe(true);
errors.forEach(error => {
expect(alert.text()).toContain(error);
});
});
});
});
describe('form not empty', () => {
describe('cancellation', () => {
beforeEach(() => {
findProfileNameInput().setValue(profileName);
createFullComponent();
});
it('asks the user to confirm the action', () => {
jest.spyOn(findCancelModal().vm, 'show').mockReturnValue();
findCancelButton().trigger('click');
expect(findCancelModal().vm.show).toHaveBeenCalled();
describe('form empty', () => {
it('redirects to the profiles library', () => {
findCancelButton().vm.$emit('click');
expect(redirectTo).toHaveBeenCalledWith(profilesLibraryPath);
});
});
it('redirects to the profiles library if confirmed', () => {
findCancelModal().vm.$emit('ok');
expect(redirectTo).toHaveBeenCalledWith(profilesLibraryPath);
describe('form not empty', () => {
beforeEach(() => {
findProfileNameInput().setValue(profileName);
});
it('asks the user to confirm the action', () => {
jest.spyOn(findCancelModal().vm, 'show').mockReturnValue();
findCancelButton().trigger('click');
expect(findCancelModal().vm.show).toHaveBeenCalled();
});
it('redirects to the profiles library if confirmed', () => {
findCancelModal().vm.$emit('ok');
expect(redirectTo).toHaveBeenCalledWith(profilesLibraryPath);
});
});
});
});
......
......@@ -6,7 +6,7 @@ RSpec.describe GitlabSchema.types['DastScannerProfile'] do
let_it_be(:dast_scanner_profile) { create(:dast_scanner_profile) }
let_it_be(:project) { dast_scanner_profile.project }
let_it_be(:user) { create(:user) }
let_it_be(:fields) { %i[id globalId profileName spiderTimeout targetTimeout] }
let_it_be(:fields) { %i[id globalId profileName spiderTimeout targetTimeout editPath] }
let(:response) do
GitlabSchema.execute(
......
......@@ -178,6 +178,7 @@ RSpec.describe ProjectsHelper do
projects/dast_site_profiles#new
projects/dast_site_profiles#edit
projects/dast_scanner_profiles#new
projects/dast_scanner_profiles#edit
projects/dependencies#index
projects/licenses#index
projects/threat_monitoring#show
......@@ -199,6 +200,7 @@ RSpec.describe ProjectsHelper do
projects/dast_site_profiles#new
projects/dast_site_profiles#edit
projects/dast_scanner_profiles#new
projects/dast_scanner_profiles#edit
]
end
......
......@@ -80,4 +80,20 @@ RSpec.describe Projects::DastScannerProfilesController, type: :request do
let(:path) { new_project_dast_scanner_profile_path(project) }
end
end
describe 'GET #edit' do
include_context 'user authorized'
include_context 'on-demand scans feature available'
let(:edit_path) { edit_project_dast_scanner_profile_path(project, dast_scanner_profile) }
it_behaves_like 'a GET request' do
let(:path) { edit_path }
end
it 'sets scanner_profile' do
get edit_path
expect(assigns(:scanner_profile)).to eq(dast_scanner_profile)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe "projects/dast_scanner_profiles/edit", type: :view do
let_it_be(:scanner_profile) { create(:dast_scanner_profile) }
let_it_be(:scanner_profile_gid) { ::URI::GID.parse("gid://gitlab/DastScannerProfile/#{scanner_profile.id}") }
before do
assign(:project, scanner_profile.project)
assign(:scanner_profile, scanner_profile)
assign(:scanner_profile_gid, scanner_profile_gid)
render
end
it 'renders Vue app root' do
expect(rendered).to have_selector('.js-dast-scanner-profile-form')
end
it 'passes project\'s full path' do
expect(rendered).to include scanner_profile.project.path_with_namespace
end
it 'passes DAST profiles library URL' do
expect(rendered).to include '/on_demand_scans/profiles'
end
it 'passes DAST scanner profile\'s data' do
expect(rendered).to include scanner_profile_gid.to_s
expect(rendered).to include scanner_profile.name
expect(rendered).to include scanner_profile.spider_timeout.to_s
expect(rendered).to include scanner_profile.target_timeout.to_s
end
end
......@@ -7864,6 +7864,9 @@ msgstr ""
msgid "DastProfiles|Could not retrieve site validation status. Please refresh the page, or try again later."
msgstr ""
msgid "DastProfiles|Could not update the scanner profile. Please try again."
msgstr ""
msgid "DastProfiles|Could not update the site profile. Please try again."
msgstr ""
......@@ -7879,7 +7882,7 @@ msgstr ""
msgid "DastProfiles|Download validation text file"
msgstr ""
msgid "DastProfiles|Edit feature will come soon. Please create a new profile if changes needed"
msgid "DastProfiles|Edit scanner profile"
msgstr ""
msgid "DastProfiles|Edit site profile"
......
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