Commit f1cf6383 authored by Kamil Trzciński's avatar Kamil Trzciński

Merge branch '3775-show-sast-results-in-mr-widget' into 'master'

Show SAST results in the merge request widget

Closes #3775

See merge request gitlab-org/gitlab-ee!3207
parents 7f9dac98 b8b3e4dc
...@@ -4,7 +4,7 @@ module Ci ...@@ -4,7 +4,7 @@ module Ci
include AfterCommitQueue include AfterCommitQueue
include Presentable include Presentable
include Importable include Importable
prepend EE::Build prepend EE::Ci::Build
belongs_to :runner belongs_to :runner
belongs_to :trigger_request belongs_to :trigger_request
...@@ -40,7 +40,6 @@ module Ci ...@@ -40,7 +40,6 @@ module Ci
scope :with_artifacts_stored_locally, ->() { with_artifacts.where(artifacts_file_store: [nil, ArtifactUploader::LOCAL_STORE]) } scope :with_artifacts_stored_locally, ->() { with_artifacts.where(artifacts_file_store: [nil, ArtifactUploader::LOCAL_STORE]) }
scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) } scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) }
scope :manual_actions, ->() { where(when: :manual, status: COMPLETED_STATUSES + [:manual]) } scope :manual_actions, ->() { where(when: :manual, status: COMPLETED_STATUSES + [:manual]) }
scope :codequality, ->() { where(name: %w[codequality codeclimate]) }
scope :ref_protected, -> { where(protected: true) } scope :ref_protected, -> { where(protected: true) }
mount_uploader :artifacts_file, ArtifactUploader mount_uploader :artifacts_file, ArtifactUploader
...@@ -471,11 +470,6 @@ module Ci ...@@ -471,11 +470,6 @@ module Ci
trace trace
end end
def has_codeclimate_json?
options.dig(:artifacts, :paths) == ['codeclimate.json'] &&
artifacts_metadata?
end
def serializable_hash(options = {}) def serializable_hash(options = {})
super(options).merge(when: read_attribute(:when)) super(options).merge(when: read_attribute(:when))
end end
......
...@@ -481,10 +481,6 @@ module Ci ...@@ -481,10 +481,6 @@ module Ci
.fabricate! .fabricate!
end end
def codeclimate_artifact
artifacts.codequality.find(&:has_codeclimate_json?)
end
def latest_builds_with_artifacts def latest_builds_with_artifacts
@latest_builds_with_artifacts ||= builds.latest.with_artifacts @latest_builds_with_artifacts ||= builds.latest.with_artifacts
end end
......
class MergeRequestEntity < IssuableEntity class MergeRequestEntity < IssuableEntity
include TimeTrackableEntity include TimeTrackableEntity
prepend ::EE::MergeRequestEntity
expose :state expose :state
expose :deleted_at expose :deleted_at
...@@ -189,29 +190,6 @@ class MergeRequestEntity < IssuableEntity ...@@ -189,29 +190,6 @@ class MergeRequestEntity < IssuableEntity
commit_change_content_project_merge_request_path(merge_request.project, merge_request) commit_change_content_project_merge_request_path(merge_request.project, merge_request)
end end
# EE-specific
expose :codeclimate, if: -> (mr, _) { mr.has_codeclimate_data? } do
expose :head_path, if: -> (mr, _) { can?(current_user, :read_build, mr.head_codeclimate_artifact) } do |merge_request|
raw_project_build_artifacts_url(merge_request.source_project,
merge_request.head_codeclimate_artifact,
path: 'codeclimate.json')
end
expose :head_blob_path, if: -> (mr, _) { mr.head_pipeline_sha } do |merge_request|
project_blob_path(merge_request.project, merge_request.head_pipeline_sha)
end
expose :base_path, if: -> (mr, _) { can?(current_user, :read_build, mr.base_codeclimate_artifact) } do |merge_request|
raw_project_build_artifacts_url(merge_request.target_project,
merge_request.base_codeclimate_artifact,
path: 'codeclimate.json')
end
expose :base_blob_path, if: -> (mr, _) { mr.base_pipeline_sha } do |merge_request|
project_blob_path(merge_request.project, merge_request.base_pipeline_sha)
end
end
private private
delegate :current_user, to: :request delegate :current_user, to: :request
......
---
title: Show SAST results in MR widget
merge_request: 3207
author:
type: added
<script> <script>
import statusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon'; import statusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon';
import loadingIcon from '~/vue_shared/components/loading_icon.vue'; import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import { pluralize } from '~/lib/utils/text_utility'; import issuesBlock from './mr_widget_report_issues.vue';
import issuesBlock from './mr_widget_code_quality_issues.vue';
export default { export default {
name: 'MRWidgetCodeQuality', name: 'MRWidgetCodeQuality',
props: { props: {
mr: { // security | codequality
type: Object, type: {
type: String,
required: true, required: true,
}, },
service: { // loading | success | error
type: Object, status: {
type: String,
required: true, required: true,
}, },
loadingText: {
type: String,
required: true,
},
errorText: {
type: String,
required: true,
},
successText: {
type: String,
required: true,
},
unresolvedIssues: {
type: Array,
required: false,
default: () => [],
},
resolvedIssues: {
type: Array,
required: false,
default: () => [],
},
}, },
components: { components: {
...@@ -28,100 +51,37 @@ export default { ...@@ -28,100 +51,37 @@ export default {
return { return {
collapseText: 'Expand', collapseText: 'Expand',
isCollapsed: true, isCollapsed: true,
isLoading: false,
loadingFailed: false,
}; };
}, },
computed: { computed: {
status() { isLoading() {
if (this.loadingFailed || this.mr.codeclimateMetrics.newIssues.length) { return this.status === 'loading';
},
loadingFailed() {
return this.status === 'error';
},
isSuccess() {
return this.status === 'success';
},
statusIconName() {
if (this.loadingFailed || this.unresolvedIssues.length) {
return 'warning'; return 'warning';
} }
return 'success'; return 'success';
}, },
hasNoneIssues() {
const { newIssues, resolvedIssues } = this.mr.codeclimateMetrics;
return !newIssues.length && !resolvedIssues.length;
},
hasIssues() { hasIssues() {
const { newIssues, resolvedIssues } = this.mr.codeclimateMetrics; return this.unresolvedIssues.length || this.resolvedIssues.length;
return newIssues.length || resolvedIssues.length;
},
codeText() {
const { newIssues, resolvedIssues } = this.mr.codeclimateMetrics;
let newIssuesText;
let resolvedIssuesText;
let text = [];
if (this.hasNoneIssues) {
text.push('No changes to code quality');
} else if (this.hasIssues) {
if (newIssues.length) {
newIssuesText = ` degraded on ${newIssues.length} ${this.pointsText(newIssues)}`;
}
if (resolvedIssues.length) {
resolvedIssuesText = ` improved on ${resolvedIssues.length} ${this.pointsText(resolvedIssues)}`;
}
const connector = (newIssues.length > 0 && resolvedIssues.length > 0) ? ' and' : null;
text = ['Code quality'];
if (resolvedIssuesText) {
text.push(resolvedIssuesText);
}
if (connector) {
text.push(connector);
}
if (newIssuesText) {
text.push(newIssuesText);
}
}
return text.join('');
}, },
}, },
methods: { methods: {
pointsText(issues) {
return pluralize('point', issues.length);
},
toggleCollapsed() { toggleCollapsed() {
this.isCollapsed = !this.isCollapsed; this.isCollapsed = !this.isCollapsed;
const text = this.isCollapsed ? 'Expand' : 'Collapse'; const text = this.isCollapsed ? 'Expand' : 'Collapse';
this.collapseText = text; this.collapseText = text;
}, },
handleError() {
this.isLoading = false;
this.loadingFailed = true;
},
},
created() {
const { head_path, head_blob_path, base_path, base_blob_path } = this.mr.codeclimate;
this.isLoading = true;
Promise.all([
this.service.fetchCodeclimate(head_path)
.then(resp => resp.json()),
this.service.fetchCodeclimate(base_path)
.then(resp => resp.json()),
])
.then((values) => {
this.mr.compareCodeclimateMetrics(values[0], values[1], head_blob_path, base_blob_path);
this.isLoading = false;
})
.catch(() => this.handleError());
}, },
}; };
</script> </script>
...@@ -135,17 +95,18 @@ export default { ...@@ -135,17 +95,18 @@ export default {
<loading-icon /> <loading-icon />
</div> </div>
<div class="media-body"> <div class="media-body">
Loading codeclimate report {{loadingText}}
</div> </div>
</div> </div>
<div <div
v-else-if="!isLoading && !loadingFailed" v-else-if="isSuccess"
class="media"> class="media">
<status-icon :status="status" /> <status-icon :status="statusIconName" />
<div class="media-body space-children"> <div class="media-body space-children">
<span class="js-code-text"> <span class="js-code-text">
{{codeText}} {{successText}}
</span> </span>
<button <button
...@@ -164,16 +125,18 @@ export default { ...@@ -164,16 +125,18 @@ export default {
v-show="!isCollapsed"> v-show="!isCollapsed">
<issues-block <issues-block
class="js-mr-code-resolved-issues" class="js-mr-code-resolved-issues"
v-if="mr.codeclimateMetrics.resolvedIssues.length" v-if="resolvedIssues.length"
type="success" :type="type"
:issues="mr.codeclimateMetrics.resolvedIssues" status="success"
:issues="resolvedIssues"
/> />
<issues-block <issues-block
class="js-mr-code-new-issues" class="js-mr-code-new-issues"
v-if="mr.codeclimateMetrics.newIssues.length" v-if="unresolvedIssues.length"
type="failed" :type="type"
:issues="mr.codeclimateMetrics.newIssues" status="failed"
:issues="unresolvedIssues"
/> />
</div> </div>
<div <div
...@@ -181,7 +144,7 @@ export default { ...@@ -181,7 +144,7 @@ export default {
class="media"> class="media">
<status-icon status="failed" /> <status-icon status="failed" />
<div class="media-body"> <div class="media-body">
Failed to load codeclimate report {{errorText}}
</div> </div>
</div> </div>
</section> </section>
......
...@@ -2,26 +2,38 @@ ...@@ -2,26 +2,38 @@
import { spriteIcon } from '~/lib/utils/common_utils'; import { spriteIcon } from '~/lib/utils/common_utils';
export default { export default {
name: 'MRWidgetCodeQualityIssues', name: 'mrWidgetReportIssues',
props: { props: {
issues: { issues: {
type: Array, type: Array,
required: true, required: true,
}, },
// security || codequality
type: { type: {
type: String, type: String,
required: true, required: true,
}, },
// failed || success
status: {
type: String,
required: true,
},
}, },
computed: { computed: {
icon() { icon() {
return this.isTypeFailed ? spriteIcon('cut') : spriteIcon('plus'); return this.isStatusFailed ? spriteIcon('cut') : spriteIcon('plus');
}, },
isTypeFailed() { isStatusFailed() {
return this.type === 'failed'; return this.status === 'failed';
}, },
isTypeSuccess() { isStatusSuccess() {
return this.type === 'success'; return this.status === 'success';
},
isTypeQuality() {
return this.type === 'codequality';
},
isTypeSecurity() {
return this.type === 'security';
}, },
}, },
}; };
...@@ -30,23 +42,32 @@ ...@@ -30,23 +42,32 @@
<ul class="mr-widget-code-quality-list"> <ul class="mr-widget-code-quality-list">
<li <li
:class="{ :class="{
failed: isTypeFailed, failed: isStatusFailed,
success: isTypeSuccess, success: isStatusSuccess
} }
"v-for="issue in issues"> "v-for="issue in issues">
<span <span
class="mr-widget-code-quality-icon" class="mr-widget-code-quality-icon"
v-html="icon"> v-html="icon">
</span> </span>
<template v-if="isTypeSuccess">Fixed:</template>
{{issue.check_name}} <template v-if="isStatusSuccess && isTypeQuality">Fixed:</template>
<template v-if="issue.location.path">in</template> <template v-if="isTypeSecurity && issue.priority">{{issue.priority}}:</template>
{{issue.name}}
<template v-if="issue.path">
in
<a <a
:href="issue.location.urlPath" :href="issue.urlPath"
target="_blank" target="_blank"
rel="noopener noreferrer nofollow"> rel="noopener noreferrer nofollow">
{{issue.location.path}}<template v-if="issue.location.lines && issue.location.lines.begin">:{{issue.location.lines.begin}}</template> {{issue.path}}<template v-if="issue.line">:{{issue.line}}</template>
</a> </a>
</template>
</li> </li>
</ul> </ul>
</template> </template>
import { n__ } from '~/locale';
import CEWidgetOptions from '~/vue_merge_request_widget/mr_widget_options'; import CEWidgetOptions from '~/vue_merge_request_widget/mr_widget_options';
import WidgetApprovals from './components/approvals/mr_widget_approvals'; import WidgetApprovals from './components/approvals/mr_widget_approvals';
import GeoSecondaryNode from './components/states/mr_widget_secondary_geo_node'; import GeoSecondaryNode from './components/states/mr_widget_secondary_geo_node';
import RebaseState from './components/states/mr_widget_rebase.vue'; import RebaseState from './components/states/mr_widget_rebase.vue';
import WidgetCodeQuality from './components/mr_widget_code_quality.vue'; import collapsibleSection from './components/mr_widget_report_collapsible_section.vue';
export default { export default {
extends: CEWidgetOptions, extends: CEWidgetOptions,
...@@ -10,7 +11,15 @@ export default { ...@@ -10,7 +11,15 @@ export default {
'mr-widget-approvals': WidgetApprovals, 'mr-widget-approvals': WidgetApprovals,
'mr-widget-geo-secondary-node': GeoSecondaryNode, 'mr-widget-geo-secondary-node': GeoSecondaryNode,
'mr-widget-rebase': RebaseState, 'mr-widget-rebase': RebaseState,
'mr-widget-code-quality': WidgetCodeQuality, collapsibleSection,
},
data() {
return {
isLoadingCodequality: false,
isLoadingSecurity: false,
loadingCodequalityFailed: false,
loadingSecurityFailed: false,
};
}, },
computed: { computed: {
shouldRenderApprovals() { shouldRenderApprovals() {
...@@ -20,6 +29,115 @@ export default { ...@@ -20,6 +29,115 @@ export default {
const { codeclimate } = this.mr; const { codeclimate } = this.mr;
return codeclimate && codeclimate.head_path && codeclimate.base_path; return codeclimate && codeclimate.head_path && codeclimate.base_path;
}, },
shouldRenderSecurityReport() {
return this.mr.sast;
},
codequalityText() {
const { newIssues, resolvedIssues } = this.mr.codeclimateMetrics;
const text = [];
if (!newIssues.length && !resolvedIssues.length) {
text.push('No changes to code quality');
} else if (newIssues.length || resolvedIssues.length) {
text.push('Code quality');
if (resolvedIssues.length) {
text.push(n__(
' improved on %d point',
' improved on %d points',
resolvedIssues.length,
));
}
if (newIssues.length > 0 && resolvedIssues.length > 0) {
text.push(' and');
}
if (newIssues.length) {
text.push(n__(
' degraded on %d point',
' degraded on %d points',
newIssues.length,
));
}
}
return text.join('');
},
securityText() {
if (this.mr.securityReport.length) {
return n__(
'%d security vulnerability detected',
'%d security vulnerabilities detected',
this.mr.securityReport.length,
);
}
return 'No security vulnerabilities detected';
},
codequalityStatus() {
if (this.isLoadingCodequality) {
return 'loading';
} else if (this.loadingCodequalityFailed) {
return 'error';
}
return 'success';
},
securityStatus() {
if (this.isLoadingSecurity) {
return 'loading';
} else if (this.loadingSecurityFailed) {
return 'error';
}
return 'success';
},
},
methods: {
fetchCodeQuality() {
const { head_path, head_blob_path, base_path, base_blob_path } = this.mr.codeclimate;
this.isLoadingCodequality = true;
Promise.all([
this.service.fetchReport(head_path),
this.service.fetchReport(base_path),
])
.then((values) => {
this.mr.compareCodeclimateMetrics(values[0], values[1], head_blob_path, base_blob_path);
this.isLoadingCodequality = false;
})
.catch(() => {
this.isLoadingCodequality = false;
this.loadingCodequalityFailed = true;
});
},
fetchSecurity() {
const { path, blob_path } = this.mr.sast;
this.isLoadingSecurity = true;
this.service.fetchReport(path)
.then((data) => {
this.mr.setSecurityReport(data, blob_path);
this.isLoadingSecurity = false;
})
.catch(() => {
this.isLoadingSecurity = false;
this.loadingSecurityFailed = true;
});
},
},
created() {
if (this.shouldRenderCodeQuality) {
this.fetchCodeQuality();
}
if (this.shouldRenderSecurityReport) {
this.fetchSecurity();
}
}, },
template: ` template: `
<div class="mr-state-widget prepend-top-default"> <div class="mr-state-widget prepend-top-default">
...@@ -38,10 +156,26 @@ export default { ...@@ -38,10 +156,26 @@ export default {
v-if="shouldRenderApprovals" v-if="shouldRenderApprovals"
:mr="mr" :mr="mr"
:service="service" /> :service="service" />
<mr-widget-code-quality <collapsible-section
class="js-codequality-widget"
v-if="shouldRenderCodeQuality" v-if="shouldRenderCodeQuality"
:mr="mr" type="codequality"
:service="service" :status="codequalityStatus"
loading-text="Loading codeclimate report"
error-text="Failed to load codeclimate report"
:success-text="codequalityText"
:unresolvedIssues="mr.codeclimateMetrics.newIssues"
:resolvedIssues="mr.codeclimateMetrics.resolvedIssues"
/>
<collapsible-section
class="js-sast-widget"
v-if="shouldRenderSecurityReport"
type="security"
:status="securityStatus"
loading-text="Loading security report"
error-text="Failed to load security report"
:success-text="securityText"
:unresolvedIssues="mr.securityReport"
/> />
<div class="mr-widget-section"> <div class="mr-widget-section">
<component <component
......
...@@ -35,7 +35,7 @@ export default class MRWidgetService extends CEWidgetService { ...@@ -35,7 +35,7 @@ export default class MRWidgetService extends CEWidgetService {
return this.rebaseResource.save(); return this.rebaseResource.save();
} }
fetchCodeclimate(endpoint) { // eslint-disable-line fetchReport(endpoint) { // eslint-disable-line
return Vue.http.get(endpoint); return Vue.http.get(endpoint).then(res => res.json());
} }
} }
...@@ -4,6 +4,7 @@ export default class MergeRequestStore extends CEMergeRequestStore { ...@@ -4,6 +4,7 @@ export default class MergeRequestStore extends CEMergeRequestStore {
constructor(data) { constructor(data) {
super(data); super(data);
this.initCodeclimate(data); this.initCodeclimate(data);
this.initSecurityReport(data);
} }
setData(data) { setData(data) {
...@@ -56,9 +57,18 @@ export default class MergeRequestStore extends CEMergeRequestStore { ...@@ -56,9 +57,18 @@ export default class MergeRequestStore extends CEMergeRequestStore {
}; };
} }
initSecurityReport(data) {
this.sast = data.sast;
this.securityReport = [];
}
setSecurityReport(issues, path) {
this.securityReport = MergeRequestStore.parseIssues(issues, path);
}
compareCodeclimateMetrics(headIssues, baseIssues, headBlobPath, baseBlobPath) { compareCodeclimateMetrics(headIssues, baseIssues, headBlobPath, baseBlobPath) {
const parsedHeadIssues = MergeRequestStore.addPathToIssues(headIssues, headBlobPath); const parsedHeadIssues = MergeRequestStore.parseIssues(headIssues, headBlobPath);
const parsedBaseIssues = MergeRequestStore.addPathToIssues(baseIssues, baseBlobPath); const parsedBaseIssues = MergeRequestStore.parseIssues(baseIssues, baseBlobPath);
this.codeclimateMetrics.newIssues = MergeRequestStore.filterByFingerprint( this.codeclimateMetrics.newIssues = MergeRequestStore.filterByFingerprint(
parsedHeadIssues, parsedHeadIssues,
...@@ -69,25 +79,61 @@ export default class MergeRequestStore extends CEMergeRequestStore { ...@@ -69,25 +79,61 @@ export default class MergeRequestStore extends CEMergeRequestStore {
parsedHeadIssues, parsedHeadIssues,
); );
} }
/**
static filterByFingerprint(firstArray, secondArray) { * In order to reuse the same component we need
return firstArray.filter(item => !secondArray.find(el => el.fingerprint === item.fingerprint)); * to set both codequality and security issues to have the same data structure:
} * [
* {
static addPathToIssues(issues, path) { * name: String,
* priority: String,
* fingerprint: String,
* path: String,
* line: Number,
* urlPath: String
* }
* ]
* @param {array} issues
* @return {array}
*/
static parseIssues(issues, path) {
return issues.map((issue) => { return issues.map((issue) => {
const parsedIssue = {
name: issue.check_name || issue.message,
...issue,
};
// code quality
if (issue.location) { if (issue.location) {
let parsedUrl = `${path}/${issue.location.path}`; let parseCodeQualityUrl;
if (issue.location.path) {
parseCodeQualityUrl = `${path}/${issue.location.path}`;
parsedIssue.path = issue.location.path;
}
if (issue.location.lines && issue.location.lines.begin) { if (issue.location.lines && issue.location.lines.begin) {
parsedUrl += `#L${issue.location.lines.begin}`; parsedIssue.line = issue.location.lines.begin;
parseCodeQualityUrl += `#L${issue.location.lines.begin}`;
} }
return Object.assign({}, issue, { parsedIssue.urlPath = parseCodeQualityUrl;
location: Object.assign({}, issue.location, { urlPath: parsedUrl }),
}); // security
} else if (issue.file) {
let parsedSecurityUrl = `${path}/${issue.file}`;
parsedIssue.path = issue.file;
if (issue.line) {
parsedSecurityUrl += `#L${issue.line}`;
}
parsedIssue.urlPath = parsedSecurityUrl;
} }
return issue;
return parsedIssue;
}); });
} }
static filterByFingerprint(firstArray, secondArray) {
return firstArray.filter(item => !secondArray.find(el => el.fingerprint === item.fingerprint));
}
} }
module EE
# Build EE mixin
#
# This module is intended to encapsulate EE-specific model logic
# and be included in the `Build` model
module Build
extend ActiveSupport::Concern
included do
after_save :stick_build_if_status_changed
end
def shared_runners_minutes_limit_enabled?
runner && runner.shared? && project.shared_runners_minutes_limit_enabled?
end
def stick_build_if_status_changed
return unless status_changed?
return unless running?
::Gitlab::Database::LoadBalancing::Sticking.stick(:build, id)
end
end
end
module EE
module Ci
# Build EE mixin
#
# This module is intended to encapsulate EE-specific model logic
# and be included in the `Build` model
module Build
extend ActiveSupport::Concern
included do
scope :codequality, ->() { where(name: %w[codequality codeclimate]) }
scope :sast, ->() { where(name: 'sast') }
after_save :stick_build_if_status_changed
end
def shared_runners_minutes_limit_enabled?
runner && runner.shared? && project.shared_runners_minutes_limit_enabled?
end
def stick_build_if_status_changed
return unless status_changed?
return unless running?
::Gitlab::Database::LoadBalancing::Sticking.stick(:build, id)
end
def has_codeclimate_json?
options.dig(:artifacts, :paths) == ['codeclimate.json'] &&
artifacts_metadata?
end
def has_sast_json?
options.dig(:artifacts, :paths) == ['gl-sast-report.json'] &&
artifacts_metadata?
end
end
end
end
...@@ -12,6 +12,14 @@ module EE ...@@ -12,6 +12,14 @@ module EE
result result
end end
def codeclimate_artifact
artifacts.codequality.find(&:has_codeclimate_json?)
end
def sast_artifact
artifacts.sast.find(&:has_sast_json?)
end
end end
end end
end end
...@@ -11,6 +11,7 @@ module EE ...@@ -11,6 +11,7 @@ module EE
delegate :codeclimate_artifact, to: :head_pipeline, prefix: :head, allow_nil: true delegate :codeclimate_artifact, to: :head_pipeline, prefix: :head, allow_nil: true
delegate :codeclimate_artifact, to: :base_pipeline, prefix: :base, allow_nil: true delegate :codeclimate_artifact, to: :base_pipeline, prefix: :base, allow_nil: true
delegate :sast_artifact, to: :head_pipeline, allow_nil: true
delegate :sha, to: :head_pipeline, prefix: :head_pipeline, allow_nil: true delegate :sha, to: :head_pipeline, prefix: :head_pipeline, allow_nil: true
delegate :sha, to: :base_pipeline, prefix: :base_pipeline, allow_nil: true delegate :sha, to: :base_pipeline, prefix: :base_pipeline, allow_nil: true
end end
...@@ -64,5 +65,9 @@ module EE ...@@ -64,5 +65,9 @@ module EE
!!(head_codeclimate_artifact&.success? && !!(head_codeclimate_artifact&.success? &&
base_codeclimate_artifact&.success?) base_codeclimate_artifact&.success?)
end end
def has_sast_data?
sast_artifact&.success?
end
end end
end end
...@@ -53,8 +53,9 @@ class License < ActiveRecord::Base ...@@ -53,8 +53,9 @@ class License < ActiveRecord::Base
].freeze ].freeze
EEU_FEATURES = EEP_FEATURES + %i[ EEU_FEATURES = EEP_FEATURES + %i[
sast
epics epics
] ].freeze
# List all features available for early adopters, # List all features available for early adopters,
# i.e. users that started using GitLab.com before # i.e. users that started using GitLab.com before
......
module EE
module MergeRequestEntity
extend ActiveSupport::Concern
prepended do
expose :codeclimate, if: -> (mr, _) { mr.has_codeclimate_data? } do
expose :head_path, if: -> (mr, _) { can?(current_user, :read_build, mr.head_codeclimate_artifact) } do |merge_request|
raw_project_build_artifacts_url(merge_request.source_project,
merge_request.head_codeclimate_artifact,
path: 'codeclimate.json')
end
expose :head_blob_path, if: -> (mr, _) { mr.head_pipeline_sha } do |merge_request|
project_blob_path(merge_request.project, merge_request.head_pipeline_sha)
end
expose :base_path, if: -> (mr, _) { can?(current_user, :read_build, mr.base_codeclimate_artifact) } do |merge_request|
raw_project_build_artifacts_url(merge_request.target_project,
merge_request.base_codeclimate_artifact,
path: 'codeclimate.json')
end
expose :base_blob_path, if: -> (mr, _) { mr.base_pipeline_sha } do |merge_request|
project_blob_path(merge_request.project, merge_request.base_pipeline_sha)
end
end
expose :sast, if: -> (mr, _) { expose_sast_data?(mr, current_user) } do
expose :path do |merge_request|
raw_project_build_artifacts_url(merge_request.source_project,
merge_request.sast_artifact,
path: 'gl-sast-report.json')
end
expose :blob_path, if: -> (mr, _) { mr.head_pipeline_sha } do |merge_request|
project_blob_path(merge_request.project, merge_request.head_pipeline_sha)
end
end
end
private
def expose_sast_data?(mr, current_user)
mr.project.feature_available?(:sast) &&
mr.has_sast_data? &&
can?(current_user, :read_build, mr.sast_artifact)
end
end
end
...@@ -161,4 +161,38 @@ describe Ci::Build do ...@@ -161,4 +161,38 @@ describe Ci::Build do
it { expect(build.has_codeclimate_json?).to be_falsey } it { expect(build.has_codeclimate_json?).to be_falsey }
end end
end end
describe '#has_sast_json?' do
context 'valid build' do
let!(:build) do
create(
:ci_build,
:artifacts,
name: 'sast',
pipeline: pipeline,
options: {
artifacts: {
paths: ['gl-sast-report.json']
}
}
)
end
it { expect(build.has_sast_json?).to be_truthy }
end
context 'invalid build' do
let!(:build) do
create(
:ci_build,
:artifacts,
name: 'sast',
pipeline: pipeline,
options: {}
)
end
it { expect(build.has_sast_json?).to be_falsey }
end
end
end end
require 'spec_helper' require 'spec_helper'
describe Ci::Pipeline do describe Ci::Pipeline do
let(:user) { create(:user) }
set(:project) { create(:project) }
let(:pipeline) do
create(:ci_empty_pipeline, status: :created, project: project)
end
describe '.failure_reasons' do describe '.failure_reasons' do
it 'contains failure reasons about exceeded limits' do it 'contains failure reasons about exceeded limits' do
expect(described_class.failure_reasons) expect(described_class.failure_reasons)
.to include 'activity_limit_exceeded', 'size_limit_exceeded' .to include 'activity_limit_exceeded', 'size_limit_exceeded'
end end
end end
describe '#codeclimate_artifact' do
context 'has codequality job' do
let!(:build) do
create(
:ci_build,
:artifacts,
name: 'codequality',
pipeline: pipeline,
options: {
artifacts: {
paths: ['codeclimate.json']
}
}
)
end
it { expect(pipeline.codeclimate_artifact).to eq(build) }
end
context 'no codequality job' do
before do
create(:ci_build, pipeline: pipeline)
end
it { expect(pipeline.codeclimate_artifact).to be_nil }
end
end
describe '#sast_artifact' do
context 'has sast job' do
let!(:build) do
create(
:ci_build,
:artifacts,
name: 'sast',
pipeline: pipeline,
options: {
artifacts: {
paths: ['gl-sast-report.json']
}
}
)
end
it { expect(pipeline.sast_artifact).to eq(build) }
end
context 'no sast job' do
before do
create(:ci_build, pipeline: pipeline)
end
it { expect(pipeline.sast_artifact).to be_nil }
end
end
end end
...@@ -166,4 +166,18 @@ describe MergeRequest do ...@@ -166,4 +166,18 @@ describe MergeRequest do
it { is_expected.to eq(expected) } it { is_expected.to eq(expected) }
end end
end end
describe '#sast_artifact' do
it { is_expected.to delegate_method(:sast_artifact).to(:head_pipeline) }
end
describe '#has_sast_data?' do
let(:artifact) { double(success?: true) }
before do
allow(merge_request).to receive(:sast_artifact).and_return(artifact)
end
it { expect(merge_request.has_sast_data?).to be_truthy }
end
end end
require 'spec_helper'
describe MergeRequestEntity do
let(:user) { create(:user) }
let(:project) { create :project, :repository }
let(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
let(:build) { create(:ci_build, name: 'sast') }
let(:request) { double('request', current_user: user) }
subject do
described_class.new(merge_request, request: request)
end
it 'has sast data' do
allow(subject).to receive(:expose_sast_data?).and_return(true)
allow(merge_request).to receive(:sast_artifact).and_return(build)
expect(subject.as_json).to include(:sast)
end
end
import Vue from 'vue';
import mrWidgetCodeQualityIssues from 'ee/vue_merge_request_widget/components/mr_widget_code_quality_issues.vue';
describe('merge request code quality issues', () => {
let vm;
let MRWidgetCodeQualityIssues;
let mountComponent;
beforeEach(() => {
MRWidgetCodeQualityIssues = Vue.extend(mrWidgetCodeQualityIssues);
mountComponent = props => new MRWidgetCodeQualityIssues({ propsData: props }).$mount();
});
describe('renders provided list of issues', () => {
describe('with positions and lines', () => {
beforeEach(() => {
vm = mountComponent({
type: 'success',
issues: [{
check_name: 'foo',
location: {
path: 'bar',
urlPath: 'foo',
positions: '81',
lines: {
begin: '21',
},
},
}],
});
});
it('should render issue', () => {
expect(
vm.$el.querySelector('li').textContent.trim().replace(/\s+/g, ''),
).toEqual('Fixed:fooinbar:21');
});
});
describe('for type failed', () => {
beforeEach(() => {
vm = mountComponent({
type: 'failed',
issues: [{
check_name: 'foo',
location: {
path: 'bar',
positions: '81',
lines: {
begin: '21',
},
},
}],
});
});
it('should render failed minus icon', () => {
expect(vm.$el.querySelector('li').classList.contains('failed')).toEqual(true);
expect(vm.$el.querySelector('li svg use').getAttribute('xlink:href')).toContain('cut');
});
});
describe('for type success', () => {
beforeEach(() => {
vm = mountComponent({
type: 'success',
issues: [{
check_name: 'foo',
location: {
path: 'bar',
positions: '81',
lines: {
begin: '21',
},
},
}],
});
});
it('should render success plus icon', () => {
expect(vm.$el.querySelector('li').classList.contains('success')).toEqual(true);
expect(vm.$el.querySelector('li svg use').getAttribute('xlink:href')).toContain('plus');
});
});
});
});
import Vue from 'vue';
import mrWidgetCodeQuality from 'ee/vue_merge_request_widget/components/mr_widget_code_quality.vue';
import Store from 'ee/vue_merge_request_widget/stores/mr_widget_store';
import Service from 'ee/vue_merge_request_widget/services/mr_widget_service';
import mockData, { baseIssues, headIssues } from '../mock_data';
describe('Merge Request Code Quality', () => {
let vm;
let MRWidgetCodeQuality;
let store;
let mountComponent;
let service;
beforeEach(() => {
MRWidgetCodeQuality = Vue.extend(mrWidgetCodeQuality);
store = new Store(mockData);
service = new Service('');
mountComponent = props => new MRWidgetCodeQuality({ propsData: props }).$mount();
});
afterEach(() => {
vm.$destroy();
});
describe('when it is loading', () => {
beforeEach(() => {
vm = mountComponent({
mr: store,
service,
});
});
it('should render loading indicator', () => {
expect(vm.$el.textContent.trim()).toEqual('Loading codeclimate report');
});
});
describe('with successful request', () => {
const interceptor = (request, next) => {
if (request.url === 'head.json') {
next(request.respondWith(JSON.stringify(headIssues), {
status: 200,
}));
}
if (request.url === 'base.json') {
next(request.respondWith(JSON.stringify(baseIssues), {
status: 200,
}));
}
};
beforeEach(() => {
Vue.http.interceptors.push(interceptor);
vm = mountComponent({
mr: store,
service,
});
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
});
it('should render provided data', (done) => {
setTimeout(() => {
expect(
vm.$el.querySelector('.js-code-text').textContent.trim(),
).toEqual('Code quality improved on 1 point and degraded on 1 point');
done();
}, 0);
});
describe('text connector', () => {
it('should only render information about fixed issues', (done) => {
setTimeout(() => {
vm.mr.codeclimateMetrics.newIssues = [];
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.js-code-text').textContent.trim(),
).toEqual('Code quality improved on 1 point');
done();
});
}, 0);
});
it('should only render information about added issues', (done) => {
setTimeout(() => {
vm.mr.codeclimateMetrics.resolvedIssues = [];
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.js-code-text').textContent.trim(),
).toEqual('Code quality degraded on 1 point');
done();
});
}, 0);
});
});
describe('toggleCollapsed', () => {
it('toggles issues', (done) => {
setTimeout(() => {
vm.$el.querySelector('button').click();
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.code-quality-container').getAttribute('style'),
).toEqual('');
expect(
vm.$el.querySelector('button').textContent.trim(),
).toEqual('Collapse');
vm.$el.querySelector('button').click();
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.code-quality-container').getAttribute('style'),
).toEqual('display: none;');
expect(
vm.$el.querySelector('button').textContent.trim(),
).toEqual('Expand');
done();
});
});
}, 0);
});
});
});
describe('with empty successful request', () => {
const emptyInterceptor = (request, next) => {
if (request.url === 'head.json') {
next(request.respondWith(JSON.stringify([]), {
status: 200,
}));
}
if (request.url === 'base.json') {
next(request.respondWith(JSON.stringify([]), {
status: 200,
}));
}
};
beforeEach(() => {
Vue.http.interceptors.push(emptyInterceptor);
vm = mountComponent({
mr: store,
service,
});
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, emptyInterceptor);
});
it('should render provided data', (done) => {
setTimeout(() => {
expect(
vm.$el.querySelector('.js-code-text').textContent.trim(),
).toEqual('No changes to code quality');
done();
}, 0);
});
});
describe('with failed request', () => {
const errorInterceptor = (request, next) => {
if (request.url === 'head.json') {
next(request.respondWith(JSON.stringify([]), {
status: 500,
}));
}
if (request.url === 'base.json') {
next(request.respondWith(JSON.stringify([]), {
status: 500,
}));
}
};
beforeEach(() => {
Vue.http.interceptors.push(errorInterceptor);
vm = mountComponent({
mr: store,
service,
});
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, errorInterceptor);
});
it('should render error indicator', (done) => {
setTimeout(() => {
expect(vm.$el.textContent.trim()).toEqual('Failed to load codeclimate report');
done();
}, 0);
});
});
});
import Vue from 'vue';
import mrWidgetCodeQuality from 'ee/vue_merge_request_widget/components/mr_widget_report_collapsible_section.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
import { codequalityParsedIssues } from '../mock_data';
describe('Merge Request collapsible section', () => {
let vm;
let MRWidgetCodeQuality;
beforeEach(() => {
MRWidgetCodeQuality = Vue.extend(mrWidgetCodeQuality);
});
afterEach(() => {
vm.$destroy();
});
describe('when it is loading', () => {
it('should render loading indicator', () => {
vm = mountComponent(MRWidgetCodeQuality, {
type: 'codequality',
status: 'loading',
loadingText: 'Loading codeclimate report',
errorText: 'foo',
successText: 'Code quality improved on 1 point and degraded on 1 point',
});
expect(vm.$el.textContent.trim()).toEqual('Loading codeclimate report');
});
});
describe('with success status', () => {
it('should render provided data', () => {
vm = mountComponent(MRWidgetCodeQuality, {
type: 'codequality',
status: 'success',
loadingText: 'Loading codeclimate report',
errorText: 'foo',
successText: 'Code quality improved on 1 point and degraded on 1 point',
resolvedIssues: codequalityParsedIssues,
});
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);
});
describe('toggleCollapsed', () => {
it('toggles issues', (done) => {
vm = mountComponent(MRWidgetCodeQuality, {
type: 'codequality',
status: 'success',
loadingText: 'Loading codeclimate report',
errorText: 'foo',
successText: 'Code quality improved on 1 point and degraded on 1 point',
resolvedIssues: codequalityParsedIssues,
});
vm.$el.querySelector('button').click();
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.code-quality-container').getAttribute('style'),
).toEqual('');
expect(
vm.$el.querySelector('button').textContent.trim(),
).toEqual('Collapse');
vm.$el.querySelector('button').click();
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.code-quality-container').getAttribute('style'),
).toEqual('display: none;');
expect(
vm.$el.querySelector('button').textContent.trim(),
).toEqual('Expand');
done();
});
});
});
});
});
describe('with failed request', () => {
it('should render error indicator', () => {
vm = mountComponent(MRWidgetCodeQuality, {
type: 'codequality',
status: 'error',
loadingText: 'Loading codeclimate report',
errorText: 'Failed to load codeclimate report',
successText: 'Code quality improved on 1 point and degraded on 1 point',
});
expect(vm.$el.textContent.trim()).toEqual('Failed to load codeclimate report');
});
});
});
import Vue from 'vue';
import mrWidgetCodeQualityIssues from 'ee/vue_merge_request_widget/components/mr_widget_report_issues.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
import { securityParsedIssues, codequalityParsedIssues } from '../mock_data';
describe('merge request report issues', () => {
let vm;
let MRWidgetCodeQualityIssues;
beforeEach(() => {
MRWidgetCodeQualityIssues = Vue.extend(mrWidgetCodeQualityIssues);
});
afterEach(() => {
vm.$destroy();
});
describe('for codequality issues', () => {
describe('resolved issues', () => {
beforeEach(() => {
vm = mountComponent(MRWidgetCodeQualityIssues, {
issues: codequalityParsedIssues,
type: 'codequality',
status: 'success',
});
});
it('should render a list of resolved issues', () => {
expect(vm.$el.querySelectorAll('.mr-widget-code-quality-list li').length).toEqual(codequalityParsedIssues.length);
});
it('should render "Fixed" keyword', () => {
expect(vm.$el.querySelector('.mr-widget-code-quality-list li').textContent).toContain('Fixed');
expect(
vm.$el.querySelector('.mr-widget-code-quality-list li').textContent.replace(/\s+/g, ' ').trim(),
).toEqual('Fixed: Insecure Dependency in Gemfile.lock:12');
});
});
describe('unresolved issues', () => {
beforeEach(() => {
vm = mountComponent(MRWidgetCodeQualityIssues, {
issues: codequalityParsedIssues,
type: 'codequality',
status: 'failed',
});
});
it('should render a list of unresolved issues', () => {
expect(vm.$el.querySelectorAll('.mr-widget-code-quality-list li').length).toEqual(codequalityParsedIssues.length);
});
it('should not render "Fixed" keyword', () => {
expect(vm.$el.querySelector('.mr-widget-code-quality-list li').textContent).not.toContain('Fixed');
});
});
});
describe('for security issues', () => {
beforeEach(() => {
vm = mountComponent(MRWidgetCodeQualityIssues, {
issues: securityParsedIssues,
type: 'security',
status: 'failed',
});
});
it('should render a list of unresolved issues', () => {
expect(vm.$el.querySelectorAll('.mr-widget-code-quality-list li').length).toEqual(securityParsedIssues.length);
});
it('should render priority', () => {
expect(vm.$el.querySelector('.mr-widget-code-quality-list li').textContent).toContain(securityParsedIssues[0].priority);
});
});
describe('with location', () => {
it('should render location', () => {
vm = mountComponent(MRWidgetCodeQualityIssues, {
issues: securityParsedIssues,
type: 'security',
status: 'failed',
});
expect(vm.$el.querySelector('.mr-widget-code-quality-list li').textContent).toContain('in');
expect(vm.$el.querySelector('.mr-widget-code-quality-list li a').getAttribute('href')).toEqual(securityParsedIssues[0].urlPath);
});
});
describe('without location', () => {
it('should not render location', () => {
vm = mountComponent(MRWidgetCodeQualityIssues, {
issues: [{
name: 'foo',
}],
type: 'security',
status: 'failed',
});
expect(vm.$el.querySelector('.mr-widget-code-quality-list li').textContent).not.toContain('in');
expect(vm.$el.querySelector('.mr-widget-code-quality-list li a')).toEqual(null);
});
});
});
import Vue from 'vue'; import Vue from 'vue';
import mrWidgetOptionsEE from 'ee/vue_merge_request_widget/mr_widget_options'; import mrWidgetOptions from 'ee/vue_merge_request_widget/mr_widget_options';
import mockData from './mock_data'; import MRWidgetService from 'ee/vue_merge_request_widget/services/mr_widget_service';
import MRWidgetStore from 'ee/vue_merge_request_widget/stores/mr_widget_store';
import mockData, { baseIssues, headIssues, securityIssues } from './mock_data';
import mountComponent from '../helpers/vue_mount_component_helper'; import mountComponent from '../helpers/vue_mount_component_helper';
describe('EE mrWidgetOptions', () => { describe('ee merge request widget options', () => {
let vm; let vm;
let MrWidgetOptions; let Component;
beforeEach(() => { beforeEach(() => {
// Prevent component mounting delete mrWidgetOptions.extends.el; // Prevent component mounting
delete mrWidgetOptionsEE.extends.el;
MrWidgetOptions = Vue.extend(mrWidgetOptionsEE); Component = Vue.extend(mrWidgetOptions);
}); });
afterEach(() => { afterEach(() => {
vm.$destroy(); vm.$destroy();
}); });
describe('security widget', () => {
beforeEach(() => {
gl.mrWidgetData = {
...mockData,
sast: {
path: 'path.json',
blob_path: 'blob_path',
},
};
Component.mr = new MRWidgetStore(gl.mrWidgetData);
Component.service = new MRWidgetService({});
});
describe('when it is loading', () => {
it('should render loading indicator', () => {
vm = mountComponent(Component);
expect(
vm.$el.querySelector('.js-sast-widget').textContent.trim(),
).toContain('Loading security report');
});
});
describe('with successful request', () => {
const interceptor = (request, next) => {
if (request.url === 'path.json') {
next(request.respondWith(JSON.stringify(securityIssues), {
status: 200,
}));
}
};
beforeEach(() => {
Vue.http.interceptors.push(interceptor);
vm = mountComponent(Component);
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
});
it('should render provided data', (done) => {
setTimeout(() => {
expect(
vm.$el.querySelector('.js-sast-widget .js-code-text').textContent.trim(),
).toEqual('2 security vulnerabilities detected');
done();
}, 0);
});
});
describe('with empty successful request', () => {
const emptyInterceptor = (request, next) => {
if (request.url === 'path.json') {
next(request.respondWith(JSON.stringify([]), {
status: 200,
}));
}
};
beforeEach(() => {
Vue.http.interceptors.push(emptyInterceptor);
vm = mountComponent(Component);
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, emptyInterceptor);
});
it('should render provided data', (done) => {
setTimeout(() => {
expect(
vm.$el.querySelector('.js-sast-widget .js-code-text').textContent.trim(),
).toEqual('No security vulnerabilities detected');
done();
}, 0);
});
});
describe('with failed request', () => {
const errorInterceptor = (request, next) => {
if (request.url === 'path.json') {
next(request.respondWith(JSON.stringify([]), {
status: 500,
}));
}
};
beforeEach(() => {
Vue.http.interceptors.push(errorInterceptor);
vm = mountComponent(Component);
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, errorInterceptor);
});
it('should render error indicator', (done) => {
setTimeout(() => {
expect(
vm.$el.querySelector('.js-sast-widget').textContent.trim(),
).toContain('Failed to load security report');
done();
}, 0);
});
});
});
describe('code quality', () => {
beforeEach(() => {
gl.mrWidgetData = {
...mockData,
codeclimate: {
head_path: 'head.json',
base_path: 'base.json',
},
};
Component.mr = new MRWidgetStore(gl.mrWidgetData);
Component.service = new MRWidgetService({});
});
describe('when it is loading', () => {
it('should render loading indicator', () => {
vm = mountComponent(Component);
expect(
vm.$el.querySelector('.js-codequality-widget').textContent.trim(),
).toContain('Loading codeclimate report');
});
});
describe('with successful request', () => {
const interceptor = (request, next) => {
if (request.url === 'head.json') {
next(request.respondWith(JSON.stringify(headIssues), {
status: 200,
}));
}
if (request.url === 'base.json') {
next(request.respondWith(JSON.stringify(baseIssues), {
status: 200,
}));
}
};
beforeEach(() => {
Vue.http.interceptors.push(interceptor);
vm = mountComponent(Component);
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
});
it('should render provided data', (done) => {
setTimeout(() => {
expect(
vm.$el.querySelector('.js-code-text').textContent.trim(),
).toEqual('Code quality improved on 1 point and degraded on 1 point');
done();
}, 0);
});
describe('text connector', () => {
it('should only render information about fixed issues', (done) => {
setTimeout(() => {
vm.mr.codeclimateMetrics.newIssues = [];
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.js-code-text').textContent.trim(),
).toEqual('Code quality improved on 1 point');
done();
});
}, 0);
});
it('should only render information about added issues', (done) => {
setTimeout(() => {
vm.mr.codeclimateMetrics.resolvedIssues = [];
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.js-code-text').textContent.trim(),
).toEqual('Code quality degraded on 1 point');
done();
});
}, 0);
});
});
});
describe('with empty successful request', () => {
const emptyInterceptor = (request, next) => {
if (request.url === 'head.json') {
next(request.respondWith(JSON.stringify([]), {
status: 200,
}));
}
if (request.url === 'base.json') {
next(request.respondWith(JSON.stringify([]), {
status: 200,
}));
}
};
beforeEach(() => {
Vue.http.interceptors.push(emptyInterceptor);
vm = mountComponent(Component);
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, emptyInterceptor);
});
it('should render provided data', (done) => {
setTimeout(() => {
expect(
vm.$el.querySelector('.js-code-text').textContent.trim(),
).toEqual('No changes to code quality');
done();
}, 0);
});
});
describe('with failed request', () => {
const errorInterceptor = (request, next) => {
if (request.url === 'head.json') {
next(request.respondWith(JSON.stringify([]), {
status: 500,
}));
}
if (request.url === 'base.json') {
next(request.respondWith(JSON.stringify([]), {
status: 500,
}));
}
};
beforeEach(() => {
Vue.http.interceptors.push(errorInterceptor);
vm = mountComponent(Component);
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, errorInterceptor);
});
it('should render error indicator', (done) => {
setTimeout(() => {
expect(vm.$el.querySelector('.js-codequality-widget').textContent.trim()).toContain('Failed to load codeclimate report');
done();
}, 0);
});
});
});
describe('computed', () => { describe('computed', () => {
describe('shouldRenderApprovals', () => { describe('shouldRenderApprovals', () => {
it('should return false when no approvals', () => { it('should return false when no approvals', () => {
vm = mountComponent(MrWidgetOptions, { vm = mountComponent(Component, {
mrData: { mrData: {
...mockData, ...mockData,
approvalsRequired: false, approvalsRequired: false,
...@@ -33,7 +293,7 @@ describe('EE mrWidgetOptions', () => { ...@@ -33,7 +293,7 @@ describe('EE mrWidgetOptions', () => {
}); });
it('should return false when in empty state', () => { it('should return false when in empty state', () => {
vm = mountComponent(MrWidgetOptions, { vm = mountComponent(Component, {
mrData: { mrData: {
...mockData, ...mockData,
approvalsRequired: true, approvalsRequired: true,
...@@ -45,7 +305,7 @@ describe('EE mrWidgetOptions', () => { ...@@ -45,7 +305,7 @@ describe('EE mrWidgetOptions', () => {
}); });
it('should return true when requiring approvals and in non-empty state', () => { it('should return true when requiring approvals and in non-empty state', () => {
vm = mountComponent(MrWidgetOptions, { vm = mountComponent(Component, {
mrData: { mrData: {
...mockData, ...mockData,
approvalsRequired: true, approvalsRequired: true,
......
...@@ -221,53 +221,160 @@ export default { ...@@ -221,53 +221,160 @@ export default {
export const headIssues = [ export const headIssues = [
{ {
"check_name": "Rubocop/Lint/UselessAssignment", check_name: 'Rubocop/Lint/UselessAssignment',
"location": { location: {
"path": "lib/six.rb", path: 'lib/six.rb',
"lines": { lines: {
"begin": 6, begin: 6,
"end": 7, end: 7,
} }
}, },
"fingerprint": "e879dd9bbc0953cad5037cde7ff0f627", fingerprint: 'e879dd9bbc0953cad5037cde7ff0f627',
}, },
{ {
"categories": ["Security"], categories: ['Security'],
"check_name": "Insecure Dependency", check_name: 'Insecure Dependency',
"location": { location: {
"path": "Gemfile.lock", path: 'Gemfile.lock',
"lines": { lines: {
"begin": 22, begin: 22,
"end": 22 end: 22
} }
}, },
"fingerprint": "ca2e59451e98ae60ba2f54e3857c50e5", fingerprint: 'ca2e59451e98ae60ba2f54e3857c50e5',
} }
]; ];
export const parsedHeadIssues = [
{
check_name: 'Rubocop/Lint/UselessAssignment',
location: {
path: 'lib/six.rb',
lines: {
begin: 6,
end: 7
},
},
fingerprint: 'e879dd9bbc0953cad5037cde7ff0f627',
name: 'Rubocop/Lint/UselessAssignment',
path: 'lib/six.rb',
urlPath: 'headPath/lib/six.rb#L6',
line: 6,
},
];
export const baseIssues = [ export const baseIssues = [
{ {
"categories": ["Security"], categories: ['Security'],
"check_name": "Insecure Dependency", check_name: 'Insecure Dependency',
"location": { location: {
"path": "Gemfile.lock", path: 'Gemfile.lock',
"lines": { lines: {
"begin": 22, begin: 22,
"end": 22 end: 22
} }
}, },
"fingerprint": "ca2e59451e98ae60ba2f54e3857c50e5", fingerprint: 'ca2e59451e98ae60ba2f54e3857c50e5',
}, },
{ {
"categories": ["Security"], categories: ['Security'],
"check_name": "Insecure Dependency", check_name: 'Insecure Dependency',
"location": { location: {
"path": "Gemfile.lock", path: 'Gemfile.lock',
"lines": { lines: {
"begin": 21, begin: 21,
"end": 21 end: 21
} }
}, },
"fingerprint": "ca2354534dee94ae60ba2f54e3857c50e5", fingerprint: 'ca2354534dee94ae60ba2f54e3857c50e5',
} }
]; ];
export const parsedBaseIssues = [
{
categories: ['Security'],
check_name: 'Insecure Dependency',
location: {
path: 'Gemfile.lock',
lines: {
begin: 21,
end: 21,
},
},
fingerprint: "ca2354534dee94ae60ba2f54e3857c50e5",
name: "Insecure Dependency",
path: "Gemfile.lock",
line: 21,
urlPath: 'basePath/Gemfile.lock#L21',
},
];
export const codequalityParsedIssues = [
{
name: 'Insecure Dependency',
fingerprint: 'ca2e59451e98ae60ba2f54e3857c50e5',
path: 'Gemfile.lock',
line: 12,
urlPath: 'foo/Gemfile.lock',
},
];
export const securityParsedIssues = [
{
name: 'Arbitrary file existence disclosure in Action Pack',
path: 'Gemfile.lock',
line: 12,
priority: 'High',
urlPath: 'foo/Gemfile.lock',
},
];
export const securityIssues = [
{
tool: 'bundler_audit',
message: 'Arbitrary file existence disclosure in Action Pack',
url: 'https://groups.google.com/forum/#!topic/rubyonrails-security/rMTQy4oRCGk',
cve: 'CVE-2014-7829',
file: 'Gemfile.lock',
solution: 'upgrade to ~> 3.2.21, ~> 4.0.11.1, ~> 4.0.12, ~> 4.1.7.1, >= 4.1.8',
priority:'High',
line: 12,
},
{
tool: 'bundler_audit',
message: 'Possible Information Leak Vulnerability in Action View',
url: 'https://groups.google.com/forum/#!topic/rubyonrails-security/335P1DcLG00',
cve: 'CVE-2016-0752',
file: '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',
priority: 'Medium',
},
];
export const parsedSecurityIssuesStore = [
{
tool: 'bundler_audit',
message: 'Arbitrary file existence disclosure in Action Pack',
url: 'https://groups.google.com/forum/#!topic/rubyonrails-security/rMTQy4oRCGk',
cve: 'CVE-2014-7829',
file: 'Gemfile.lock',
solution: 'upgrade to ~> 3.2.21, ~> 4.0.11.1, ~> 4.0.12, ~> 4.1.7.1, >= 4.1.8',
priority:'High',
line: 12,
name: 'Arbitrary file existence disclosure in Action Pack',
path: 'Gemfile.lock',
urlPath: 'path/Gemfile.lock#L12'
},
{
tool: 'bundler_audit',
message: 'Possible Information Leak Vulnerability in Action View',
url: 'https://groups.google.com/forum/#!topic/rubyonrails-security/335P1DcLG00',
cve: 'CVE-2016-0752',
file: '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',
priority: 'Medium',
name: 'Possible Information Leak Vulnerability in Action View',
path: 'Gemfile.lock',
urlPath: 'path/Gemfile.lock',
},
];
import MergeRequestStore from 'ee/vue_merge_request_widget/stores/mr_widget_store'; import MergeRequestStore from 'ee/vue_merge_request_widget/stores/mr_widget_store';
import mockData, { headIssues, baseIssues } from '../mock_data'; import mockData, {
headIssues,
baseIssues,
securityIssues,
parsedBaseIssues,
parsedHeadIssues,
parsedSecurityIssuesStore,
} from '../mock_data';
describe('MergeRequestStore', () => { describe('MergeRequestStore', () => {
let store; let store;
...@@ -60,27 +67,32 @@ describe('MergeRequestStore', () => { ...@@ -60,27 +67,32 @@ describe('MergeRequestStore', () => {
}); });
it('should return the new issues', () => { it('should return the new issues', () => {
const parsed = MergeRequestStore.addPathToIssues(headIssues, 'headPath'); expect(store.codeclimateMetrics.newIssues[0]).toEqual(parsedHeadIssues[0]);
expect(store.codeclimateMetrics.newIssues[0]).toEqual(parsed[0]);
}); });
it('should return the resolved issues', () => { it('should return the resolved issues', () => {
const parsed = MergeRequestStore.addPathToIssues(baseIssues, 'basePath'); expect(store.codeclimateMetrics.resolvedIssues[0]).toEqual(parsedBaseIssues[0]);
expect(store.codeclimateMetrics.resolvedIssues[0]).toEqual(parsed[1]);
}); });
}); });
describe('addPathToIssues', () => { describe('setSecurityReport', () => {
it('should add urlPath key to each entry', () => { it('should set security issues', () => {
expect( store.setSecurityReport(securityIssues, 'path');
MergeRequestStore.addPathToIssues(headIssues, 'path')[0].location.urlPath,
).toEqual(`path/${headIssues[0].location.path}#L${headIssues[0].location.lines.begin}`); expect(store.securityReport).toEqual(parsedSecurityIssuesStore);
});
}); });
it('should return the same object whe there is no locaiton', () => { describe('parseIssues', () => {
expect( it('should parse the received issues', () => {
MergeRequestStore.addPathToIssues([{ check_name: 'foo' }], 'path'), const codequality = MergeRequestStore.parseIssues(baseIssues, 'path')[0];
).toEqual([{ check_name: 'foo' }]); expect(codequality.name).toEqual(baseIssues[0].check_name);
expect(codequality.path).toEqual(baseIssues[0].location.path);
expect(codequality.line).toEqual(baseIssues[0].location.lines.begin);
const security = MergeRequestStore.parseIssues(securityIssues, 'path')[0];
expect(security.name).toEqual(securityIssues[0].message);
expect(security.path).toEqual(securityIssues[0].file);
}); });
}); });
}); });
...@@ -1472,32 +1472,4 @@ describe Ci::Pipeline, :mailer do ...@@ -1472,32 +1472,4 @@ describe Ci::Pipeline, :mailer do
expect(query_count).to eq(1) expect(query_count).to eq(1)
end end
end end
describe '#codeclimate_artifact' do
context 'has codequality job' do
let!(:build) do
create(
:ci_build,
:artifacts,
name: 'codequality',
pipeline: pipeline,
options: {
artifacts: {
paths: ['codeclimate.json']
}
}
)
end
it { expect(pipeline.codeclimate_artifact).to eq(build) }
end
context 'no codequality job' do
before do
create(:ci_build, pipeline: pipeline)
end
it { expect(pipeline.codeclimate_artifact).to be_nil }
end
end
end end
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