Commit bd183177 authored by Mayra Cabrera's avatar Mayra Cabrera

Merge branch '233110-on-demand-scans-site-profiles-ff' into 'master'

[Feature flag] Rollout of `security_on_demand_scans_site_profiles_feature_flag`

See merge request gitlab-org/gitlab!38412
parents 2fb63ac4 2660c558
<script> <script>
import OnDemandScansFormOld from './on_demand_scans_form_old.vue';
import OnDemandScansForm from './on_demand_scans_form.vue'; import OnDemandScansForm from './on_demand_scans_form.vue';
import OnDemandScansEmptyState from './on_demand_scans_empty_state.vue'; import OnDemandScansEmptyState from './on_demand_scans_empty_state.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default { export default {
name: 'OnDemandScansApp', name: 'OnDemandScansApp',
components: { components: {
OnDemandScansFormOld,
OnDemandScansForm, OnDemandScansForm,
OnDemandScansEmptyState, OnDemandScansEmptyState,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
helpPagePath: { helpPagePath: {
type: String, type: String,
...@@ -36,13 +40,22 @@ export default { ...@@ -36,13 +40,22 @@ export default {
<template> <template>
<div> <div>
<on-demand-scans-form <template v-if="showForm">
v-if="showForm" <on-demand-scans-form
:help-page-path="helpPagePath" v-if="glFeatures.securityOnDemandScansSiteProfilesFeatureFlag"
:project-path="projectPath" :help-page-path="helpPagePath"
:default-branch="defaultBranch" :project-path="projectPath"
@cancel="showForm = false" :default-branch="defaultBranch"
/> @cancel="showForm = false"
/>
<on-demand-scans-form-old
v-else
:help-page-path="helpPagePath"
:project-path="projectPath"
:default-branch="defaultBranch"
@cancel="showForm = false"
/>
</template>
<on-demand-scans-empty-state <on-demand-scans-empty-state
v-else v-else
:help-page-path="helpPagePath" :help-page-path="helpPagePath"
......
<script>
import * as Sentry from '@sentry/browser';
import { s__, sprintf } from '~/locale';
import createFlash from '~/flash';
import { isAbsolute, redirectTo } from '~/lib/utils/url_utility';
import {
GlButton,
GlForm,
GlFormGroup,
GlFormInput,
GlIcon,
GlLink,
GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
import runDastScanMutation from '../graphql/run_dast_scan.mutation.graphql';
import { SCAN_TYPES } from '../constants';
const initField = value => ({
value,
state: null,
feedback: null,
});
export default {
components: {
GlButton,
GlForm,
GlFormGroup,
GlFormInput,
GlIcon,
GlLink,
GlSprintf,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
helpPagePath: {
type: String,
required: true,
},
projectPath: {
type: String,
required: true,
},
defaultBranch: {
type: String,
required: true,
},
},
data() {
return {
form: {
scanType: initField(SCAN_TYPES.PASSIVE),
branch: initField(this.defaultBranch),
targetUrl: initField(''),
},
loading: false,
};
},
computed: {
formData() {
return {
projectPath: this.projectPath,
...Object.fromEntries(Object.entries(this.form).map(([key, { value }]) => [key, value])),
};
},
formHasErrors() {
return Object.values(this.form).some(({ state }) => state === false);
},
someFieldEmpty() {
return Object.values(this.form).some(({ value }) => !value);
},
isSubmitDisabled() {
return this.formHasErrors || this.someFieldEmpty;
},
},
methods: {
validateTargetUrl() {
let [state, feedback] = [true, null];
const { value: targetUrl } = this.form.targetUrl;
if (!isAbsolute(targetUrl)) {
state = false;
feedback = s__(
'OnDemandScans|Please enter a valid URL format, ex: http://www.example.com/home',
);
}
this.form.targetUrl = {
...this.form.targetUrl,
state,
feedback,
};
},
onSubmit() {
this.loading = true;
this.$apollo
.mutate({
mutation: runDastScanMutation,
variables: this.formData,
})
.then(({ data: { runDastScan: { pipelineUrl, errors } } }) => {
if (errors?.length) {
createFlash(
sprintf(s__('OnDemandScans|Could not run the scan: %{backendErrorMessage}'), {
backendErrorMessage: errors.join(', '),
}),
);
this.loading = false;
} else {
redirectTo(pipelineUrl);
}
})
.catch(e => {
Sentry.captureException(e);
createFlash(s__('OnDemandScans|Could not run the scan. Please try again.'));
this.loading = false;
});
},
},
};
</script>
<template>
<gl-form @submit.prevent="onSubmit">
<header class="gl-mb-6">
<h2>{{ s__('OnDemandScans|New on-demand DAST scan') }}</h2>
<p>
<gl-icon name="information-o" class="gl-vertical-align-text-bottom gl-text-gray-600" />
<gl-sprintf
:message="
s__(
'OnDemandScans|On-demand scans run outside the DevOps cycle and find vulnerabilities in your projects. %{learnMoreLinkStart}Learn more%{learnMoreLinkEnd}',
)
"
>
<template #learnMoreLink="{ content }">
<gl-link :href="helpPagePath">
{{ content }}
</gl-link>
</template>
</gl-sprintf>
</p>
</header>
<gl-form-group>
<template #label>
{{ s__('OnDemandScans|Scan mode') }}
<gl-icon
v-gl-tooltip.hover
name="information-o"
class="gl-vertical-align-text-bottom gl-text-gray-600"
:title="s__('OnDemandScans|Only a passive scan can be performed on demand.')"
/>
</template>
{{ s__('OnDemandScans|Passive DAST Scan') }}
</gl-form-group>
<gl-form-group>
<template #label>
{{ s__('OnDemandScans|Attached branch') }}
<gl-icon
v-gl-tooltip.hover
name="information-o"
class="gl-vertical-align-text-bottom gl-text-gray-600"
:title="s__('OnDemandScans|Attached branch is where the scan job runs.')"
/>
</template>
{{ defaultBranch }}
</gl-form-group>
<gl-form-group :invalid-feedback="form.targetUrl.feedback">
<template #label>
{{ s__('OnDemandScans|Target URL') }}
<gl-icon
v-gl-tooltip.hover
name="information-o"
class="gl-vertical-align-text-bottom gl-text-gray-600"
:title="s__('OnDemandScans|DAST will scan the target URL and any discovered sub URLs.')"
/>
</template>
<gl-form-input
v-model="form.targetUrl.value"
class="mw-460"
data-testid="target-url-input"
type="url"
:state="form.targetUrl.state"
@input="validateTargetUrl"
/>
</gl-form-group>
<div class="gl-mt-6 gl-pt-6">
<gl-button
type="submit"
variant="success"
class="js-no-auto-disable"
:disabled="isSubmitDisabled"
:loading="loading"
>
{{ s__('OnDemandScans|Run this scan') }}
</gl-button>
<gl-button @click="$emit('cancel')">
{{ __('Cancel') }}
</gl-button>
</div>
</gl-form>
</template>
...@@ -2,7 +2,10 @@ ...@@ -2,7 +2,10 @@
module Projects module Projects
class OnDemandScansController < Projects::ApplicationController class OnDemandScansController < Projects::ApplicationController
before_action :authorize_read_on_demand_scans! before_action do
authorize_read_on_demand_scans!
push_frontend_feature_flag(:security_on_demand_scans_site_profiles_feature_flag, project)
end
def index def index
end end
......
---
name: security_on_demand_scans_site_profiles_feature_flag
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/38412
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/233110
group: group::dynamic analysis
type: development
default_enabled: false
import { merge } from 'lodash';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import OnDemandScansApp from 'ee/on_demand_scans/components/on_demand_scans_app.vue'; import OnDemandScansApp from 'ee/on_demand_scans/components/on_demand_scans_app.vue';
import OnDemandScansForm from 'ee/on_demand_scans/components/on_demand_scans_form.vue'; import OnDemandScansForm from 'ee/on_demand_scans/components/on_demand_scans_form.vue';
import OnDemandScansFormOld from 'ee/on_demand_scans/components/on_demand_scans_form_old.vue';
import OnDemandScansEmptyState from 'ee/on_demand_scans/components/on_demand_scans_empty_state.vue'; import OnDemandScansEmptyState from 'ee/on_demand_scans/components/on_demand_scans_empty_state.vue';
const helpPagePath = `${TEST_HOST}/application_security/dast/index#on-demand-scans`; const helpPagePath = `${TEST_HOST}/application_security/dast/index#on-demand-scans`;
...@@ -12,7 +14,6 @@ const emptyStateSvgPath = `${TEST_HOST}/assets/illustrations/alert-management-em ...@@ -12,7 +14,6 @@ const emptyStateSvgPath = `${TEST_HOST}/assets/illustrations/alert-management-em
describe('OnDemandScansApp', () => { describe('OnDemandScansApp', () => {
let wrapper; let wrapper;
const findOnDemandScansForm = () => wrapper.find(OnDemandScansForm);
const findOnDemandScansEmptyState = () => wrapper.find(OnDemandScansEmptyState); const findOnDemandScansEmptyState = () => wrapper.find(OnDemandScansEmptyState);
const expectEmptyState = () => { const expectEmptyState = () => {
...@@ -20,33 +21,34 @@ describe('OnDemandScansApp', () => { ...@@ -20,33 +21,34 @@ describe('OnDemandScansApp', () => {
expect(wrapper.contains(OnDemandScansEmptyState)).toBe(true); expect(wrapper.contains(OnDemandScansEmptyState)).toBe(true);
}; };
const expectForm = () => { const createComponent = options => {
expect(wrapper.contains(OnDemandScansForm)).toBe(true); wrapper = shallowMount(
expect(wrapper.contains(OnDemandScansEmptyState)).toBe(false); OnDemandScansApp,
merge(
{},
{
propsData: {
helpPagePath,
projectPath,
defaultBranch,
emptyStateSvgPath,
},
},
options,
),
);
}; };
const createComponent = (props = {}) => {
wrapper = shallowMount(OnDemandScansApp, {
propsData: {
helpPagePath,
projectPath,
defaultBranch,
emptyStateSvgPath,
...props,
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
}); });
describe('empty state', () => { describe('empty state', () => {
beforeEach(() => {
createComponent();
});
it('renders an empty state by default', () => { it('renders an empty state by default', () => {
expectEmptyState(); expectEmptyState();
}); });
...@@ -59,29 +61,55 @@ describe('OnDemandScansApp', () => { ...@@ -59,29 +61,55 @@ describe('OnDemandScansApp', () => {
}); });
}); });
describe('form', () => { describe.each`
beforeEach(async () => { description | securityOnDemandScansSiteProfilesFeatureFlag | expectedComponent | unexpectedComponent
findOnDemandScansEmptyState().vm.$emit('createNewScan'); ${'enabled'} | ${true} | ${OnDemandScansForm} | ${OnDemandScansFormOld}
await wrapper.vm.$nextTick(); ${'disabled'} | ${false} | ${OnDemandScansFormOld} | ${OnDemandScansForm}
}); `(
'with :security_on_demand_scans_site_profiles_feature_flag $description',
it('renders the form when clicking on the primary button', () => { ({ securityOnDemandScansSiteProfilesFeatureFlag, expectedComponent, unexpectedComponent }) => {
expectForm(); const findOnDemandScansForm = () => wrapper.find(expectedComponent);
}); const expectForm = () => {
expect(wrapper.contains(expectedComponent)).toBe(true);
expect(wrapper.contains(unexpectedComponent)).toBe(false);
expect(wrapper.contains(OnDemandScansEmptyState)).toBe(false);
};
it('passes correct props to GlEmptyState', () => { beforeEach(() => {
expect(findOnDemandScansForm().props()).toMatchObject({ createComponent({
defaultBranch, provide: {
helpPagePath, glFeatures: {
projectPath, securityOnDemandScansSiteProfilesFeatureFlag,
},
},
});
}); });
});
it('shows the empty state on cancel', async () => { describe('form', () => {
findOnDemandScansForm().vm.$emit('cancel'); beforeEach(async () => {
await wrapper.vm.$nextTick(); findOnDemandScansEmptyState().vm.$emit('createNewScan');
await wrapper.vm.$nextTick();
});
expectEmptyState(); it('renders the form when clicking on the primary button', () => {
}); expectForm();
}); });
it('passes correct props to GlEmptyState', () => {
expect(findOnDemandScansForm().props()).toMatchObject({
defaultBranch,
helpPagePath,
projectPath,
});
});
it('shows the empty state on cancel', async () => {
findOnDemandScansForm().vm.$emit('cancel');
await wrapper.vm.$nextTick();
expectEmptyState();
});
});
},
);
}); });
import { shallowMount } from '@vue/test-utils';
import { GlForm } from '@gitlab/ui';
import { TEST_HOST } from 'helpers/test_constants';
import OnDemandScansForm from 'ee/on_demand_scans/components/on_demand_scans_form.vue';
import runDastScanMutation from 'ee/on_demand_scans/graphql/run_dast_scan.mutation.graphql';
import createFlash from '~/flash';
import { redirectTo } from '~/lib/utils/url_utility';
const helpPagePath = `${TEST_HOST}/application_security/dast/index#on-demand-scans`;
const projectPath = 'group/project';
const defaultBranch = 'master';
const targetUrl = 'http://example.com';
const pipelineUrl = `${TEST_HOST}/${projectPath}/pipelines/123`;
jest.mock('~/flash');
jest.mock('~/lib/utils/url_utility', () => ({
isAbsolute: jest.requireActual('~/lib/utils/url_utility').isAbsolute,
redirectTo: jest.fn(),
}));
describe('OnDemandScansApp', () => {
let wrapper;
const findForm = () => wrapper.find(GlForm);
const findTargetUrlInput = () => wrapper.find('[data-testid="target-url-input"]');
const submitForm = () => findForm().vm.$emit('submit', { preventDefault: () => {} });
const createComponent = ({ props = {}, computed = {} } = {}) => {
wrapper = shallowMount(OnDemandScansForm, {
attachToDocument: true,
propsData: {
helpPagePath,
projectPath,
defaultBranch,
...props,
},
computed,
mocks: {
$apollo: {
mutate: jest.fn(),
},
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('renders properly', () => {
expect(wrapper.isVueInstance()).toBe(true);
});
describe('computed props', () => {
describe('formData', () => {
it('returns an object with a key:value mapping from the form object including the project path', () => {
wrapper.vm.form = {
targetUrl: {
value: targetUrl,
state: null,
feedback: '',
},
};
expect(wrapper.vm.formData).toEqual({
projectPath,
targetUrl,
});
});
});
describe('formHasErrors', () => {
it('returns true if any of the fields are invalid', () => {
wrapper.vm.form = {
targetUrl: {
value: targetUrl,
state: false,
feedback: '',
},
foo: {
value: 'bar',
state: null,
},
};
expect(wrapper.vm.formHasErrors).toBe(true);
});
it('returns false if none of the fields are invalid', () => {
wrapper.vm.form = {
targetUrl: {
value: targetUrl,
state: null,
feedback: '',
},
foo: {
value: 'bar',
state: null,
},
};
expect(wrapper.vm.formHasErrors).toBe(false);
});
});
describe('someFieldEmpty', () => {
it('returns true if any of the fields are empty', () => {
wrapper.vm.form = {
targetUrl: {
value: '',
state: false,
feedback: '',
},
foo: {
value: 'bar',
state: null,
},
};
expect(wrapper.vm.someFieldEmpty).toBe(true);
});
it('returns false if no field is empty', () => {
wrapper.vm.form = {
targetUrl: {
value: targetUrl,
state: null,
feedback: '',
},
foo: {
value: 'bar',
state: null,
},
};
expect(wrapper.vm.someFieldEmpty).toBe(false);
});
});
describe('isSubmitDisabled', () => {
it.each`
formHasErrors | someFieldEmpty | expected
${true} | ${true} | ${true}
${true} | ${false} | ${true}
${false} | ${true} | ${true}
${false} | ${false} | ${false}
`(
'is $expected when formHasErrors is $formHasErrors and someFieldEmpty is $someFieldEmpty',
({ formHasErrors, someFieldEmpty, expected }) => {
createComponent({
computed: {
formHasErrors: () => formHasErrors,
someFieldEmpty: () => someFieldEmpty,
},
});
expect(wrapper.vm.isSubmitDisabled).toBe(expected);
},
);
});
});
describe('target URL input', () => {
it.each(['asd', 'example.com'])('is marked as invalid provided an invalid URL', async value => {
const input = findTargetUrlInput();
input.vm.$emit('input', value);
await wrapper.vm.$nextTick();
expect(wrapper.vm.form.targetUrl).toEqual({
value,
state: false,
feedback: 'Please enter a valid URL format, ex: http://www.example.com/home',
});
expect(input.attributes().state).toBeUndefined();
});
it('is marked as valid provided a valid URL', async () => {
const input = findTargetUrlInput();
input.vm.$emit('input', targetUrl);
await wrapper.vm.$nextTick();
expect(wrapper.vm.form.targetUrl).toEqual({
value: targetUrl,
state: true,
feedback: null,
});
expect(input.attributes().state).toBe('true');
});
});
describe('submission', () => {
describe('on success', () => {
beforeEach(async () => {
jest
.spyOn(wrapper.vm.$apollo, 'mutate')
.mockResolvedValue({ data: { runDastScan: { pipelineUrl, errors: [] } } });
const input = findTargetUrlInput();
input.vm.$emit('input', targetUrl);
submitForm();
});
it('sets loading state', () => {
expect(wrapper.vm.loading).toBe(true);
});
it('triggers GraphQL mutation', () => {
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: runDastScanMutation,
variables: {
scanType: 'PASSIVE',
branch: 'master',
targetUrl,
projectPath,
},
});
});
it('redirects to the URL provided in the response', () => {
expect(redirectTo).toHaveBeenCalledWith(pipelineUrl);
});
});
describe('on top-level error', () => {
beforeEach(async () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue();
const input = findTargetUrlInput();
input.vm.$emit('input', targetUrl);
submitForm();
});
it('resets loading state', () => {
expect(wrapper.vm.loading).toBe(false);
});
it('shows an error flash', () => {
expect(createFlash).toHaveBeenCalledWith('Could not run the scan. Please try again.');
});
});
describe('on errors as data', () => {
beforeEach(async () => {
const errors = ['A', 'B', 'C'];
jest
.spyOn(wrapper.vm.$apollo, 'mutate')
.mockResolvedValue({ data: { runDastScan: { pipelineUrl: null, errors } } });
const input = findTargetUrlInput();
input.vm.$emit('input', targetUrl);
submitForm();
});
it('resets loading state', () => {
expect(wrapper.vm.loading).toBe(false);
});
it('shows an error flash', () => {
expect(createFlash).toHaveBeenCalledWith('Could not run the scan: A, B, C');
});
});
});
});
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