Commit f1ed5229 authored by Simon Knox's avatar Simon Knox

Merge branch 'test-sast-entry-points' into 'master'

Test SAST entry points [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!64625
parents 2889b54b 28499b1e
......@@ -19,6 +19,7 @@
= render "archived_notice", project: @project
= render_if_exists "projects/marked_for_deletion_notice", project: @project
= render_if_exists "projects/ancestor_group_marked_for_deletion_notice", project: @project
= render_if_exists 'projects/sast_entry_points', project: @project
- view_path = @project.default_view
......
......@@ -214,6 +214,7 @@ module Gitlab
config.assets.precompile << "page_bundles/jira_connect.css"
config.assets.precompile << "page_bundles/jira_connect_users.css"
config.assets.precompile << "page_bundles/learn_gitlab.css"
config.assets.precompile << "page_bundles/marketing_popover.css"
config.assets.precompile << "page_bundles/members.css"
config.assets.precompile << "page_bundles/merge_conflicts.css"
config.assets.precompile << "page_bundles/merge_requests.css"
......
import '~/pages/projects/show/index';
import { initSastEntryPointsExperiment } from 'ee/projects/sast_entry_points_experiment';
import initVueAlerts from '~/vue_alerts';
initVueAlerts();
initSastEntryPointsExperiment();
<script>
import { GlBanner } from '@gitlab/ui';
import { I18N } from '../constants';
import { isDismissed, dismiss, trackShow, trackCtaClicked, trackDismissed } from '../utils';
export default {
components: {
GlBanner,
},
props: {
sastDocumentationPath: {
type: String,
required: true,
},
},
data() {
return {
isVisible: !isDismissed(),
};
},
mounted() {
if (this.isVisible) {
trackShow();
}
},
methods: {
onDismiss() {
this.isVisible = false;
dismiss();
trackDismissed();
},
onClick() {
dismiss();
trackCtaClicked();
},
},
i18n: I18N,
};
</script>
<template>
<gl-banner
v-if="isVisible"
:title="$options.i18n.title"
:button-text="$options.i18n.buttonText"
:button-link="sastDocumentationPath"
variant="promotion"
class="gl-my-5"
@close="onDismiss"
@primary="onClick"
>
<p>
{{ $options.i18n.bodyText }}
</p>
</gl-banner>
</template>
<script>
import { GlPopover, GlButton, GlLink } from '@gitlab/ui';
import { I18N, POPOVER_TARGET } from '../constants';
import { isDismissed, dismiss, trackShow, trackCtaClicked, trackDismissed } from '../utils';
export default {
components: {
GlPopover,
GlButton,
GlLink,
},
props: {
sastDocumentationPath: {
type: String,
required: true,
},
},
data() {
return {
isVisible: !isDismissed(),
target: document.querySelector(POPOVER_TARGET),
};
},
mounted() {
if (this.isVisible) {
trackShow();
}
},
methods: {
onDismiss() {
this.isVisible = false;
dismiss();
trackDismissed();
},
onClick() {
dismiss();
trackCtaClicked();
},
},
gitlabLogo: window.gon.gitlab_logo,
i18n: I18N,
};
</script>
<template>
<gl-popover
v-if="isVisible"
:target="target"
show
triggers="manual"
placement="bottomright"
:css-classes="['marketing-popover', 'gl-border-4']"
>
<div class="gl-display-flex gl-mt-n2">
<img :src="$options.gitlabLogo" :alt="''" height="24" width="24" class="gl-ml-2 gl-mr-3" />
<div>
<div
class="gl-font-weight-bold gl-font-lg gl-line-height-20 gl-text-theme-indigo-900 gl-mb-3"
>
{{ $options.i18n.title }}
</div>
<div class="gl-font-base gl-line-height-20 gl-mb-3">
{{ $options.i18n.bodyText }}
</div>
<gl-link :href="sastDocumentationPath" @click="onClick">
{{ $options.i18n.linkText }}
</gl-link>
</div>
<gl-button
category="tertiary"
class="gl-align-self-start gl-mt-n3 gl-mr-n3"
icon="close"
:aria-label="__('Close')"
@click="onDismiss"
/>
</div>
</gl-popover>
</template>
<script>
import { GlPopover, GlButton, GlLink } from '@gitlab/ui';
import { I18N, POPOVER_TARGET } from '../constants';
import { isDismissed, dismiss, trackShow, trackCtaClicked, trackDismissed } from '../utils';
export default {
components: {
GlPopover,
GlButton,
GlLink,
},
props: {
sastDocumentationPath: {
type: String,
required: true,
},
},
data() {
return {
isVisible: !isDismissed(),
target: document.querySelector(POPOVER_TARGET),
};
},
mounted() {
if (this.isVisible) {
trackShow();
}
},
methods: {
onDismiss() {
this.isVisible = false;
dismiss();
trackDismissed();
},
onClick() {
dismiss();
trackCtaClicked();
},
},
i18n: I18N,
};
</script>
<template>
<gl-popover v-if="isVisible" :target="target" show triggers="manual" placement="bottomright">
<template #title>
<div class="gl-display-flex">
<span>
{{ $options.i18n.title }}
<gl-emoji class="gl-ml-2" data-name="raised_hands" />
</span>
<gl-button
category="tertiary"
class="gl-align-self-start close gl-opacity-10"
icon="close"
:aria-label="__('Close')"
@click="onDismiss"
/>
</div>
</template>
{{ $options.i18n.bodyText }}
<div class="gl-text-right gl-font-weight-bold">
<gl-link :href="sastDocumentationPath" @click="onClick">
{{ $options.i18n.linkText }}
</gl-link>
</div>
</gl-popover>
</template>
import { s__ } from '~/locale';
export const EXPERIMENT_NAME = 'sast_entry_points';
export const COOKIE_NAME = 'sast_entry_point_dismissed';
export const POPOVER_TARGET = '.js-sast-entry-point';
export const I18N = {
title: s__('SastEntryPoints|Catch your security vulnerabilities ahead of time!'),
bodyText: s__(
'SastEntryPoints|GitLab can scan your code for security vulnerabilities. Static Application Security Testing (SAST) helps you worry less and build more.',
),
buttonText: s__('SastEntryPoints|Learn more.'),
linkText: s__('SastEntryPoints|How do I set up SAST?'),
};
import Vue from 'vue';
import Banner from './components/banner.vue';
import PopoverDark from './components/popover_dark.vue';
import PopoverLight from './components/popover_light.vue';
export const initSastEntryPointsExperiment = () => {
const el = document.querySelector('.js-sast-entry-points-experiment');
if (!el) return false;
const { variant, sastDocumentationPath } = el.dataset;
const component = {
banner: Banner,
popover_dark: PopoverDark,
popover_light: PopoverLight,
}[variant];
if (!component) return false;
return new Vue({
el,
render(h) {
return h(component, {
props: {
sastDocumentationPath,
},
});
},
});
};
import ExperimentTracking from '~/experimentation/experiment_tracking';
import { setCookie, getCookie, parseBoolean } from '~/lib/utils/common_utils';
import { COOKIE_NAME, EXPERIMENT_NAME } from './constants';
const tracking = new ExperimentTracking(EXPERIMENT_NAME);
export const isDismissed = () => {
return parseBoolean(getCookie(COOKIE_NAME));
};
export const dismiss = () => {
setCookie(COOKIE_NAME, 'true');
};
export const trackDismissed = () => {
tracking.event('dismissed');
};
export const trackShow = () => {
tracking.event('show');
};
export const trackCtaClicked = () => {
tracking.event('cta_clicked');
};
@import 'page_bundles/mixins_and_variables_and_functions';
.marketing-popover {
max-width: $grid-size * 45;
&.bs-popover-bottom {
@include gl-border-t-solid;
border-top-color: $theme-indigo-900;
.arrow {
@include gl-mt-n2;
&::after {
border-bottom-color: $theme-indigo-900;
}
}
}
&.bs-popover-top {
@include gl-border-b-solid;
border-bottom-color: $theme-indigo-900;
.arrow {
margin-bottom: -$gl-spacing-scale-2;
&::after {
border-top-color: $theme-indigo-900;
}
}
}
}
......@@ -12,6 +12,7 @@ module EE
before_action only: :show do
push_frontend_feature_flag(:cve_id_request_button, project)
enable_sast_entry_points_experiment
end
feature_category :projects, [:restore]
......@@ -190,5 +191,18 @@ module EE
def log_unarchive_audit_event
log_audit_event(message: 'Project unarchived')
end
def enable_sast_entry_points_experiment
return unless enable_sast_entry_points_experiment?(project)
experiment(:sast_entry_points, namespace: project.root_ancestor) do |e|
e.control {}
e.candidate(:banner) {}
e.candidate(:popover_light) {}
e.candidate(:popover_dark) {}
e.record!
end
end
end
end
......@@ -243,6 +243,17 @@ module EE
project.marked_for_deletion_at.present?
end
def enable_sast_entry_points_experiment?(project)
can?(current_user, :admin_project, project) &&
!project.empty_repo? &&
!OnboardingProgress.completed?(project.root_ancestor, :security_scan_enabled)
end
def sast_entry_points_experiment_enabled?(project)
enable_sast_entry_points_experiment?(project) &&
experiment(:sast_entry_points, namespace: project.root_ancestor).variant.group == :experiment
end
private
def remove_message_data(project)
......
......@@ -10,11 +10,30 @@ module EE
end
def extra_statistics_buttons
[]
[sast_anchor_data.presence].compact
end
def approver_groups
::ApproverGroup.filtered_approver_groups(project.approver_groups, current_user)
end
private
def sast_anchor_data
return unless sast_entry_points_experiment_enabled?(project)
::ProjectPresenter::AnchorData.new(
false,
statistic_icon + s_('SastEntryPoints|Add Security Testing'),
help_page_path('user/application_security/sast/index'),
'btn-dashed js-sast-entry-point',
nil,
nil,
{
'track-event': 'cta_clicked_button',
'track-experiment': 'sast_entry_points'
}
)
end
end
end
- if sast_entry_points_experiment_enabled?(project)
- variant = experiment(:sast_entry_points, namespace: project.root_ancestor).variant.name
- add_page_specific_style 'page_bundles/marketing_popover'
.js-sast-entry-points-experiment{ data: { variant: variant, sast_documentation_path: help_page_path('user/application_security/sast/index') } }
---
name: sast_entry_points
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64625
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/334277
milestone: '14.1'
type: experiment
group: group::activation
default_enabled: false
......@@ -105,6 +105,28 @@ RSpec.describe ProjectsController do
it_behaves_like 'namespace storage limit alert'
end
context 'sast_entry_points experiment' do
before do
allow(controller).to receive(:enable_sast_entry_points_experiment?).with(public_project).and_return(true)
stub_experiments(sast_entry_points: :banner)
end
it 'tracks the assignment', :experiment do
expect(experiment(:sast_entry_points))
.to track(:assignment)
.with_context(namespace: public_project.namespace)
.on_next_instance
subject
end
it 'records the subject' do
expect(Experiment).to receive(:add_subject).with('sast_entry_points', variant: :experimental, subject: public_project.namespace)
subject
end
end
end
describe 'GET edit' do
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`When the cookie is not set matches the snapshot 1`] = `
<gl-banner-stub
buttonlink="sast_documentation_path"
buttontext="Learn more."
class="gl-my-5"
title="Catch your security vulnerabilities ahead of time!"
variant="promotion"
>
<p>
GitLab can scan your code for security vulnerabilities. Static Application Security Testing (SAST) helps you worry less and build more.
</p>
</gl-banner-stub>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`When the cookie is not set matches the snapshot 1`] = `
<gl-popover-stub
cssclasses="marketing-popover,gl-border-4"
placement="bottomright"
show=""
triggers="manual"
>
<div
class="gl-display-flex gl-mt-n2"
>
<img
alt=""
class="gl-ml-2 gl-mr-3"
height="24"
width="24"
/>
<div>
<div
class="gl-font-weight-bold gl-font-lg gl-line-height-20 gl-text-theme-indigo-900 gl-mb-3"
>
Catch your security vulnerabilities ahead of time!
</div>
<div
class="gl-font-base gl-line-height-20 gl-mb-3"
>
GitLab can scan your code for security vulnerabilities. Static Application Security Testing (SAST) helps you worry less and build more.
</div>
<gl-link-stub
href="sast_documentation_path"
>
How do I set up SAST?
</gl-link-stub>
</div>
<gl-button-stub
aria-label="Close"
buttontextclasses=""
category="tertiary"
class="gl-align-self-start gl-mt-n3 gl-mr-n3"
icon="close"
size="medium"
variant="default"
/>
</div>
</gl-popover-stub>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`When the cookie is not set matches the snapshot 1`] = `
<div
class="gl-popover"
show=""
>
GitLab can scan your code for security vulnerabilities. Static Application Security Testing (SAST) helps you worry less and build more.
<div
class="gl-text-right gl-font-weight-bold"
>
<a
class="gl-link"
href="sast_documentation_path"
>
How do I set up SAST?
</a>
</div>
<div
class="gl-display-flex"
>
<span>
Catch your security vulnerabilities ahead of time!
<gl-emoji
class="gl-ml-2"
data-name="raised_hands"
/>
</span>
<button
aria-label="Close"
class="btn gl-align-self-start close gl-opacity-10 btn-default btn-md gl-button btn-default-tertiary btn-icon"
type="button"
>
<!---->
<svg
aria-hidden="true"
class="gl-button-icon gl-icon s16"
data-testid="close-icon"
role="img"
>
<use
href="#close"
/>
</svg>
<!---->
</button>
</div>
</div>
`;
import { GlBanner } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Cookies from 'js-cookie';
import Banner from 'ee/projects/sast_entry_points_experiment/components/banner.vue';
import { COOKIE_NAME } from 'ee/projects/sast_entry_points_experiment/constants';
import ExperimentTracking from '~/experimentation/experiment_tracking';
jest.mock('~/experimentation/experiment_tracking');
let wrapper;
const sastDocumentationPath = 'sast_documentation_path';
const findBanner = () => wrapper.findComponent(GlBanner);
function createComponent() {
wrapper = shallowMount(Banner, {
propsData: { sastDocumentationPath },
});
}
afterEach(() => {
wrapper.destroy();
Cookies.remove(COOKIE_NAME);
});
describe('When the cookie is set', () => {
beforeEach(() => {
Cookies.set(COOKIE_NAME, 'true', { expires: 365 });
createComponent();
});
it('does not render the component', () => {
expect(findBanner().exists()).toBe(false);
});
});
describe('When the cookie is not set', () => {
beforeEach(() => {
createComponent();
});
it('renders the component', () => {
expect(findBanner().exists()).toBe(true);
});
it('tracks the show event', () => {
expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('show');
});
it('uses the sastDocumentationPath from the props for the button link', () => {
expect(findBanner().attributes('buttonlink')).toBe(sastDocumentationPath);
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
describe('When clicking the CTA button', () => {
beforeEach(() => {
findBanner().vm.$emit('primary');
});
it('tracks the cta_clicked event', () => {
expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('cta_clicked');
});
it('sets a cookie', () => {
expect(Cookies.get(COOKIE_NAME)).toBe('true');
});
});
describe('When dismissing the component', () => {
beforeEach(() => {
findBanner().vm.$emit('close');
});
it('tracks the dismissed event', () => {
expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('dismissed');
});
it('sets a cookie', () => {
expect(Cookies.get(COOKIE_NAME)).toBe('true');
});
it('hides the component', () => {
expect(findBanner().exists()).toBe(false);
});
});
});
import '~/commons';
import { GlPopover, GlButton, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Cookies from 'js-cookie';
import PopoverDark from 'ee/projects/sast_entry_points_experiment/components/popover_dark.vue';
import { COOKIE_NAME } from 'ee/projects/sast_entry_points_experiment/constants';
import ExperimentTracking from '~/experimentation/experiment_tracking';
jest.mock('~/experimentation/experiment_tracking');
let wrapper;
const sastDocumentationPath = 'sast_documentation_path';
const findPopover = () => wrapper.findComponent(GlPopover);
const findCtaLink = () => findPopover().findComponent(GlLink);
const findCloseButton = () => findPopover().findComponent(GlButton);
function createComponent() {
wrapper = shallowMount(PopoverDark, {
propsData: { sastDocumentationPath },
});
}
afterEach(() => {
wrapper.destroy();
Cookies.remove(COOKIE_NAME);
});
describe('When the cookie is set', () => {
beforeEach(() => {
Cookies.set(COOKIE_NAME, 'true', { expires: 365 });
createComponent();
});
it('does not render the component', () => {
expect(findPopover().exists()).toBe(false);
});
});
describe('When the cookie is not set', () => {
beforeEach(() => {
createComponent();
});
it('renders the component', () => {
expect(findPopover().exists()).toBe(true);
});
it('tracks the show event', () => {
expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('show');
});
it('uses the sastDocumentationPath from the props for the button link', () => {
expect(findCtaLink().attributes('href')).toBe(sastDocumentationPath);
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
describe('When clicking the CTA button', () => {
beforeEach(() => {
findCtaLink().vm.$emit('click');
});
it('tracks the cta_clicked event', () => {
expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('cta_clicked');
});
it('sets a cookie', () => {
expect(Cookies.get(COOKIE_NAME)).toBe('true');
});
});
describe('When dismissing the component', () => {
beforeEach(() => {
findCloseButton().vm.$emit('click');
});
it('tracks the dismissed event', () => {
expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('dismissed');
});
it('sets a cookie', () => {
expect(Cookies.get(COOKIE_NAME)).toBe('true');
});
it('hides the component', () => {
expect(findPopover().exists()).toBe(false);
});
});
});
import '~/commons';
import { GlPopover, GlButton, GlLink } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import Cookies from 'js-cookie';
import PopoverLight from 'ee/projects/sast_entry_points_experiment/components/popover_light.vue';
import { COOKIE_NAME } from 'ee/projects/sast_entry_points_experiment/constants';
import ExperimentTracking from '~/experimentation/experiment_tracking';
jest.mock('~/experimentation/experiment_tracking');
let wrapper;
const sastDocumentationPath = 'sast_documentation_path';
const findPopover = () => wrapper.findComponent(GlPopover);
const findCtaLink = () => findPopover().findComponent(GlLink);
const findCloseButton = () => findPopover().findComponent(GlButton);
function createComponent() {
wrapper = mount(PopoverLight, {
propsData: { sastDocumentationPath },
});
}
afterEach(() => {
wrapper.destroy();
Cookies.remove(COOKIE_NAME);
});
describe('When the cookie is set', () => {
beforeEach(() => {
Cookies.set(COOKIE_NAME, 'true', { expires: 365 });
createComponent();
});
it('does not render the component', () => {
expect(findPopover().exists()).toBe(false);
});
});
describe('When the cookie is not set', () => {
beforeEach(() => {
createComponent();
});
it('renders the component', () => {
expect(findPopover().exists()).toBe(true);
});
it('tracks the show event', () => {
expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('show');
});
it('uses the sastDocumentationPath from the props for the button link', () => {
expect(findCtaLink().attributes('href')).toBe(sastDocumentationPath);
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
describe('When clicking the CTA button', () => {
beforeEach(() => {
findCtaLink().vm.$emit('click');
});
it('tracks the cta_clicked event', () => {
expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('cta_clicked');
});
it('sets a cookie', () => {
expect(Cookies.get(COOKIE_NAME)).toBe('true');
});
});
describe('When dismissing the component', () => {
beforeEach(() => {
findCloseButton().vm.$emit('click');
});
it('tracks the dismissed event', () => {
expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('dismissed');
});
it('sets a cookie', () => {
expect(Cookies.get(COOKIE_NAME)).toBe('true');
});
it('hides the component', () => {
expect(findPopover().exists()).toBe(false);
});
});
});
......@@ -413,4 +413,49 @@ RSpec.describe ProjectsHelper do
})
end
end
describe '#enable_sast_entry_points_experiment?' do
using RSpec::Parameterized::TableSyntax
where(
can_admin_project?: [true, false],
empty_repo?: [true, false],
sast_enabled?: [true, false]
)
with_them do
before do
allow(helper).to receive(:can?) { can_admin_project? }
allow(project).to receive(:empty_repo?) { empty_repo? }
allow(project).to receive(:scanner_enabled?).and_call_original
allow(OnboardingProgress).to receive(:completed?).with(project.root_ancestor, :security_scan_enabled) { sast_enabled? }
allow(helper).to receive(:current_user) { double }
end
subject { helper.enable_sast_entry_points_experiment?(project) }
it { is_expected.to eq(can_admin_project? && !empty_repo? && !sast_enabled?) }
end
end
describe '#sast_entry_points_experiment_enabled?' do
using RSpec::Parameterized::TableSyntax
where(
enable_sast_entry_points_experiment?: [true, false],
experiment_enabled?: [true, false]
)
with_them do
before do
allow(helper).to receive(:enable_sast_entry_points_experiment?) { enable_sast_entry_points_experiment? }
variant = experiment_enabled? ? :banner : :control
stub_experiments(sast_entry_points: variant)
end
subject { helper.sast_entry_points_experiment_enabled?(project) }
it { is_expected.to eq(enable_sast_entry_points_experiment? && experiment_enabled?) }
end
end
end
......@@ -12,5 +12,15 @@ RSpec.describe ProjectPresenter do
let(:presenter) { described_class.new(project, current_user: user) }
it { expect(presenter.extra_statistics_buttons).to be_empty }
context 'when the sast entry points experiment is enabled' do
before do
allow(presenter).to receive(:sast_entry_points_experiment_enabled?).with(project).and_return(true)
end
it 'has the sast help page button' do
expect(presenter.extra_statistics_buttons.find { |button| button[:link] == help_page_path('user/application_security/sast/index') }).not_to be_nil
end
end
end
end
......@@ -28492,6 +28492,21 @@ msgstr ""
msgid "SVG illustration"
msgstr ""
msgid "SastEntryPoints|Add Security Testing"
msgstr ""
msgid "SastEntryPoints|Catch your security vulnerabilities ahead of time!"
msgstr ""
msgid "SastEntryPoints|GitLab can scan your code for security vulnerabilities. Static Application Security Testing (SAST) helps you worry less and build more."
msgstr ""
msgid "SastEntryPoints|How do I set up SAST?"
msgstr ""
msgid "SastEntryPoints|Learn more."
msgstr ""
msgid "Satisfied"
msgstr ""
......
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