Commit a30c3f03 authored by Sean McGivern's avatar Sean McGivern

Merge branch '6165_project_security_dashboard' into 'master'

Show latest security reports at project level

Closes #6165

See merge request gitlab-org/gitlab-ee!6197
parents 83a79a28 3ed2b8ae
......@@ -362,6 +362,12 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
end
end
# EE-specific start
namespace :security do
resource :dashboard, only: [:show], controller: :dashboard
end
# EE-specific end
resources :milestones, constraints: { id: /\d+/ } do
member do
post :promote
......
......@@ -82,6 +82,7 @@ website with GitLab Pages
- [Wiki](wiki/index.md): Document your GitLab project in an integrated Wiki
- [Snippets](../snippets.md): Store, share and collaborate on code snippets
- [Cycle Analytics](cycle_analytics.md): Review your development lifecycle
- [Security Dashboard](security_dashboard.md): Security Dashboard
- [Syntax highlighting](highlighting.md): An alternative to customize
your code blocks, overriding GitLab's default choice of language
- [Badges](badges.md): Badges for the project overview
......
# Project Security Dashboard
> [Introduced][ee-6165] in [GitLab Ultimate][ee] 11.1.
The Security Dashboard displays the latest security reports for your project.
Use it to find and fix vulnerabilities affecting the [default branch](./repository/branches/index.md#default-branch).
![Project Security Dashboard](img/project_security_dashboard.png)
## How it works?
To benefit from the Security Dashboard you must first configure the [Security Reports](./merge_requests/index.md#security-reports).
The Security Dashboard will then list security vulnerabilities from the latest pipeline run on the default branch (e.g., `master`).
You will also be able to interact with the reports [the same way you can do on a merge request](./merge_requests/index.md#interacting-with-security-reports).
[ee-6165]: https://gitlab.com/gitlab-org/gitlab-ee/issues/6165
[ee]: https://about.gitlab.com/pricing
import Vue from 'vue';
import createStore from 'ee/vue_shared/security_reports/store';
import SecurityReportApp from 'ee/vue_shared/security_reports/card_security_reports_app.vue';
document.addEventListener('DOMContentLoaded', () => {
const securityTab = document.getElementById('js-security-report-app');
const {
hasPipelineData,
userPath,
userAvatarPath,
pipelineCreated,
pipelinePath,
userName,
commitId,
commitPath,
refId,
refPath,
pipelineId,
canCreateFeedback,
canCreateIssue,
...rest
} = securityTab.dataset;
const parsedPipelineId = parseInt(pipelineId, 10);
const store = createStore();
return new Vue({
el: securityTab,
store,
components: {
SecurityReportApp,
},
methods: {},
render(createElement) {
return createElement('security-report-app', {
props: {
pipelineId: parsedPipelineId,
hasPipelineData: hasPipelineData === 'true',
canCreateIssue: canCreateIssue === 'true',
canCreateFeedback: canCreateFeedback === 'true',
triggeredBy: {
avatarPath: userAvatarPath,
name: userName,
path: userPath,
},
pipeline: {
id: parsedPipelineId,
created: pipelineCreated,
path: pipelinePath,
},
commit: {
id: commitId,
path: commitPath,
},
branch: {
id: refId,
path: refPath,
},
...rest,
},
});
},
});
});
<script>
import { s__, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import EmptySecurityDashboard from './components/empty_security_dashboard.vue';
import SplitSecurityReport from './split_security_reports_app.vue';
export default {
components: {
EmptySecurityDashboard,
UserAvatarLink,
Icon,
SplitSecurityReport,
TimeagoTooltip,
},
props: {
hasPipelineData: {
type: Boolean,
required: false,
default: false,
},
emptyStateIllustrationPath: {
type: String,
required: false,
default: null,
},
securityDashboardHelpPath: {
type: String,
required: false,
default: null,
},
alwaysOpen: {
type: Boolean,
required: false,
default: false,
},
headBlobPath: {
type: String,
required: false,
default: null,
},
sastHeadPath: {
type: String,
required: false,
default: null,
},
dastHeadPath: {
type: String,
required: false,
default: null,
},
sastContainerHeadPath: {
type: String,
required: false,
default: null,
},
dependencyScanningHeadPath: {
type: String,
required: false,
default: null,
},
sastHelpPath: {
type: String,
required: false,
default: null,
},
sastContainerHelpPath: {
type: String,
required: false,
default: '',
},
dastHelpPath: {
type: String,
required: false,
default: '',
},
dependencyScanningHelpPath: {
type: String,
required: false,
default: null,
},
vulnerabilityFeedbackPath: {
type: String,
required: false,
default: '',
},
vulnerabilityFeedbackHelpPath: {
type: String,
required: false,
default: '',
},
pipelineId: {
type: Number,
required: false,
default: null,
},
commit: {
type: Object,
required: false,
default: () => ({}),
},
triggeredBy: {
type: Object,
required: false,
default: () => ({}),
},
branch: {
type: Object,
required: false,
default: () => ({}),
},
pipeline: {
type: Object,
required: false,
default: () => ({}),
},
canCreateFeedback: {
type: Boolean,
required: true,
},
canCreateIssue: {
type: Boolean,
required: true,
},
},
computed: {
headline() {
return sprintf(
s__('SecurityDashboard|Pipeline %{pipelineLink} triggered'),
{
pipelineLink: `<a href="${this.pipeline.path}">#${this.pipeline.id}</a>`,
},
false,
);
},
},
};
</script>
<template>
<div>
<div
v-if="hasPipelineData"
class="card security-dashboard prepend-top-default"
>
<div class="card-header">
<span class="js-security-dashboard-left">
<span v-html="headline"></span>
<timeago-tooltip :time="pipeline.created"/>
{{ __('by') }}
<user-avatar-link
:link-href="triggeredBy.path"
:img-src="triggeredBy.avatarPath"
:img-alt="triggeredBy.name"
:img-size="24"
:username="triggeredBy.name"
class="avatar-image-container"
/>
</span>
<span class="js-security-dashboard-right pull-right">
<icon name="branch"/>
<a
:href="branch.path"
class="monospace"
>{{ branch.id }}</a>
<span class="text-muted prepend-left-5 append-right-5">&middot;</span>
<icon name="commit"/>
<a
:href="commit.path"
class="monospace"
>{{ commit.id }}</a>
</span>
</div>
<split-security-report
:pipeline-id="pipelineId"
:head-blob-path="headBlobPath"
:sast-head-path="sastHeadPath"
:dast-head-path="dastHeadPath"
:sast-container-head-path="sastContainerHeadPath"
:dependency-scanning-head-path="dependencyScanningHeadPath"
:sast-help-path="sastHelpPath"
:sast-container-help-path="sastContainerHelpPath"
:dast-help-path="dastHelpPath"
:dependency-scanning-help-path="dependencyScanningHelpPath"
:vulnerability-feedback-path="vulnerabilityFeedbackPath"
:vulnerability-feedback-help-path="vulnerabilityFeedbackHelpPath"
:can-create-feedback="canCreateFeedback"
:can-create-issue="canCreateIssue"
always-open
/>
</div>
<empty-security-dashboard
v-else
:help-path="securityDashboardHelpPath"
:illustration-path="emptyStateIllustrationPath"
/>
</div>
</template>
<script>
import { s__ } from '~/locale';
export default {
props: {
illustrationPath: {
type: String,
required: true,
},
helpPath: {
type: String,
required: true,
},
},
computed: {
paragraphText: () =>
s__(
`SecurityDashboard|
The security dashboard displays the latest security report.
Use it to find and fix vulnerabilities.`,
),
},
};
</script>
<template>
<div class="row empty-state">
<div class="col-12">
<div class="svg-content">
<img
:src="illustrationPath"
/>
</div>
</div>
<div class="col-12">
<div class="text-content text-center">
<h4>
{{ s__('SecurityDashboard|Monitor vulnerabilities in your code') }}
</h4>
<p>
{{ paragraphText }}
</p>
<a
:href="helpPath"
class="btn btn-new"
rel="nofollow"
>
{{ __('Learn more') }}
</a>
</div>
</div>
</div>
</template>
<script>
import { __ } from '~/locale';
import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import IssuesList from './issues_list.vue';
import Popover from './help_popover.vue';
import { LOADING, ERROR, SUCCESS } from '../store/constants';
......@@ -10,11 +9,15 @@ export default {
name: 'ReportSection',
components: {
IssuesList,
LoadingIcon,
StatusIcon,
Popover,
},
props: {
alwaysOpen: {
type: Boolean,
required: false,
default: false,
},
type: {
type: String,
required: false,
......@@ -76,12 +79,14 @@ export default {
data() {
return {
collapseText: __('Expand'),
isCollapsed: true,
};
},
computed: {
collapseText() {
return this.isCollapsed ? __('Expand') : __('Collapse');
},
isLoading() {
return this.status === LOADING;
},
......@@ -91,7 +96,16 @@ export default {
isSuccess() {
return this.status === SUCCESS;
},
isCollapsible() {
return !this.alwaysOpen && this.hasIssues;
},
isExpanded() {
return this.alwaysOpen || !this.isCollapsed;
},
statusIconName() {
if (this.isLoading) {
return 'loading';
}
if (this.loadingFailed || this.unresolvedIssues.length || this.neutralIssues.length) {
return 'warning';
}
......@@ -116,13 +130,9 @@ export default {
return Object.keys(this.popoverOptions).length > 0;
},
},
methods: {
toggleCollapsed() {
this.isCollapsed = !this.isCollapsed;
const text = this.isCollapsed ? __('Expand') : __('Collapse');
this.collapseText = text;
},
},
};
......@@ -130,15 +140,9 @@ export default {
<template>
<section>
<div
class="media prepend-top-default prepend-left-default
append-right-default append-bottom-default"
class="media"
>
<loading-icon
v-if="isLoading"
class="mr-widget-icon"
/>
<status-icon
v-else
:status="statusIconName"
/>
<div
......@@ -157,7 +161,7 @@ export default {
</span>
<button
v-if="hasIssues"
v-if="isCollapsible"
type="button"
class="js-collapse-btn btn bt-default float-right btn-sm"
@click="toggleCollapsed"
......@@ -169,7 +173,7 @@ export default {
<div
v-if="hasIssues"
v-show="!isCollapsed"
v-show="isExpanded"
class="js-report-section-container"
>
<slot name="body">
......
......@@ -15,6 +15,11 @@ export default {
},
mixins: [mixin, reportsMixin],
props: {
alwaysOpen: {
type: Boolean,
required: false,
default: false,
},
headBlobPath: {
type: String,
required: true,
......@@ -210,6 +215,7 @@ export default {
<div>
<report-section
v-if="sastHeadPath"
:always-open="alwaysOpen"
:type="$options.sast"
:status="checkReportStatus(sast.isLoading, sast.hasError)"
:loading-text="translateText('SAST').loading"
......@@ -223,6 +229,7 @@ export default {
<report-section
v-if="dependencyScanningHeadPath"
:always-open="alwaysOpen"
:type="$options.sast"
:status="checkReportStatus(dependencyScanning.isLoading, dependencyScanning.hasError)"
:loading-text="translateText('Dependency scanning').loading"
......@@ -236,6 +243,7 @@ export default {
<report-section
v-if="sastContainerHeadPath"
:always-open="alwaysOpen"
:type="$options.sastContainer"
:status="checkReportStatus(sastContainer.isLoading, sastContainer.hasError)"
:loading-text="translateText('Container scanning').loading"
......@@ -249,6 +257,7 @@ export default {
<report-section
v-if="dastHeadPath"
:always-open="alwaysOpen"
:type="$options.dast"
:status="checkReportStatus(dast.isLoading, dast.hasError)"
:loading-text="translateText('DAST').loading"
......
......@@ -112,3 +112,31 @@
border-left: none;
}
}
.security-dashboard {
.card-header {
padding: $gl-padding;
background-color: $gray-light;
.user-avatar-link {
color: $gl-text-color;
font-weight: $gl-font-weight-bold;
.avatar {
margin-right: $gl-padding-4;
}
}
svg {
vertical-align: sub;
}
.avatar {
float: none;
}
}
.split-report-section:last-of-type {
border-bottom: none;
}
}
......@@ -14,6 +14,22 @@
.media {
align-items: center;
padding: 10px;
line-height: 20px;
/*
This fixes the wrapping div of the icon in the report header.
Apparently the borderless status icons are half the size of the status icons with border.
This means we have to double the size of the wrapping div for borderless icons.
*/
.space-children:first-child {
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
margin-right: 5px;
margin-left: 1px;
}
}
.code-text {
......@@ -108,7 +124,7 @@
}
.report-block-list-issue-description-text::after {
content: "\00a0";
content: '\00a0';
}
.report-block-list-issue-description {
......
module Projects
module Security
class DashboardController < Projects::ApplicationController
before_action :ensure_security_features_enabled
before_action :authorize_read_project_security_dashboard!
def show
@pipeline = @project.latest_pipeline_with_security_reports
end
private
def ensure_security_features_enabled
render_404 unless @project.security_reports_feature_available?
end
end
end
end
......@@ -2,6 +2,11 @@ module EE
module ProjectsHelper
extend ::Gitlab::Utils::Override
override :sidebar_projects_paths
def sidebar_projects_paths
super + %w(projects/security/dashboard#show)
end
override :sidebar_settings_paths
def sidebar_settings_paths
super + %w(audit_events#index)
......@@ -95,5 +100,53 @@ module EE
description.to_s.html_safe
end
def project_security_dashboard_config(project, pipeline)
if pipeline.nil?
{
empty_state_illustration_path: image_path('illustrations/security-dashboard_empty.svg'),
security_dashboard_help_path: help_page_path("user/project/security_dashboard"),
has_pipeline_data: "false",
can_create_feedback: "false",
can_create_issue: "false"
}
else
# Handle old job and artifact names for container scanning
sast_container_head_path = if pipeline.expose_sast_container_data?
sast_container_artifact_url(pipeline)
elsif pipeline.expose_container_scanning_data?
container_scanning_artifact_url(pipeline)
else
nil
end
{
head_blob_path: project_blob_path(project, pipeline.sha),
sast_head_path: pipeline.expose_sast_data? ? sast_artifact_url(pipeline) : nil,
dependency_scanning_head_path: pipeline.expose_dependency_scanning_data? ? dependency_scanning_artifact_url(pipeline) : nil,
dast_head_path: pipeline.expose_dast_data? ? dast_artifact_url(pipeline) : nil,
sast_container_head_path: sast_container_head_path,
vulnerability_feedback_path: project_vulnerability_feedback_index_path(project),
pipeline_id: pipeline.id,
vulnerability_feedback_help_path: help_page_path("user/project/merge_requests/index", anchor: "interacting-with-security-reports-ultimate"),
sast_help_path: help_page_path('user/project/merge_requests/sast'),
dependency_scanning_help_path: help_page_path('user/project/merge_requests/dependency_scanning'),
dast_help_path: help_page_path('user/project/merge_requests/dast'),
sast_container_help_path: help_page_path('user/project/merge_requests/sast_container'),
user_path: user_url(pipeline.user),
user_avatar_path: pipeline.user.avatar_url,
user_name: pipeline.user.name,
commit_id: pipeline.commit.short_id,
commit_path: project_commit_url(project, pipeline.commit),
ref_id: pipeline.ref,
ref_path: project_commits_url(project, pipeline.ref),
pipeline_path: pipeline_url(pipeline),
pipeline_created: pipeline.created_at.to_s,
has_pipeline_data: "true",
can_create_feedback: can?(current_user, :admin_vulnerability_feedback, project).to_s,
can_create_issue: can?(current_user, :create_issue, project).to_s
}
end
end
end
end
......@@ -10,6 +10,10 @@ module EE
included do
has_one :chat_data, class_name: 'Ci::PipelineChatData'
scope :with_security_reports, -> {
joins(:artifacts).where(ci_builds: { name: %w[sast dependency_scanning sast:container container_scanning dast] })
}
end
# codeclimate_artifact is deprecated and replaced with code_quality_artifact (#5779)
......
......@@ -88,6 +88,17 @@ module EE
end
end
def security_reports_feature_available?
feature_available?(:sast) ||
feature_available?(:dependency_scanning) ||
feature_available?(:sast_container) ||
feature_available?(:dast)
end
def latest_pipeline_with_security_reports
pipelines.newest_first(default_branch).with_security_reports.first
end
def ensure_external_webhook_token
return if external_webhook_token.present?
......
......@@ -81,6 +81,7 @@ module EE
rule { can?(:developer_access) }.policy do
enable :admin_board
enable :admin_vulnerability_feedback
enable :read_project_security_dashboard
end
rule { can?(:read_project) }.enable :read_vulnerability_feedback
......
- breadcrumb_title _("Security Dashboard")
- page_title _("Security Dashboard")
#js-security-report-app{ data: project_security_dashboard_config(@project, @pipeline) }
- return unless @project.security_reports_feature_available? && can?(current_user, :read_project_security_dashboard, @project)
= nav_link(path: 'projects/security/dashboard#show') do
= link_to project_security_dashboard_path(@project), title: _('Security Dashboard'), class: 'shortcuts-project-security-dashboard' do
%span= _('Security Dashboard')
---
title: Add project Security Dashboard
merge_request: 6197
author:
type: added
require 'spec_helper'
describe Projects::Security::DashboardController do
let(:group) { create(:group) }
let(:project) { create(:project, :public, namespace: group) }
let(:user) { create(:user) }
before do
group.add_developer(user)
end
describe 'GET #show' do
let(:pipeline_1) { create(:ci_pipeline_without_jobs, project: project) }
let(:pipeline_2) { create(:ci_pipeline_without_jobs, project: project) }
let(:pipeline_3) { create(:ci_pipeline_without_jobs, project: project) }
before do
create(
:ci_build,
:success,
:artifacts,
name: 'sast',
pipeline: pipeline_1,
options: {
artifacts: {
paths: [Ci::Build::SAST_FILE]
}
}
)
end
def show_security_dashboard(current_user = user)
sign_in(current_user)
get :show, namespace_id: project.namespace, project_id: project
end
context 'when security reports features are enabled' do
it 'returns the latest pipeline with security reports for project' do
stub_licensed_features(sast: true)
show_security_dashboard
expect(response).to have_gitlab_http_status(200)
expect(response).to render_template(:show)
end
end
context 'when security reports features are disabled' do
it 'returns the latest pipeline with security reports for project' do
stub_licensed_features(sast: false, dependency_scanning: false, sast_container: false, dast: false)
show_security_dashboard
expect(response).to have_gitlab_http_status(404)
expect(response).to render_template('errors/not_found')
end
end
context 'with unauthorized user for security dashboard' do
let(:guest) { create(:user) }
it 'returns a not found 404 response' do
stub_licensed_features(sast: true)
group.add_guest(guest)
show_security_dashboard guest
expect(response).to have_gitlab_http_status(404)
expect(response).to render_template('errors/access_denied')
end
end
end
end
......@@ -96,4 +96,79 @@ describe Ci::Pipeline do
it { expect(pipeline.send(method.to_sym)).to be_truthy }
end
end
describe '#with_security_reports scope' do
let(:pipeline_1) { create(:ci_pipeline_without_jobs, project: project) }
let(:pipeline_2) { create(:ci_pipeline_without_jobs, project: project) }
let(:pipeline_3) { create(:ci_pipeline_without_jobs, project: project) }
let(:pipeline_4) { create(:ci_pipeline_without_jobs, project: project) }
let(:pipeline_5) { create(:ci_pipeline_without_jobs, project: project) }
before do
create(
:ci_build,
:success,
:artifacts,
name: 'sast',
pipeline: pipeline_1,
options: {
artifacts: {
paths: [Ci::Build::SAST_FILE]
}
}
)
create(
:ci_build,
:success,
:artifacts,
name: 'dependency_scanning',
pipeline: pipeline_2,
options: {
artifacts: {
paths: [Ci::Build::DEPENDENCY_SCANNING_FILE]
}
}
)
create(
:ci_build,
:success,
:artifacts,
name: 'container_scanning',
pipeline: pipeline_3,
options: {
artifacts: {
paths: [Ci::Build::CONTAINER_SCANNING_FILE]
}
}
)
create(
:ci_build,
:success,
:artifacts,
name: 'dast',
pipeline: pipeline_4,
options: {
artifacts: {
paths: [Ci::Build::DAST_FILE]
}
}
)
create(
:ci_build,
:success,
:artifacts,
name: 'foobar',
pipeline: pipeline_5,
options: {
artifacts: {
paths: ['foobar-report.json']
}
}
)
end
it "returns pipeline with security reports" do
expect(described_class.with_security_reports).to eq([pipeline_1, pipeline_2, pipeline_3, pipeline_4])
end
end
end
......@@ -1420,4 +1420,65 @@ describe Project do
2.times { expect(project.any_path_locks?).to be_truthy }
end
end
describe '#security_reports_feature_available?' do
security_features = %i[sast dependency_scanning sast_container dast]
let(:project) { create(:project) }
security_features.each do |feature|
it "returns true when at least #{feature} is enabled" do
allow(project).to receive(:feature_available?) { false }
allow(project).to receive(:feature_available?).with(feature) { true }
expect(project.security_reports_feature_available?).to eq(true)
end
end
it "returns false when all security features are disabled" do
security_features.each do |feature|
allow(project).to receive(:feature_available?).with(feature) { false }
end
expect(project.security_reports_feature_available?).to eq(false)
end
end
describe '#latest_pipeline_with_security_reports' do
let(:project) { create(:project) }
let(:pipeline_1) { create(:ci_pipeline_without_jobs, project: project) }
let(:pipeline_2) { create(:ci_pipeline_without_jobs, project: project) }
let(:pipeline_3) { create(:ci_pipeline_without_jobs, project: project) }
before do
create(
:ci_build,
:success,
:artifacts,
name: 'sast',
pipeline: pipeline_1,
options: {
artifacts: {
paths: [Ci::Build::SAST_FILE]
}
}
)
create(
:ci_build,
:success,
:artifacts,
name: 'sast',
pipeline: pipeline_2,
options: {
artifacts: {
paths: [Ci::Build::SAST_FILE]
}
}
)
end
it "returns the latest pipeline with security reports" do
expect(project.latest_pipeline_with_security_reports).to eq(pipeline_2)
end
end
end
......@@ -303,4 +303,56 @@ describe ProjectPolicy do
it { is_expected.to be_disallowed(:admin_vulnerability_feedback) }
end
end
describe 'read_project_security_dashboard' do
subject { described_class.new(current_user, project) }
context 'with admin' do
let(:current_user) { admin }
it { is_expected.to be_allowed(:read_project_security_dashboard) }
end
context 'with owner' do
let(:current_user) { owner }
it { is_expected.to be_allowed(:read_project_security_dashboard) }
end
context 'with master' do
let(:current_user) { master }
it { is_expected.to be_allowed(:read_project_security_dashboard) }
end
context 'with developer' do
let(:current_user) { developer }
it { is_expected.to be_allowed(:read_project_security_dashboard) }
end
context 'with reporter' do
let(:current_user) { reporter }
it { is_expected.to be_disallowed(:read_project_security_dashboard) }
end
context 'with guest' do
let(:current_user) { guest }
it { is_expected.to be_disallowed(:read_project_security_dashboard) }
end
context 'with non member' do
let(:current_user) { create(:user) }
it { is_expected.to be_disallowed(:read_project_security_dashboard) }
end
context 'with anonymous' do
let(:current_user) { nil }
it { is_expected.to be_disallowed(:read_project_security_dashboard) }
end
end
end
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import { TEST_HOST } from 'spec/test_constants';
import component from 'ee/vue_shared/security_reports/card_security_reports_app.vue';
import createStore from 'ee/vue_shared/security_reports/store';
import state from 'ee/vue_shared/security_reports/store/state';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { trimText } from 'spec/helpers/vue_component_helper';
import { sastIssues, dast, dockerReport } from './mock_data';
describe('Card security reports app', () => {
const Component = Vue.extend(component);
let vm;
let mock;
const runDate = new Date();
runDate.setDate(runDate.getDate() - 7);
beforeEach(() => {
mock = new MockAdapter(axios);
vm = mountComponentWithStore(Component, {
store: createStore(),
props: {
hasPipelineData: true,
emptyStateIllustrationPath: `${TEST_HOST}/img`,
securityDashboardHelpPath: `${TEST_HOST}/help_dashboard`,
commit: {
id: '1234adf',
path: `${TEST_HOST}/commit`,
},
branch: {
id: 'master',
path: `${TEST_HOST}/branch`,
},
pipeline: {
id: '55',
created: runDate.toISOString(),
path: `${TEST_HOST}/pipeline`,
},
triggeredBy: {
path: `${TEST_HOST}/user`,
avatarPath: `${TEST_HOST}/img`,
name: 'TestUser',
},
headBlobPath: 'path',
baseBlobPath: 'path',
sastHeadPath: `${TEST_HOST}/sast_head`,
dependencyScanningHeadPath: `${TEST_HOST}/dss_head`,
dastHeadPath: `${TEST_HOST}/dast_head`,
sastContainerHeadPath: `${TEST_HOST}/sast_container_head`,
sastHelpPath: 'path',
dependencyScanningHelpPath: 'path',
vulnerabilityFeedbackPath: `${TEST_HOST}/vulnerability_feedback_path`,
vulnerabilityFeedbackHelpPath: 'path',
dastHelpPath: 'path',
sastContainerHelpPath: 'path',
pipelineId: 123,
canCreateFeedback: true,
canCreateIssue: true,
},
});
});
afterEach(() => {
vm.$store.replaceState(state());
vm.$destroy();
mock.restore();
});
describe('computed properties', () => {
describe('headline', () => {
it('renders `Pipeline <link> triggered`', () => {
expect(vm.headline).toBe(`Pipeline <a href="${TEST_HOST}/pipeline">#55</a> triggered`);
});
});
});
describe('Headline renders', () => {
it('pipeline metadata information', () => {
const element = vm.$el.querySelector('.card-header .js-security-dashboard-left');
expect(trimText(element.textContent)).toBe('Pipeline #55 triggered 1 week ago by TestUser');
const pipelineLink = element.querySelector(`a[href="${TEST_HOST}/pipeline"]`);
expect(pipelineLink).not.toBeNull();
expect(pipelineLink.textContent).toBe('#55');
const userAvatarLink = element.querySelector('a.user-avatar-link');
expect(userAvatarLink).not.toBeNull();
expect(userAvatarLink.getAttribute('href')).toBe(`${TEST_HOST}/user`);
expect(userAvatarLink.querySelector('img').getAttribute('src')).toBe(`${TEST_HOST}/img`);
expect(userAvatarLink.textContent).toBe('TestUser');
});
it('branch and commit information', () => {
const branchIcon = vm.$el.querySelector(
'.card-header .js-security-dashboard-right .ic-branch',
);
expect(branchIcon).not.toBeNull();
const branchLink = branchIcon.nextElementSibling;
expect(branchLink).not.toBeNull();
expect(branchLink.textContent).toBe('master');
expect(branchLink.getAttribute('href')).toBe(`${TEST_HOST}/branch`);
const middot = branchLink.nextElementSibling;
expect(middot).not.toBeNull();
expect(middot.textContent).toBe('·');
const commitIcon = middot.nextElementSibling;
expect(commitIcon).not.toBeNull();
expect(commitIcon.classList).toContain('ic-commit');
const commitLink = commitIcon.nextElementSibling;
expect(commitLink).not.toBeNull();
expect(commitLink.textContent).toContain('1234adf');
expect(commitLink.getAttribute('href')).toBe(`${TEST_HOST}/commit`);
});
});
describe('Empty State renders correctly', () => {
beforeEach(done => {
vm.hasPipelineData = false;
Vue.nextTick(done);
});
it('image illustration is set to defined path', () => {
const imgEl = vm.$el.querySelector('img');
expect(imgEl.getAttribute('src')).toBe(`${TEST_HOST}/img`);
});
it('headline text is to `Monitor vulnerabilities in your code`', () => {
const headingEl = vm.$el.querySelector('h4');
expect(headingEl.textContent.trim()).toBe('Monitor vulnerabilities in your code');
});
it('paragraph text is to `The security dashboard...`', () => {
const paragraphEl = vm.$el.querySelector('p');
expect(trimText(paragraphEl.textContent)).toBe(
'The security dashboard displays the latest security report. Use it to find and fix vulnerabilities.',
);
});
it('learn more link has correct path and text', () => {
const linkEl = vm.$el.querySelector('a');
expect(linkEl.textContent.trim()).toBe('Learn more');
expect(linkEl.getAttribute('href')).toBe(`${TEST_HOST}/help_dashboard`);
});
});
describe('Report renders correctly', () => {
describe('while loading', () => {
beforeEach(() => {
mock.onGet(`${TEST_HOST}/sast_head`).reply(200, sastIssues);
mock.onGet(`${TEST_HOST}/dss_head`).reply(200, sastIssues);
mock.onGet(`${TEST_HOST}/dast_head`).reply(200, dast);
mock.onGet(`${TEST_HOST}/sast_container_head`).reply(200, dockerReport);
mock.onGet(`${TEST_HOST}/vulnerability_feedback_path`).reply(200, []);
});
it('renders loading summary text + spinner', done => {
expect(vm.$el.querySelector('.fa-spinner')).not.toBeNull();
expect(vm.$el.textContent).toContain('SAST is loading');
expect(vm.$el.textContent).toContain('Dependency scanning is loading');
expect(vm.$el.textContent).toContain('Container scanning is loading');
expect(vm.$el.textContent).toContain('DAST is loading');
setTimeout(() => {
done();
}, 0);
});
});
describe('with all reports', () => {
beforeEach(() => {
mock.onGet(`${TEST_HOST}/sast_head`).reply(200, sastIssues);
mock.onGet(`${TEST_HOST}/dss_head`).reply(200, sastIssues);
mock.onGet(`${TEST_HOST}/dast_head`).reply(200, dast);
mock.onGet(`${TEST_HOST}/sast_container_head`).reply(200, dockerReport);
mock.onGet(`${TEST_HOST}/vulnerability_feedback_path`).reply(200, []);
});
it('renders reports', done => {
setTimeout(() => {
expect(vm.$el.querySelector('.fa-spinner')).toBeNull();
expect(vm.$el.textContent).toContain('SAST detected 3 vulnerabilities');
expect(vm.$el.textContent).toContain('Dependency scanning detected 3 vulnerabilities');
// Renders container scanning result
expect(vm.$el.textContent).toContain('Container scanning detected 2 vulnerabilities');
// Renders DAST result
expect(vm.$el.textContent).toContain('DAST detected 2 vulnerabilities');
done();
}, 0);
});
it('renders all reports expanded and with no way to collapse', done => {
setTimeout(() => {
expect(vm.$el.querySelector('.fa-spinner')).toBeNull();
expect(vm.$el.querySelector('.js-collapse-btn')).toBeNull();
const reports = vm.$el.querySelectorAll('.js-report-section-container');
reports.forEach(report => {
expect(report).not.toHaveCss({ display: 'none' });
});
done();
}, 0);
});
});
describe('with error', () => {
beforeEach(() => {
mock.onGet(`${TEST_HOST}/sast_head`).reply(500);
mock.onGet(`${TEST_HOST}/dss_head`).reply(500);
mock.onGet(`${TEST_HOST}/dast_head`).reply(500);
mock.onGet(`${TEST_HOST}/sast_container_head`).reply(500);
mock.onGet(`${TEST_HOST}/vulnerability_feedback_path`).reply(500, []);
});
it('renders error state', done => {
setTimeout(() => {
expect(vm.$el.querySelector('.fa-spinner')).toBeNull();
expect(vm.$el.textContent).toContain('SAST resulted in error while loading results');
expect(vm.$el.textContent).toContain(
'Dependency scanning resulted in error while loading results',
);
expect(vm.$el.textContent).toContain(
'Container scanning resulted in error while loading results',
);
expect(vm.$el.textContent).toContain('DAST resulted in error while loading results');
done();
}, 0);
});
});
});
});
......@@ -5,16 +5,78 @@ import { codequalityParsedIssues } from 'spec/vue_mr_widget/mock_data';
describe('Report section', () => {
let vm;
let ReportSection;
const ReportSection = Vue.extend(reportSection);
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
beforeEach(() => {
ReportSection = Vue.extend(reportSection);
vm = mountComponent(ReportSection, {
type: 'codequality',
status: 'SUCCESS',
loadingText: 'Loading codeclimate report',
errorText: 'foo',
successText: 'Code quality improved on 1 point and degraded on 1 point',
resolvedIssues: codequalityParsedIssues,
hasIssues: false,
alwaysOpen: false,
});
});
afterEach(() => {
vm.$destroy();
describe('isCollapsible', () => {
const testMatrix = [
{ hasIssues: false, alwaysOpen: false, isCollapsible: false },
{ hasIssues: false, alwaysOpen: true, isCollapsible: false },
{ hasIssues: true, alwaysOpen: false, isCollapsible: true },
{ hasIssues: true, alwaysOpen: true, isCollapsible: false },
];
testMatrix.forEach(({ hasIssues, alwaysOpen, isCollapsible }) => {
const issues = hasIssues ? 'has issues' : 'has no issues';
const open = alwaysOpen ? 'is always open' : 'is not always open';
it(`is ${isCollapsible}, if the report ${issues} and ${open}`, done => {
vm.hasIssues = hasIssues;
vm.alwaysOpen = alwaysOpen;
Vue.nextTick()
.then(() => {
expect(vm.isCollapsible).toBe(isCollapsible);
})
.then(done)
.catch(done.fail);
});
});
});
describe('isExpanded', () => {
const testMatrix = [
{ isCollapsed: false, alwaysOpen: false, isExpanded: true },
{ isCollapsed: false, alwaysOpen: true, isExpanded: true },
{ isCollapsed: true, alwaysOpen: false, isExpanded: false },
{ isCollapsed: true, alwaysOpen: true, isExpanded: true },
];
testMatrix.forEach(({ isCollapsed, alwaysOpen, isExpanded }) => {
const issues = isCollapsed ? 'is collapsed' : 'is not collapsed';
const open = alwaysOpen ? 'is always open' : 'is not always open';
it(`is ${isExpanded}, if the report ${issues} and ${open}`, done => {
vm.isCollapsed = isCollapsed;
vm.alwaysOpen = alwaysOpen;
Vue.nextTick()
.then(() => {
expect(vm.isExpanded).toBe(isExpanded);
})
.then(done)
.catch(done.fail);
});
});
});
});
describe('when it is loading', () => {
it('should render loading indicator', () => {
vm = mountComponent(ReportSection, {
......@@ -30,7 +92,7 @@ describe('Report section', () => {
});
describe('with success status', () => {
it('should render provided data', () => {
beforeEach(() => {
vm = mountComponent(ReportSection, {
type: 'codequality',
status: 'SUCCESS',
......@@ -40,51 +102,49 @@ describe('Report section', () => {
resolvedIssues: codequalityParsedIssues,
hasIssues: true,
});
});
expect(
vm.$el.querySelector('.js-code-text').textContent.trim(),
).toEqual('Code quality improved on 1 point and degraded on 1 point');
it('should render provided data', () => {
expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
'Code quality improved on 1 point and degraded on 1 point',
);
expect(
vm.$el.querySelectorAll('.js-mr-code-resolved-issues li').length,
).toEqual(codequalityParsedIssues.length);
expect(vm.$el.querySelectorAll('.js-mr-code-resolved-issues li').length).toEqual(
codequalityParsedIssues.length,
);
});
describe('toggleCollapsed', () => {
it('toggles issues', (done) => {
vm = mountComponent(ReportSection, {
type: 'codequality',
status: 'SUCCESS',
loadingText: 'Loading codeclimate report',
errorText: 'foo',
successText: 'Code quality improved on 1 point and degraded on 1 point',
resolvedIssues: codequalityParsedIssues,
hasIssues: true,
});
const hiddenCss = { display: 'none' };
it('toggles issues', done => {
vm.$el.querySelector('button').click();
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.js-report-section-container').getAttribute('style'),
).toEqual('');
expect(
vm.$el.querySelector('button').textContent.trim(),
).toEqual('Collapse');
Vue.nextTick()
.then(() => {
expect(vm.$el.querySelector('.js-report-section-container')).not.toHaveCss(hiddenCss);
expect(vm.$el.querySelector('button').textContent.trim()).toEqual('Collapse');
vm.$el.querySelector('button').click();
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.js-report-section-container').getAttribute('style'),
).toEqual('display: none;');
expect(
vm.$el.querySelector('button').textContent.trim(),
).toEqual('Expand');
done();
});
})
.then(Vue.nextTick)
.then(() => {
expect(vm.$el.querySelector('.js-report-section-container')).toHaveCss(hiddenCss);
expect(vm.$el.querySelector('button').textContent.trim()).toEqual('Expand');
})
.then(done)
.catch(done.fail);
});
it('is always expanded, if always-open is set to true', done => {
vm.alwaysOpen = true;
Vue.nextTick()
.then(() => {
expect(vm.$el.querySelector('.js-report-section-container')).not.toHaveCss(hiddenCss);
expect(vm.$el.querySelector('button')).toBeNull();
})
.then(done)
.catch(done.fail);
});
});
});
......@@ -107,23 +167,28 @@ describe('Report section', () => {
beforeEach(() => {
vm = mountComponent(ReportSection, {
status: 'SUCCESS',
successText: 'SAST improved on 1 security vulnerability and degraded on 1 security vulnerability',
successText:
'SAST improved on 1 security vulnerability and degraded on 1 security vulnerability',
type: 'SAST',
errorText: 'Failed to load security report',
hasIssues: true,
loadingText: 'Loading security report',
resolvedIssues: [{
resolvedIssues: [
{
cve: 'CVE-2016-9999',
file: 'Gemfile.lock',
message: 'Test Information Leak Vulnerability in Action View',
title: 'Test Information Leak Vulnerability in Action View',
path: 'Gemfile.lock',
solution: 'upgrade to >= 5.0.0.beta1.1, >= 4.2.5.1, ~> 4.2.5, >= 4.1.14.1, ~> 4.1.14, ~> 3.2.22.1',
solution:
'upgrade to >= 5.0.0.beta1.1, >= 4.2.5.1, ~> 4.2.5, >= 4.1.14.1, ~> 4.1.14, ~> 3.2.22.1',
tool: 'bundler_audit',
url: 'https://groups.google.com/forum/#!topic/rubyonrails-security/335P1DcLG00',
urlPath: '/Gemfile.lock',
}],
unresolvedIssues: [{
},
],
unresolvedIssues: [
{
cve: 'CVE-2014-7829',
file: 'Gemfile.lock',
message: 'Arbitrary file existence disclosure in Action Pack',
......@@ -133,43 +198,47 @@ describe('Report section', () => {
tool: 'bundler_audit',
url: 'https://groups.google.com/forum/#!topic/rubyonrails-security/rMTQy4oRCGk',
urlPath: '/Gemfile.lock',
}],
allIssues: [{
},
],
allIssues: [
{
cve: 'CVE-2016-0752',
file: 'Gemfile.lock',
message: 'Possible Information Leak Vulnerability in Action View',
title: 'Possible Information Leak Vulnerability in Action View',
path: 'Gemfile.lock',
solution: 'upgrade to >= 5.0.0.beta1.1, >= 4.2.5.1, ~> 4.2.5, >= 4.1.14.1, ~> 4.1.14, ~> 3.2.22.1',
solution:
'upgrade to >= 5.0.0.beta1.1, >= 4.2.5.1, ~> 4.2.5, >= 4.1.14.1, ~> 4.1.14, ~> 3.2.22.1',
tool: 'bundler_audit',
url: 'https://groups.google.com/forum/#!topic/rubyonrails-security/335P1DcLG00',
urlPath: '/Gemfile.lock',
}],
},
],
});
});
it('should render full report section', (done) => {
it('should render full report section', done => {
vm.$el.querySelector('button').click();
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.js-expand-full-list').textContent.trim(),
).toEqual('Show complete code vulnerabilities report');
expect(vm.$el.querySelector('.js-expand-full-list').textContent.trim()).toEqual(
'Show complete code vulnerabilities report',
);
done();
});
});
it('should expand full list when clicked and hide the show all button', (done) => {
it('should expand full list when clicked and hide the show all button', done => {
vm.$el.querySelector('button').click();
Vue.nextTick(() => {
vm.$el.querySelector('.js-expand-full-list').click();
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.js-mr-code-all-issues').textContent.trim(),
).toContain('Possible Information Leak Vulnerability in Action View');
expect(vm.$el.querySelector('.js-mr-code-all-issues').textContent.trim()).toContain(
'Possible Information Leak Vulnerability in Action View',
);
done();
});
......
......@@ -3,7 +3,8 @@ import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import component from 'ee/vue_shared/security_reports/grouped_security_reports_app.vue';
import state from 'ee/vue_shared/security_reports/store/state';
import mountComponent from '../../helpers/vue_mount_component_helper';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { trimText } from 'spec/helpers/vue_component_helper';
import {
sastIssues,
sastIssuesBase,
......@@ -20,13 +21,6 @@ describe('Grouped security reports app', () => {
let mock;
const Component = Vue.extend(component);
function removeBreakLine(data) {
return data
.replace(/\r?\n|\r/g, '')
.replace(/\s\s+/g, ' ')
.trim();
}
beforeEach(() => {
mock = new MockAdapter(axios);
});
......@@ -80,10 +74,10 @@ describe('Grouped security reports app', () => {
);
expect(vm.$el.querySelector('.js-collapse-btn').textContent.trim()).toEqual('Expand');
expect(removeBreakLine(vm.$el.textContent)).toContain(
expect(trimText(vm.$el.textContent)).toContain(
'SAST resulted in error while loading results',
);
expect(removeBreakLine(vm.$el.textContent)).toContain(
expect(trimText(vm.$el.textContent)).toContain(
'Dependency scanning resulted in error while loading results',
);
expect(vm.$el.textContent).toContain(
......@@ -130,7 +124,7 @@ describe('Grouped security reports app', () => {
});
});
it('renders loading summary text + spinner', (done) => {
it('renders loading summary text + spinner', done => {
expect(vm.$el.querySelector('.fa-spinner')).not.toBeNull();
expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
'Security scanning is loading',
......@@ -197,12 +191,12 @@ describe('Grouped security reports app', () => {
expect(vm.$el.querySelector('.js-collapse-btn').textContent.trim()).toEqual('Expand');
// Renders Sast result
expect(removeBreakLine(vm.$el.textContent)).toContain(
expect(trimText(vm.$el.textContent)).toContain(
'SAST detected 2 new vulnerabilities and 1 fixed vulnerability',
);
// Renders DSS result
expect(removeBreakLine(vm.$el.textContent)).toContain(
expect(trimText(vm.$el.textContent)).toContain(
'Dependency scanning detected 2 new vulnerabilities and 1 fixed vulnerability',
);
// Renders container scanning result
......@@ -214,12 +208,14 @@ describe('Grouped security reports app', () => {
}, 0);
});
it('opens modal with more information', (done) => {
it('opens modal with more information', done => {
setTimeout(() => {
vm.$el.querySelector('.break-link').click();
Vue.nextTick(() => {
expect(vm.$el.querySelector('.modal-title').textContent.trim()).toEqual(sastIssues[0].message);
expect(vm.$el.querySelector('.modal-title').textContent.trim()).toEqual(
sastIssues[0].message,
);
expect(vm.$el.querySelector('.modal-body').textContent).toContain(sastIssues[0].solution);
done();
......
......@@ -7,19 +7,12 @@ import state from 'ee/vue_shared/security_reports/store/state';
import { mountComponentWithStore } from '../../helpers/vue_mount_component_helper';
import { sastIssues, dast, dockerReport } from './mock_data';
describe('Slipt security reports app', () => {
describe('Split security reports app', () => {
const Component = Vue.extend(component);
let vm;
let mock;
function removeBreakLine(data) {
return data
.replace(/\r?\n|\r/g, '')
.replace(/\s\s+/g, ' ')
.trim();
}
beforeEach(() => {
mock = new MockAdapter(axios);
});
......@@ -107,18 +100,48 @@ describe('Slipt security reports app', () => {
it('renders reports', done => {
setTimeout(() => {
expect(vm.$el.querySelector('.fa-spinner')).toBeNull();
expect(vm.$el.querySelector('.js-collapse-btn').textContent.trim()).toEqual('Expand');
expect(removeBreakLine(vm.$el.textContent)).toContain('SAST detected 3 vulnerabilities');
expect(removeBreakLine(vm.$el.textContent)).toContain(
'Dependency scanning detected 3 vulnerabilities',
);
expect(vm.$el.textContent).toContain('SAST detected 3 vulnerabilities');
expect(vm.$el.textContent).toContain('Dependency scanning detected 3 vulnerabilities');
// Renders container scanning result
expect(vm.$el.textContent).toContain('Container scanning detected 2 vulnerabilities');
// Renders DAST result
expect(vm.$el.textContent).toContain('DAST detected 2 vulnerabilities');
done();
}, 0);
});
it('renders all reports collapsed by default', done => {
setTimeout(() => {
expect(vm.$el.querySelector('.fa-spinner')).toBeNull();
expect(vm.$el.querySelector('.js-collapse-btn').textContent.trim()).toEqual('Expand');
const reports = vm.$el.querySelectorAll('.js-report-section-container');
reports.forEach(report => {
expect(report).toHaveCss({ display: 'none' });
});
done();
}, 0);
});
it('renders all reports expanded with the option always-open', done => {
vm.alwaysOpen = true;
setTimeout(() => {
expect(vm.$el.querySelector('.fa-spinner')).toBeNull();
expect(vm.$el.querySelector('.js-collapse-btn')).toBeNull();
const reports = vm.$el.querySelectorAll('.js-report-section-container');
reports.forEach(report => {
expect(report).not.toHaveCss({ display: 'none' });
});
done();
}, 0);
});
......@@ -158,8 +181,8 @@ describe('Slipt security reports app', () => {
setTimeout(() => {
expect(vm.$el.querySelector('.fa-spinner')).toBeNull();
expect(removeBreakLine(vm.$el.textContent)).toContain('SAST resulted in error while loading results');
expect(removeBreakLine(vm.$el.textContent)).toContain(
expect(vm.$el.textContent).toContain('SAST resulted in error while loading results');
expect(vm.$el.textContent).toContain(
'Dependency scanning resulted in error while loading results',
);
expect(vm.$el.textContent).toContain(
......
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