diff --git a/config/routes/project.rb b/config/routes/project.rb index cf607772f34060853cd65a2018ecd96b0e3ec02a..d0cea915d496e8400dca3df73cf7dd753509d806 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -362,6 +362,12 @@ 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 diff --git a/doc/user/project/img/project_security_dashboard.png b/doc/user/project/img/project_security_dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..3294e59e9432a8c707c989ad25a102faa57cde6c Binary files /dev/null and b/doc/user/project/img/project_security_dashboard.png differ diff --git a/doc/user/project/index.md b/doc/user/project/index.md index 43db5f6afa0ba5b2ea69e19cf8edb3fac767f46e..5c2bcd30dcbef42e231a7621b8878835fc08d9dd 100644 --- a/doc/user/project/index.md +++ b/doc/user/project/index.md @@ -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 diff --git a/doc/user/project/security_dashboard.md b/doc/user/project/security_dashboard.md new file mode 100644 index 0000000000000000000000000000000000000000..c597f4bdd8256668cd50eaa0cb9f7ad8a31d92ab --- /dev/null +++ b/doc/user/project/security_dashboard.md @@ -0,0 +1,18 @@ +# 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). + + + +## 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 diff --git a/ee/app/assets/javascripts/pages/projects/security/dashboard/show/index.js b/ee/app/assets/javascripts/pages/projects/security/dashboard/show/index.js new file mode 100644 index 0000000000000000000000000000000000000000..88f1aa479d712e04f305bb41a6379a361e0a696d --- /dev/null +++ b/ee/app/assets/javascripts/pages/projects/security/dashboard/show/index.js @@ -0,0 +1,66 @@ +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, + }, + }); + }, + }); +}); diff --git a/ee/app/assets/javascripts/vue_shared/security_reports/card_security_reports_app.vue b/ee/app/assets/javascripts/vue_shared/security_reports/card_security_reports_app.vue new file mode 100644 index 0000000000000000000000000000000000000000..064cf08e20e830007ad20da61ff8a2da914e32ec --- /dev/null +++ b/ee/app/assets/javascripts/vue_shared/security_reports/card_security_reports_app.vue @@ -0,0 +1,198 @@ +<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">·</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> diff --git a/ee/app/assets/javascripts/vue_shared/security_reports/components/empty_security_dashboard.vue b/ee/app/assets/javascripts/vue_shared/security_reports/components/empty_security_dashboard.vue new file mode 100644 index 0000000000000000000000000000000000000000..0defc046471251ff3f93ad960d2f3c45336b0f71 --- /dev/null +++ b/ee/app/assets/javascripts/vue_shared/security_reports/components/empty_security_dashboard.vue @@ -0,0 +1,52 @@ +<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> diff --git a/ee/app/assets/javascripts/vue_shared/security_reports/components/report_section.vue b/ee/app/assets/javascripts/vue_shared/security_reports/components/report_section.vue index 290c563b3588aa9dd9b08b0530b191ea3e41f7df..29ae78adeab83cf2e9056e80af96dccf997c2ab2 100644 --- a/ee/app/assets/javascripts/vue_shared/security_reports/components/report_section.vue +++ b/ee/app/assets/javascripts/vue_shared/security_reports/components/report_section.vue @@ -1,7 +1,6 @@ <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"> diff --git a/ee/app/assets/javascripts/vue_shared/security_reports/split_security_reports_app.vue b/ee/app/assets/javascripts/vue_shared/security_reports/split_security_reports_app.vue index af95dc71494b7dda7214d14ce176288522aa171c..4b29f15172db64f21d9f1c3fa9df8b0eaf8f161b 100644 --- a/ee/app/assets/javascripts/vue_shared/security_reports/split_security_reports_app.vue +++ b/ee/app/assets/javascripts/vue_shared/security_reports/split_security_reports_app.vue @@ -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" diff --git a/ee/app/assets/stylesheets/pages/projects.scss b/ee/app/assets/stylesheets/pages/projects.scss index 1f9a24e7ab020f0396acde07c7201ea19f339d21..d48ee36d4fe167a2d758cb01286686dbc2f4cf8a 100644 --- a/ee/app/assets/stylesheets/pages/projects.scss +++ b/ee/app/assets/stylesheets/pages/projects.scss @@ -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; + } +} diff --git a/ee/app/assets/stylesheets/pages/security_reports.scss b/ee/app/assets/stylesheets/pages/security_reports.scss index 20264a5f150519aeec5b974e974f883ad56908d3..b644c1372d6d03fc098c45f4c61c7ac157787b03 100644 --- a/ee/app/assets/stylesheets/pages/security_reports.scss +++ b/ee/app/assets/stylesheets/pages/security_reports.scss @@ -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 { diff --git a/ee/app/controllers/projects/security/dashboard_controller.rb b/ee/app/controllers/projects/security/dashboard_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..4be839987dad953649bc65a76c8fb47ce42df417 --- /dev/null +++ b/ee/app/controllers/projects/security/dashboard_controller.rb @@ -0,0 +1,18 @@ +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 diff --git a/ee/app/helpers/ee/projects_helper.rb b/ee/app/helpers/ee/projects_helper.rb index 64eca85c936860103f90a3fa55a2d3e2d13f10bb..f50e69eae9cbd420ca871e248786be40d5aecff4 100644 --- a/ee/app/helpers/ee/projects_helper.rb +++ b/ee/app/helpers/ee/projects_helper.rb @@ -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 @@ def share_project_description 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 diff --git a/ee/app/models/ee/ci/pipeline.rb b/ee/app/models/ee/ci/pipeline.rb index 1f24f206e58c688dba665aeb560f196c5587efbe..b5ee461230895e0c8d9dbb661be316cc2212469f 100644 --- a/ee/app/models/ee/ci/pipeline.rb +++ b/ee/app/models/ee/ci/pipeline.rb @@ -10,6 +10,10 @@ module Pipeline 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) diff --git a/ee/app/models/ee/project.rb b/ee/app/models/ee/project.rb index 0cb9e626b1a265063f535700d8bca7f20925a3e4..858e4bf6a99b926b2cb5e7fdcf0860eea121f0a0 100644 --- a/ee/app/models/ee/project.rb +++ b/ee/app/models/ee/project.rb @@ -88,6 +88,17 @@ def with_slack_application_disabled 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? diff --git a/ee/app/policies/ee/project_policy.rb b/ee/app/policies/ee/project_policy.rb index a354788082164a862ebbfaf98e7edd71fbed0783..b74d1313497a5eb32e32a89e2bf35c2ce4c598ec 100644 --- a/ee/app/policies/ee/project_policy.rb +++ b/ee/app/policies/ee/project_policy.rb @@ -81,6 +81,7 @@ module ProjectPolicy 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 diff --git a/ee/app/views/projects/security/dashboard/show.html.haml b/ee/app/views/projects/security/dashboard/show.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..3154cbfb060e0460f74aa167481c3dd6182bda91 --- /dev/null +++ b/ee/app/views/projects/security/dashboard/show.html.haml @@ -0,0 +1,4 @@ +- breadcrumb_title _("Security Dashboard") +- page_title _("Security Dashboard") + +#js-security-report-app{ data: project_security_dashboard_config(@project, @pipeline) } diff --git a/ee/app/views/projects/sidebar/_security_dashboard.html.haml b/ee/app/views/projects/sidebar/_security_dashboard.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..c1de616fc2bf25eaa36848ffc10fec792f8134d6 --- /dev/null +++ b/ee/app/views/projects/sidebar/_security_dashboard.html.haml @@ -0,0 +1,5 @@ +- 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') diff --git a/ee/changelogs/unreleased/6165_project_security_dashboard.yml b/ee/changelogs/unreleased/6165_project_security_dashboard.yml new file mode 100644 index 0000000000000000000000000000000000000000..b7ec3ca37df33edac75f66d9aaf02a03eb17d23c --- /dev/null +++ b/ee/changelogs/unreleased/6165_project_security_dashboard.yml @@ -0,0 +1,5 @@ +--- +title: Add project Security Dashboard +merge_request: 6197 +author: +type: added diff --git a/ee/spec/controllers/projects/security/dashboard_controller_spec.rb b/ee/spec/controllers/projects/security/dashboard_controller_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..cdc28a1edbc8a7a49200bcd7590f13c8f8bd0245 --- /dev/null +++ b/ee/spec/controllers/projects/security/dashboard_controller_spec.rb @@ -0,0 +1,74 @@ +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 diff --git a/ee/spec/models/ci/pipeline_spec.rb b/ee/spec/models/ci/pipeline_spec.rb index 9adb152a9925b808415d7b8446cb3aac1ca17178..f9cd9e2bcbe734f93298faddcebd912d6882fe99 100644 --- a/ee/spec/models/ci/pipeline_spec.rb +++ b/ee/spec/models/ci/pipeline_spec.rb @@ -96,4 +96,79 @@ 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 diff --git a/ee/spec/models/project_spec.rb b/ee/spec/models/project_spec.rb index 28ec4bad9ba63d5f5fa4167a53282a25b04a180b..fc698e6fe6b9610e6751fa0cf00077a7c0c86dc9 100644 --- a/ee/spec/models/project_spec.rb +++ b/ee/spec/models/project_spec.rb @@ -1420,4 +1420,65 @@ 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 diff --git a/ee/spec/policies/project_policy_spec.rb b/ee/spec/policies/project_policy_spec.rb index d64f5a83219c45c23fbe829e3e687304782dc76d..ed0f55ea45719c56083d40db8acf239a4cd73c6a 100644 --- a/ee/spec/policies/project_policy_spec.rb +++ b/ee/spec/policies/project_policy_spec.rb @@ -303,4 +303,56 @@ 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 diff --git a/spec/javascripts/vue_shared/security_reports/card_security_reports_app_spec.js b/spec/javascripts/vue_shared/security_reports/card_security_reports_app_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..ef355b79f35e8de32de9db30d18d44d1f68ca61e --- /dev/null +++ b/spec/javascripts/vue_shared/security_reports/card_security_reports_app_spec.js @@ -0,0 +1,259 @@ +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); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/security_reports/components/report_section_spec.js b/spec/javascripts/vue_shared/security_reports/components/report_section_spec.js index f2c1e33aa43af605566ef6155c20d601c0d8efde..095ee338ee886440506402045681166903cd85ad 100644 --- a/spec/javascripts/vue_shared/security_reports/components/report_section_spec.js +++ b/spec/javascripts/vue_shared/security_reports/components/report_section_spec.js @@ -5,16 +5,78 @@ import { codequalityParsedIssues } from 'spec/vue_mr_widget/mock_data'; describe('Report section', () => { let vm; - let ReportSection; - - beforeEach(() => { - ReportSection = Vue.extend(reportSection); - }); + const ReportSection = Vue.extend(reportSection); afterEach(() => { vm.$destroy(); }); + describe('computed', () => { + beforeEach(() => { + 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, + }); + }); + + 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'); - - 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(); - }); - }); + 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(); + }) + .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,69 +167,78 @@ 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: [{ - 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', - tool: 'bundler_audit', - url: 'https://groups.google.com/forum/#!topic/rubyonrails-security/335P1DcLG00', - urlPath: '/Gemfile.lock', - }], - unresolvedIssues: [{ - cve: 'CVE-2014-7829', - file: 'Gemfile.lock', - message: 'Arbitrary file existence disclosure in Action Pack', - title: 'Arbitrary file existence disclosure in Action Pack', - path: 'Gemfile.lock', - solution: 'upgrade to ~> 3.2.21, ~> 4.0.11.1, ~> 4.0.12, ~> 4.1.7.1, >= 4.1.8', - tool: 'bundler_audit', - url: 'https://groups.google.com/forum/#!topic/rubyonrails-security/rMTQy4oRCGk', - urlPath: '/Gemfile.lock', - }], - 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', - tool: 'bundler_audit', - url: 'https://groups.google.com/forum/#!topic/rubyonrails-security/335P1DcLG00', - urlPath: '/Gemfile.lock', - }], + 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', + tool: 'bundler_audit', + url: 'https://groups.google.com/forum/#!topic/rubyonrails-security/335P1DcLG00', + urlPath: '/Gemfile.lock', + }, + ], + unresolvedIssues: [ + { + cve: 'CVE-2014-7829', + file: 'Gemfile.lock', + message: 'Arbitrary file existence disclosure in Action Pack', + title: 'Arbitrary file existence disclosure in Action Pack', + path: 'Gemfile.lock', + solution: 'upgrade to ~> 3.2.21, ~> 4.0.11.1, ~> 4.0.12, ~> 4.1.7.1, >= 4.1.8', + tool: 'bundler_audit', + url: 'https://groups.google.com/forum/#!topic/rubyonrails-security/rMTQy4oRCGk', + urlPath: '/Gemfile.lock', + }, + ], + 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', + 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(); }); diff --git a/spec/javascripts/vue_shared/security_reports/grouped_security_reports_app_spec.js b/spec/javascripts/vue_shared/security_reports/grouped_security_reports_app_spec.js index 1fc7a5ed29f64b03437fe788f67c7d734e5f2fbb..a6a3c07defd584ae0becf1a249d6d4fbaecb430e 100644 --- a/spec/javascripts/vue_shared/security_reports/grouped_security_reports_app_spec.js +++ b/spec/javascripts/vue_shared/security_reports/grouped_security_reports_app_spec.js @@ -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(); diff --git a/spec/javascripts/vue_shared/security_reports/split_security_reports_app_spec.js b/spec/javascripts/vue_shared/security_reports/split_security_reports_app_spec.js index 4e8f7e7494dbe0063ae8bbbd8e557da229c45344..8141cd64a3ba9e0f83ad7ad9c2746ee38d68401f 100644 --- a/spec/javascripts/vue_shared/security_reports/split_security_reports_app_spec.js +++ b/spec/javascripts/vue_shared/security_reports/split_security_reports_app_spec.js @@ -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(