Commit 1737a7c4 authored by Paul Gascou-Vaillancourt's avatar Paul Gascou-Vaillancourt Committed by Phil Hughes

Add branch selector to on-demand scans form

Add the RefSelector component to the on-demand scans form. It lets user
pick a branch to be associated with the scan. It defaults to the
project's default branch.
parent cbd3726e
......@@ -4,7 +4,6 @@ import {
GlDropdownDivider,
GlSearchBoxByType,
GlSprintf,
GlIcon,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce, isArray } from 'lodash';
......@@ -27,7 +26,6 @@ export default {
GlDropdownDivider,
GlSearchBoxByType,
GlSprintf,
GlIcon,
GlLoadingIcon,
RefResultsSection,
},
......@@ -111,7 +109,10 @@ export default {
return this.enabledRefTypes.length > 1;
},
toggleButtonClass() {
return { 'gl-inset-border-1-red-500!': !this.state };
return {
'gl-inset-border-1-red-500!': !this.state,
'gl-font-monospace': Boolean(this.selectedRef),
};
},
footerSlotProps() {
return {
......@@ -120,6 +121,9 @@ export default {
query: this.lastQuery,
};
},
buttonText() {
return this.selectedRef || this.i18n.noRefSelected;
},
},
watch: {
// Keep the Vuex store synchronized if the parent
......@@ -190,19 +194,12 @@ export default {
<gl-dropdown
:header-text="i18n.dropdownHeader"
:toggle-class="toggleButtonClass"
:text="buttonText"
class="ref-selector"
v-bind="$attrs"
v-on="$listeners"
@shown="focusSearchBox"
>
<template #button-content>
<span class="gl-flex-grow-1 gl-ml-2 gl-text-gray-400" data-testid="button-content">
<span v-if="selectedRef" class="gl-font-monospace">{{ selectedRef }}</span>
<span v-else>{{ i18n.noRefSelected }}</span>
</span>
<gl-icon name="chevron-down" />
</template>
<template #header>
<gl-search-box-by-type
ref="searchBox"
......
......@@ -23,6 +23,8 @@ import { convertToGraphQLId } from '~/graphql_shared/utils';
import { serializeFormObject } from '~/lib/utils/forms';
import { redirectTo, queryToObject } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import RefSelector from '~/ref/components/ref_selector.vue';
import { REF_TYPE_BRANCHES } from '~/ref/constants';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import validation from '~/vue_shared/directives/validation';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
......@@ -68,9 +70,11 @@ const createProfilesApolloOptions = (name, field, { fetchQuery, fetchError }) =>
export default {
SCAN_TYPE_LABEL,
REF_TYPE_BRANCHES,
saveAndRunScanBtnId: 'scan-submit-button',
saveScanBtnId: 'scan-save-button',
components: {
RefSelector,
ProfileSelectorSummaryCell,
ScannerProfileSelector,
SiteProfileSelector,
......@@ -156,6 +160,7 @@ export default {
...savedScansFields,
scannerProfiles: [],
siteProfiles: [],
selectedBranch: this.dastScan?.branch?.name ?? this.defaultBranch,
selectedScannerProfileId: this.dastScan?.scannerProfileId || null,
selectedSiteProfileId: this.dastScan?.siteProfileId || null,
loading: false,
......@@ -277,6 +282,9 @@ export default {
[this.isEdit ? 'runAfterUpdate' : 'runAfterCreate']: runAfter,
};
}
if (this.glFeatures.dastBranchSelection) {
input.branchName = this.selectedBranch;
}
this.$apollo
.mutate({
......@@ -431,6 +439,21 @@ export default {
/>
</gl-form-group>
</template>
<gl-form-group v-if="glFeatures.dastBranchSelection" :label="__('Branch')">
<ref-selector
v-model="selectedBranch"
data-testid="dast-scan-branch-input"
no-flip
:enabled-ref-types="[$options.REF_TYPE_BRANCHES]"
:project-id="projectPath"
:translations="{
dropdownHeader: __('Select a branch'),
searchPlaceholder: __('Search'),
}"
/>
</gl-form-group>
<scanner-profile-selector
v-model="selectedScannerProfileId"
class="gl-mb-5"
......
......@@ -7,6 +7,7 @@ module Projects
before_action do
push_frontend_feature_flag(:security_dast_site_profiles_additional_fields, @project, default_enabled: :yaml)
push_frontend_feature_flag(:dast_saved_scans, @project, default_enabled: :yaml)
push_frontend_feature_flag(:dast_branch_selection, @project, default_enabled: :yaml)
end
before_action :authorize_read_on_demand_scans!, only: :index
......
......@@ -14,6 +14,7 @@ import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import createApolloProvider from 'helpers/mock_apollo_helper';
import { stubComponent } from 'helpers/stub_component';
import { redirectTo, setUrlParams } from '~/lib/utils/url_utility';
import RefSelector from '~/ref/components/ref_selector.vue';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import * as responses from '../mocks/apollo_mocks';
import { scannerProfiles, siteProfiles } from '../mocks/mock_data';
......@@ -40,6 +41,7 @@ const [passiveScannerProfile, activeScannerProfile] = scannerProfiles;
const [nonValidatedSiteProfile, validatedSiteProfile] = siteProfiles;
const dastScan = {
id: 1,
branch: { name: 'dev' },
name: 'My daily scan',
description: 'Tests for SQL injections',
scannerProfileId: passiveScannerProfile.id,
......@@ -64,10 +66,14 @@ describe('OnDemandScansForm', () => {
const GlFormInputStub = stubComponent(GlFormInput, {
template: '<input />',
});
const RefSelectorStub = stubComponent(RefSelector, {
template: '<input />',
});
const findForm = () => subject.find(GlForm);
const findByTestId = (testId) => subject.find(`[data-testid="${testId}"]`);
const findNameInput = () => findByTestId('dast-scan-name-input');
const findBranchInput = () => findByTestId('dast-scan-branch-input');
const findDescriptionInput = () => findByTestId('dast-scan-description-input');
const findScannerProfilesSelector = () => subject.find(ScannerProfileSelector);
const findSiteProfilesSelector = () => subject.find(SiteProfileSelector);
......@@ -79,12 +85,13 @@ describe('OnDemandScansForm', () => {
const setValidFormData = () => {
findNameInput().vm.$emit('input', 'My daily scan');
findBranchInput().vm.$emit('input', 'some-other-branch');
findScannerProfilesSelector().vm.$emit('input', passiveScannerProfile.id);
findSiteProfilesSelector().vm.$emit('input', nonValidatedSiteProfile.id);
return subject.vm.$nextTick();
};
const setupSuccess = ({ edit = false } = {}) => {
jest.spyOn(subject.vm.$apollo, 'mutate').mockResolvedValue({
subject.vm.$apollo.mutate.mockResolvedValue({
data: {
[edit ? 'dastProfileUpdate' : 'dastProfileCreate']: {
dastProfile: { editPath },
......@@ -146,10 +153,12 @@ describe('OnDemandScansForm', () => {
newSiteProfilePath,
glFeatures: {
dastSavedScans: true,
dastBranchSelection: true,
},
},
stubs: {
GlFormInput: GlFormInputStub,
RefSelector: RefSelectorStub,
LocalStorageSync,
},
},
......@@ -181,34 +190,43 @@ describe('OnDemandScansForm', () => {
localStorage.clear();
});
it('renders properly', () => {
mountSubject();
expect(subject.text()).toContain('New on-demand DAST scan');
});
describe('when creating a new scan', () => {
it('renders properly', () => {
mountSubject();
it.each`
scannerProfilesLoading | siteProfilesLoading | isLoading
${true} | ${true} | ${true}
${false} | ${true} | ${true}
${true} | ${false} | ${true}
${false} | ${false} | ${false}
`(
'sets loading state to $isLoading if scanner profiles loading is $scannerProfilesLoading and site profiles loading is $siteProfilesLoading',
({ scannerProfilesLoading, siteProfilesLoading, isLoading }) => {
mountShallowSubject({
mocks: {
$apollo: {
queries: {
scannerProfiles: { loading: scannerProfilesLoading },
siteProfiles: { loading: siteProfilesLoading },
expect(subject.text()).toContain('New on-demand DAST scan');
});
it('populates the branch input with the default branch', () => {
mountSubject();
expect(findBranchInput().props('value')).toBe(defaultBranch);
});
it.each`
scannerProfilesLoading | siteProfilesLoading | isLoading
${true} | ${true} | ${true}
${false} | ${true} | ${true}
${true} | ${false} | ${true}
${false} | ${false} | ${false}
`(
'sets loading state to $isLoading if scanner profiles loading is $scannerProfilesLoading and site profiles loading is $siteProfilesLoading',
({ scannerProfilesLoading, siteProfilesLoading, isLoading }) => {
mountShallowSubject({
mocks: {
$apollo: {
queries: {
scannerProfiles: { loading: scannerProfilesLoading },
siteProfiles: { loading: siteProfilesLoading },
},
},
},
},
});
});
expect(subject.find(GlSkeletonLoader).exists()).toBe(isLoading);
},
);
expect(subject.find(GlSkeletonLoader).exists()).toBe(isLoading);
},
);
});
describe('when editing an existing scan', () => {
beforeEach(() => {
......@@ -225,6 +243,7 @@ describe('OnDemandScansForm', () => {
it('populates the fields with passed values', () => {
expect(findNameInput().attributes('value')).toBe(dastScan.name);
expect(findBranchInput().props('value')).toBe(dastScan.branch.name);
expect(findDescriptionInput().attributes('value')).toBe(dastScan.description);
expect(findScannerProfilesSelector().attributes('value')).toBe(dastScan.scannerProfileId);
expect(findSiteProfilesSelector().attributes('value')).toBe(dastScan.siteProfileId);
......@@ -324,6 +343,7 @@ describe('OnDemandScansForm', () => {
variables: {
input: {
name: 'My daily scan',
branchName: 'some-other-branch',
dastScannerProfileId: passiveScannerProfile.id,
dastSiteProfileId: nonValidatedSiteProfile.id,
fullPath: projectPath,
......@@ -362,6 +382,7 @@ describe('OnDemandScansForm', () => {
input: {
id: 1,
name: 'My daily scan',
branchName: 'some-other-branch',
description: 'Tests for SQL injections',
dastScannerProfileId: passiveScannerProfile.id,
dastSiteProfileId: nonValidatedSiteProfile.id,
......@@ -387,7 +408,7 @@ describe('OnDemandScansForm', () => {
describe('on top-level error', () => {
beforeEach(async () => {
mountShallowSubject();
jest.spyOn(subject.vm.$apollo, 'mutate').mockRejectedValue();
subject.vm.$apollo.mutate.mockRejectedValue();
await setValidFormData();
submitForm();
});
......@@ -408,9 +429,9 @@ describe('OnDemandScansForm', () => {
beforeEach(async () => {
mountShallowSubject();
jest
.spyOn(subject.vm.$apollo, 'mutate')
.mockResolvedValue({ data: { dastProfileCreate: { pipelineUrl: null, errors } } });
subject.vm.$apollo.mutate.mockResolvedValue({
data: { dastProfileCreate: { pipelineUrl: null, errors } },
});
await setValidFormData();
submitForm();
});
......@@ -452,9 +473,9 @@ describe('OnDemandScansForm', () => {
},
},
});
jest
.spyOn(subject.vm.$apollo, 'mutate')
.mockResolvedValue({ data: { dastOnDemandScanCreate: { pipelineUrl, errors: [] } } });
subject.vm.$apollo.mutate.mockResolvedValue({
data: { dastOnDemandScanCreate: { pipelineUrl, errors: [] } },
});
subject.find(ScannerProfileSelector).vm.$emit('input', passiveScannerProfile.id);
subject.find(SiteProfileSelector).vm.$emit('input', nonValidatedSiteProfile.id);
submitForm();
......@@ -465,6 +486,7 @@ describe('OnDemandScansForm', () => {
mutation: dastOnDemandScanCreateMutation,
variables: {
input: {
branchName: defaultBranch,
dastScannerProfileId: passiveScannerProfile.id,
dastSiteProfileId: nonValidatedSiteProfile.id,
fullPath: projectPath,
......@@ -609,4 +631,99 @@ describe('OnDemandScansForm', () => {
expect(findSiteProfilesSelector().attributes('value')).toBe(siteProfile.id);
});
});
describe('dastBranchSelection feature flag disabled', () => {
describe.each`
action | actionFunction | runAfter
${'submit'} | ${submitForm} | ${true}
${'save'} | ${saveScan} | ${false}
`('on $action', ({ actionFunction, runAfter }) => {
describe('when creating a new scan', () => {
beforeEach(async () => {
mountShallowSubject({
provide: {
glFeatures: {
dastBranchSelection: false,
},
},
});
subject.vm.$apollo.mutate.mockResolvedValue({
data: {
dastProfileCreate: {
dastProfile: { editPath },
pipelineUrl,
errors: [],
},
},
});
findNameInput().vm.$emit('input', 'My daily scan');
findScannerProfilesSelector().vm.$emit('input', passiveScannerProfile.id);
findSiteProfilesSelector().vm.$emit('input', nonValidatedSiteProfile.id);
await subject.vm.$nextTick();
actionFunction();
});
it(`triggers dastProfileCreateMutation mutation without the branch name and runAfterCreate set to ${runAfter}`, async () => {
expect(subject.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: dastProfileCreateMutation,
variables: {
input: {
name: 'My daily scan',
dastScannerProfileId: passiveScannerProfile.id,
dastSiteProfileId: nonValidatedSiteProfile.id,
fullPath: projectPath,
runAfterCreate: runAfter,
},
},
});
});
});
describe('when editing an existing scan', () => {
beforeEach(async () => {
mountShallowSubject({
propsData: {
dastScan,
},
provide: {
glFeatures: {
dastBranchSelection: false,
},
},
});
subject.vm.$apollo.mutate.mockResolvedValue({
data: {
dastProfileUpdate: {
dastProfile: { editPath },
pipelineUrl,
errors: [],
},
},
});
findNameInput().vm.$emit('input', 'My daily scan');
findScannerProfilesSelector().vm.$emit('input', passiveScannerProfile.id);
findSiteProfilesSelector().vm.$emit('input', nonValidatedSiteProfile.id);
await subject.vm.$nextTick();
actionFunction();
});
it(`triggers dastProfileUpdateMutation mutation without the branch name and runAfterUpdate set to ${runAfter}`, async () => {
expect(subject.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: dastProfileUpdateMutation,
variables: {
input: {
id: 1,
name: 'My daily scan',
description: 'Tests for SQL injections',
dastScannerProfileId: passiveScannerProfile.id,
dastSiteProfileId: nonValidatedSiteProfile.id,
fullPath: projectPath,
runAfterUpdate: runAfter,
},
},
});
});
});
});
});
});
......@@ -26918,6 +26918,9 @@ msgstr ""
msgid "Select Stack"
msgstr ""
msgid "Select a branch"
msgstr ""
msgid "Select a file from the left sidebar to begin editing. Afterwards, you'll be able to commit your changes."
msgstr ""
......
......@@ -91,7 +91,7 @@ describe('Ref selector component', () => {
//
// Finders
//
const findButtonContent = () => wrapper.find('[data-testid="button-content"]');
const findButtonContent = () => wrapper.find('button');
const findNoResults = () => wrapper.find('[data-testid="no-results"]');
......
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