Commit 18521850 authored by Nikola Milojevic's avatar Nikola Milojevic

Merge branch '346266-clean-up-old-code' into 'master'

Clean up old compliance report code

See merge request gitlab-org/gitlab!83964
parents 8492e153 a2d0859b
......@@ -27,5 +27,3 @@ class MergeRequestSerializer < BaseSerializer
super(merge_request, opts, entity)
end
end
MergeRequestSerializer.prepend_mod_with('MergeRequestSerializer')
<script>
import { GlTabs, GlTab } from '@gitlab/ui';
import { getCookie } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
import { COMPLIANCE_TAB_COOKIE_KEY } from '../constants';
import { mapDashboardToDrawerData } from '../utils';
import MergeRequestDrawer from './drawer.vue';
import EmptyState from './empty_state.vue';
import MergeRequestsGrid from './merge_requests/grid.vue';
import MergeCommitsExportButton from './shared/merge_commits_export_button.vue';
export default {
name: 'ComplianceDashboard',
components: {
MergeRequestDrawer,
MergeRequestsGrid,
EmptyState,
GlTab,
GlTabs,
MergeCommitsExportButton,
},
props: {
emptyStateSvgPath: {
type: String,
required: true,
},
mergeRequests: {
type: Array,
required: true,
},
isLastPage: {
type: Boolean,
required: false,
default: false,
},
mergeCommitsCsvExportPath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
showDrawer: false,
drawerMergeRequest: {},
drawerProject: {},
};
},
computed: {
hasMergeRequests() {
return this.mergeRequests.length > 0;
},
hasMergeCommitsCsvExportPath() {
return this.mergeCommitsCsvExportPath !== '';
},
drawerMergeRequests() {
return this.mergeRequests.map(mapDashboardToDrawerData);
},
},
methods: {
showTabs() {
return getCookie(COMPLIANCE_TAB_COOKIE_KEY) === 'true';
},
toggleDrawer(mergeRequest) {
if (this.showDrawer && mergeRequest.id === this.drawerMergeRequest.id) {
this.closeDrawer();
} else {
this.openDrawer(this.drawerMergeRequests.find((mr) => mr.id === mergeRequest.id));
}
},
openDrawer(data) {
this.showDrawer = true;
this.drawerMergeRequest = data.mergeRequest;
this.drawerProject = data.project;
},
closeDrawer() {
this.showDrawer = false;
this.drawerMergeRequest = {};
this.drawerProject = {};
},
},
DRAWER_Z_INDEX,
strings: {
heading: __('Compliance report'),
subheading: __('Here you will find recent merge request activity'),
mergeRequestsTabLabel: __('Merge Requests'),
},
};
</script>
<template>
<div v-if="hasMergeRequests" class="compliance-dashboard">
<header>
<div class="gl-mt-5 d-flex">
<h4 class="gl-flex-grow-1 gl-my-0">{{ $options.strings.heading }}</h4>
<merge-commits-export-button
v-if="hasMergeCommitsCsvExportPath"
:merge-commits-csv-export-path="mergeCommitsCsvExportPath"
/>
</div>
<p>{{ $options.strings.subheading }}</p>
</header>
<gl-tabs v-if="showTabs()">
<gl-tab>
<template #title>
<span>{{ $options.strings.mergeRequestsTabLabel }}</span>
</template>
<merge-requests-grid
:merge-requests="mergeRequests"
:is-last-page="isLastPage"
@toggleDrawer="toggleDrawer"
/>
</gl-tab>
</gl-tabs>
<merge-requests-grid
v-else
:merge-requests="mergeRequests"
:is-last-page="isLastPage"
@toggleDrawer="toggleDrawer"
/>
<merge-request-drawer
:show-drawer="showDrawer"
:merge-request="drawerMergeRequest"
:project="drawerProject"
:z-index="$options.DRAWER_Z_INDEX"
@close="closeDrawer"
/>
</div>
<empty-state v-else :image-path="emptyStateSvgPath" />
</template>
<script>
import { GlLink, GlEmptyState } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
GlLink,
GlEmptyState,
},
props: {
imagePath: {
type: String,
required: true,
},
},
strings: {
heading: __("A merge request hasn't yet been merged"),
subheading: __(
'The compliance report captures merged changes that violate compliance best practices.',
),
documentation: __('View documentation'),
},
documentationPath: 'https://docs.gitlab.com/ee/user/compliance/compliance_report/index.html',
};
</script>
<template>
<gl-empty-state
:title="$options.strings.heading"
:description="$options.strings.subheading"
:svg-path="imagePath"
>
<template #actions>
<gl-link :href="$options.documentationPath">{{ $options.strings.documentation }}</gl-link>
</template>
</gl-empty-state>
</template>
<script>
import { GlAvatarLink, GlAvatar, GlAvatarsInline, GlTooltipDirective } from '@gitlab/ui';
import { sprintf, __, n__ } from '~/locale';
import { PRESENTABLE_APPROVERS_LIMIT } from '../../constants';
export default {
directives: {
GlTooltip: GlTooltipDirective,
},
components: {
GlAvatarsInline,
GlAvatarLink,
GlAvatar,
},
props: {
approvers: {
type: Array,
required: true,
},
},
computed: {
hasApprovers() {
return this.approvers.length > 0;
},
approversToPresent() {
return this.approvers.slice(0, PRESENTABLE_APPROVERS_LIMIT);
},
amountOfApproversOverLimit() {
return this.approvers.length - PRESENTABLE_APPROVERS_LIMIT;
},
isApproversOverLimit() {
return this.amountOfApproversOverLimit > 0;
},
approversOverLimitString() {
return sprintf(__('+%{approvers} more approvers'), {
approvers: this.amountOfApproversOverLimit,
});
},
approversBadgeSrOnlyText() {
return n__(
'%d additional approver',
'%d additional approvers',
this.amountOfApproversOverLimit,
);
},
},
PRESENTABLE_APPROVERS_LIMIT,
strings: {
approvedBy: __('approved by: '),
noApprovers: __('no approvers'),
},
};
</script>
<template>
<div class="gl-display-flex gl-align-items-center gl-justify-content-end" data-testid="approvers">
<span class="gl-text-gray-500">
<template v-if="hasApprovers">
{{ $options.strings.approvedBy }}
</template>
<template v-else>
{{ $options.strings.noApprovers }}
</template>
</span>
<gl-avatars-inline
v-if="hasApprovers"
:avatars="approvers"
:collapsed="true"
:max-visible="$options.PRESENTABLE_APPROVERS_LIMIT"
:avatar-size="24"
:badge-sr-only-text="approversBadgeSrOnlyText"
class="gl-display-inline-flex gl-lg-display-none! gl-ml-3"
badge-tooltip-prop="name"
>
<template #avatar="{ avatar }">
<gl-avatar-link
v-gl-tooltip
target="blank"
:href="avatar.webUrl"
:title="avatar.name"
class="gl-text-gray-900 author-link js-user-link"
>
<gl-avatar
:src="avatar.avatarUrl"
:entity-id="avatar.id"
:entity-name="avatar.name"
:size="24"
/>
</gl-avatar-link>
</template>
</gl-avatars-inline>
<gl-avatar-link
v-for="approver in approversToPresent"
:key="approver.id"
:title="approver.name"
:href="approver.webUrl"
:data-user-id="approver.id"
:data-name="approver.name"
class="gl-display-none gl-lg-display-inline-flex! gl-align-items-center gl-justify-content-end gl-ml-3 gl-text-gray-900 author-link js-user-link"
>
<gl-avatar
:src="approver.avatarUrl"
:entity-id="approver.id"
:entity-name="approver.name"
:size="16"
class="gl-mr-2"
/>
<span>{{ approver.name }}</span>
</gl-avatar-link>
<span
v-if="isApproversOverLimit"
v-gl-tooltip.top="approversOverLimitString"
class="gl-display-none gl-lg-display-inline-block! avatar-counter gl-ml-3 gl-px-2 gl-flex-shrink-0 gl-flex-grow-0"
>+ {{ amountOfApproversOverLimit }}</span
>
</div>
</template>
<script>
import { GlSprintf } from '@gitlab/ui';
import { __ } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import BranchDetails from '../shared/branch_details.vue';
import GridColumnHeading from '../shared/grid_column_heading.vue';
import Pagination from '../shared/pagination.vue';
import Approvers from './approvers.vue';
import MergeRequest from './merge_request.vue';
import Status from './status.vue';
export default {
components: {
Approvers,
BranchDetails,
GlSprintf,
GridColumnHeading,
MergeRequest,
Pagination,
Status,
TimeAgoTooltip,
},
props: {
mergeRequests: {
type: Array,
required: true,
},
isLastPage: {
type: Boolean,
required: false,
default: false,
},
},
methods: {
key(id, value) {
return `${id}-${value}`;
},
hasBranchDetails(mergeRequest) {
return mergeRequest.target_branch && mergeRequest.source_branch;
},
onRowClick(e, mergeRequest) {
const link = e.target.closest('a');
// Only toggle the drawer if the element isn't a link
if (!link) {
this.$emit('toggleDrawer', mergeRequest);
}
},
},
strings: {
approvalStatusLabel: __('Approval Status'),
mergedAtText: __('merged %{timeAgo}'),
mergeRequestLabel: __('Merge Request'),
pipelineStatusLabel: __('Pipeline'),
updatesLabel: __('Updates'),
},
keyTypes: {
mergeRequest: 'MR',
approvalStatus: 'approvalStatus',
pipeline: 'pipeline',
updates: 'updates',
},
};
</script>
<template>
<div>
<div class="dashboard-grid gl-display-grid gl-grid-tpl-rows-auto">
<grid-column-heading :heading="$options.strings.mergeRequestLabel" />
<grid-column-heading :heading="$options.strings.approvalStatusLabel" class="gl-text-center" />
<grid-column-heading :heading="$options.strings.pipelineStatusLabel" class="gl-text-center" />
<grid-column-heading :heading="$options.strings.updatesLabel" class="gl-text-right" />
<div
v-for="mergeRequest in mergeRequests"
:key="mergeRequest.id"
class="dashboard-merge-request dashboard-grid gl-display-grid gl-grid-tpl-rows-auto gl-hover-bg-blue-50 gl-hover-text-decoration-none gl-hover-cursor-pointer"
data-testid="merge-request-drawer-toggle"
tabindex="0"
@click="onRowClick($event, mergeRequest)"
@keypress.enter="onRowClick($event, mergeRequest)"
>
<merge-request
:key="key(mergeRequest.id, $options.keyTypes.mergeRequest)"
:merge-request="mergeRequest"
/>
<status
:key="key(mergeRequest.id, 'approval')"
:status="{ type: 'approval', data: mergeRequest.approval_status }"
/>
<status
:key="key(mergeRequest.id, 'pipeline')"
:status="{ type: 'pipeline', data: mergeRequest.pipeline_status }"
/>
<div
:key="key(mergeRequest.id, $options.keyTypes.updates)"
class="gl-text-right gl-border-b-solid gl-border-b-1 gl-border-b-gray-100 gl-p-5 gl-relative"
>
<approvers :approvers="mergeRequest.approved_by_users" />
<branch-details
v-if="hasBranchDetails(mergeRequest)"
class="gl-justify-content-end gl-text-gray-900"
:source-branch="{
name: mergeRequest.source_branch,
uri: mergeRequest.source_branch_uri,
}"
:target-branch="{
name: mergeRequest.target_branch,
uri: mergeRequest.target_branch_uri,
}"
/>
<time-ago-tooltip
:time="mergeRequest.merged_at"
tooltip-placement="bottom"
class="gl-text-gray-500"
>
<template #default="{ timeAgo }">
<gl-sprintf :message="$options.strings.mergedAtText">
<template #timeAgo>{{ timeAgo }}</template>
</gl-sprintf>
</template>
</time-ago-tooltip>
</div>
</div>
</div>
<pagination class="gl-mt-5" :is-last-page="isLastPage" />
</div>
</template>
<script>
import { GlAvatar, GlAvatarLink } from '@gitlab/ui';
import ComplianceFrameworkLabel from 'ee/vue_shared/components/compliance_framework_label/compliance_framework_label.vue';
import { s__ } from '~/locale';
export default {
components: {
ComplianceFrameworkLabel,
GlAvatar,
GlAvatarLink,
},
props: {
mergeRequest: {
type: Object,
required: true,
},
},
computed: {
complianceFramework() {
return this.mergeRequest.compliance_management_framework;
},
},
strings: {
createdBy: s__('ComplianceDashboard|created by:'),
},
};
</script>
<template>
<div
class="gl-grid-col-start-1 gl-border-b-solid gl-border-b-1 gl-border-b-gray-100 gl-p-5"
data-testid="merge-request"
>
<div>
<a :href="mergeRequest.path" class="gl-text-gray-900 gl-font-weight-bold">
{{ mergeRequest.title }}
</a>
</div>
<span class="gl-text-gray-500">{{ mergeRequest.issuable_reference }}</span>
<span class="issuable-authored gl-text-gray-500 gl-display-inline-flex gl-align-items-center">
- {{ $options.strings.createdBy }}
<gl-avatar-link
:key="mergeRequest.author.id"
:title="mergeRequest.author.name"
:href="mergeRequest.author.webUrl"
:data-user-id="mergeRequest.author.id"
:data-name="mergeRequest.author.name"
class="gl-display-inline-flex gl-align-items-center gl-ml-3 gl-text-gray-900 author-link js-user-link"
>
<gl-avatar
:src="mergeRequest.author.avatarUrl"
:entity-id="mergeRequest.author.id"
:entity-name="mergeRequest.author.name"
:size="16"
class="gl-mr-2"
/>
<span>{{ mergeRequest.author.name }}</span>
</gl-avatar-link>
</span>
<div>
<compliance-framework-label
v-if="complianceFramework"
:name="complianceFramework.name"
:color="complianceFramework.color"
:description="complianceFramework.description"
/>
</div>
</div>
</template>
<script>
import { isEmpty } from 'lodash';
import Approval from './statuses/approval.vue';
import Pipeline from './statuses/pipeline.vue';
export default {
components: {
Approval,
Pipeline,
},
props: {
status: {
type: Object,
required: true,
},
},
computed: {
hasData() {
return !isEmpty(this.status.data);
},
},
};
</script>
<template>
<div
class="gl-display-flex gl-align-items-center gl-justify-content-center gl-border-b-solid gl-border-b-1 gl-border-b-gray-100 gl-p-5"
>
<component :is="status.type" v-if="hasData" :status="status.data" />
</div>
</template>
<script>
import { GlLink, GlTooltipDirective } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
const APPROVAL_WARNING_ICON = 'success-with-warnings';
export default {
directives: {
GlTooltip: GlTooltipDirective,
},
components: {
CiIcon,
GlLink,
},
props: {
status: {
type: String,
required: true,
},
},
computed: {
tooltip() {
return this.$options.tooltips[this.status];
},
icon() {
return `status_${this.status}`;
},
group() {
if (this.status === 'warning') {
return APPROVAL_WARNING_ICON;
}
return this.status;
},
},
tooltips: {
success: s__('ApprovalStatusTooltip|Adheres to separation of duties'),
warning: s__('ApprovalStatusTooltip|At least one rule does not adhere to separation of duties'),
failed: s__('ApprovalStatusTooltip|Fails to adhere to separation of duties'),
},
docLink: helpPagePath('user/compliance/compliance_report/index', {
anchor: 'separation-of-duties',
}),
};
</script>
<template>
<gl-link :href="$options.docLink">
<ci-icon v-gl-tooltip.left="tooltip" class="gl-display-flex" :status="{ icon, group }" />
</gl-link>
</template>
<script>
import { GlLink, GlTooltipDirective } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
export default {
directives: {
GlTooltip: GlTooltipDirective,
},
components: {
CiIcon,
GlLink,
},
props: {
status: {
type: Object,
required: true,
},
},
computed: {
pipelineCiStatus() {
return { ...this.status, group: this.status.group || this.status.label };
},
pipelineTitle() {
return sprintf(s__('PipelineStatusTooltip|Pipeline: %{ci_status}'), {
ci_status: this.status.tooltip,
});
},
},
};
</script>
<template>
<gl-link :href="pipelineCiStatus.details_path">
<ci-icon v-gl-tooltip.left="pipelineTitle" class="gl-display-flex" :status="pipelineCiStatus" />
</gl-link>
</template>
<script>
export default {
props: {
heading: {
type: String,
required: true,
},
},
};
</script>
<template>
<p class="gl-text-gray-500 gl-border-b-solid gl-border-b-2 gl-border-b-gray-100 gl-mb-0 gl-p-5">
{{ heading }}
</p>
</template>
<script>
import { GlPagination } from '@gitlab/ui';
import { getParameterValues, setUrlParams } from '~/lib/utils/url_utility';
export default {
components: {
GlPagination,
},
props: {
isLastPage: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
page: parseInt(getParameterValues('page')[0], 10) || 1,
};
},
computed: {
isOnlyPage() {
return this.isLastPage && this.page === 1;
},
prevPage() {
return this.page > 1 ? this.page - 1 : null;
},
nextPage() {
return !this.isLastPage ? this.page + 1 : null;
},
},
methods: {
generateLink(page) {
return setUrlParams({ page });
},
},
};
</script>
<template>
<gl-pagination
v-if="!isOnlyPage"
v-model="page"
:prev-page="prevPage"
:next-page="nextPage"
:link-gen="generateLink"
align="center"
class="w-100"
/>
</template>
import { s__ } from '~/locale';
export const PRESENTABLE_APPROVERS_LIMIT = 2;
export const COMPLIANCE_TAB_COOKIE_KEY = 'compliance_dashboard_tabs';
export const INPUT_DEBOUNCE = 500;
export const CUSTODY_REPORT_PARAMETER = 'commit_sha';
......
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { convertToGraphQLIds } from '~/graphql_shared/utils';
import { TYPE_PROJECT } from '~/graphql_shared/constants';
import { formatDate, getDateInPast, pikadayToString } from '~/lib/utils/datetime_utility';
......@@ -6,20 +5,6 @@ import { ISO_SHORT_FORMAT } from '~/vue_shared/constants';
import { queryToObject } from '~/lib/utils/url_utility';
import { CURRENT_DATE } from '../audit_events/constants';
export const mapDashboardToDrawerData = (mergeRequest) => ({
id: mergeRequest.id,
mergeRequest: {
...convertObjectPropsToCamelCase(mergeRequest, { deep: true }),
webUrl: mergeRequest.path,
},
project: {
...convertObjectPropsToCamelCase(mergeRequest.project, { deep: true }),
complianceFramework: convertObjectPropsToCamelCase(
mergeRequest.compliance_management_framework,
),
},
});
export const convertProjectIdsToGraphQl = (projectIds) =>
convertToGraphQLIds(
TYPE_PROJECT,
......
......@@ -11,21 +11,5 @@ class Groups::Security::ComplianceDashboardsController < Groups::ApplicationCont
feature_category :compliance_management
def show
@last_page = paginated_merge_requests.last_page?
@merge_requests = serialize(paginated_merge_requests)
end
private
def paginated_merge_requests
@paginated_merge_requests ||= begin
merge_requests = MergeRequestsComplianceFinder.new(current_user, { group_id: @group.id }).execute
merge_requests.page(params[:page])
end
end
def serialize(merge_requests)
MergeRequestSerializer.new(current_user: current_user).represent(merge_requests, serializer: 'compliance_dashboard')
end
def show; end
end
# frozen_string_literal: true
#
# Used to filter MergeRequests collections for compliance dashboard
#
# Arguments:
# current_user - which user calls a class
# params:
# group_id: integer
# preloads: array of associations to preload
#
class MergeRequestsComplianceFinder < MergeRequestsFinder
def execute
# rubocop: disable CodeReuse/ActiveRecord
# This lateral query is used to get the single, latest
# "MR merged" event PER project.
lateral = Event
.select(:created_at, :target_id)
.where('projects.id = project_id')
.merged_action
.recent
.limit(1)
.to_sql
query = projects_in_group
.joins("JOIN LATERAL (#{lateral}) events ON true")
.order('events.created_at DESC')
.select('events.target_id as target_id') # The `target_id` of the `events` are the MR ids.
ordered_events_cte = Gitlab::SQL::CTE.new(:ordered_events_cte, query)
MergeRequest
.with(ordered_events_cte.to_arel)
.joins(inner_join_ordered_events_table(ordered_events_cte))
.order(Arel.sql('array_position(ARRAY(SELECT target_id FROM ordered_events_cte), merge_requests.id)'))
.preload(preloads)
# rubocop: enable CodeReuse/ActiveRecord
end
private
def inner_join_ordered_events_table(ordered_events_cte)
merge_requests_table = MergeRequest.arel_table
merge_requests_table
.join(ordered_events_cte.table, Arel::Nodes::InnerJoin)
.on(merge_requests_table[:id].eq(ordered_events_cte.table[:target_id]))
.join_sources
end
def projects_in_group
params.find_group_projects
end
def params
finder_options = {
include_subgroups: true,
attempt_group_search_optimizations: true
}
super.merge(finder_options)
end
def preloads
[
:author,
:approved_by_users,
:metrics,
source_project: :route,
target_project: [:namespace, :compliance_management_framework],
head_pipeline: [project: :project_feature]
]
end
end
# frozen_string_literal: true
module EE
module MergeRequestSerializer
extend ::Gitlab::Utils::Override
override :represent
def represent(merge_request, opts = {}, entity = nil)
entity ||=
case opts[:serializer]
when 'compliance_dashboard'
MergeRequestComplianceEntity
end
super(merge_request, opts, entity)
end
end
end
# frozen_string_literal: true
class MergeRequestComplianceEntity < Grape::Entity
include MergeRequestMetricsHelper
include RequestAwareEntity
SUCCESS_APPROVAL_STATUS = :success
WARNING_APPROVAL_STATUS = :warning
FAILED_APPROVAL_STATUS = :failed
expose :id
expose :title
expose :merged_at
expose :milestone
expose :path do |merge_request|
merge_request_path(merge_request)
end
expose :issuable_reference do |merge_request|
merge_request.to_reference(merge_request.project.group)
end
expose :reference do |merge_request|
merge_request.to_reference
end
expose :project do |merge_request|
{
avatar_url: merge_request.project.avatar_url,
name: merge_request.project.name,
web_url: merge_request.project.web_url
}
end
expose :author, using: API::Entities::UserBasic
expose :approved_by_users, using: API::Entities::UserBasic
expose :committers, using: API::Entities::UserBasic
expose :participants, using: API::Entities::UserBasic
expose :merged_by, using: API::Entities::UserBasic
expose :pipeline_status, if: -> (*) { can_read_pipeline? }, with: DetailedStatusEntity
expose :approval_status
expose :target_branch
expose :target_branch_uri, if: -> (merge_request) { merge_request.target_branch_exists? }
expose :source_branch
expose :source_branch_uri, if: -> (merge_request) { merge_request.source_branch_exists? }
expose :compliance_management_framework
private
alias_method :merge_request, :object
def can_read_pipeline?
can?(request.current_user, :read_pipeline, merge_request.head_pipeline)
end
def pipeline_status
merge_request.head_pipeline.detailed_status(request.current_user)
end
def approval_status
# All these checks should be false for this to pass as a success
# If any are true then there is a violation of the separation of duties
checks = [
merge_request.authors_can_approve?,
merge_request.committers_can_approve?,
merge_request.approvals_required < 2
]
return FAILED_APPROVAL_STATUS if checks.all?
return WARNING_APPROVAL_STATUS if checks.any?
SUCCESS_APPROVAL_STATUS
end
def merged_by
build_metrics(merge_request).merged_by
end
def target_branch_uri
project_ref_path(merge_request.project, merge_request.target_branch)
end
def source_branch_uri
project_ref_path(merge_request.project, merge_request.source_branch)
end
def compliance_management_framework
merge_request.project&.compliance_management_framework
end
end
- breadcrumb_title _("Compliance report")
- page_title _("Compliance report")
#js-compliance-report{ data: { merge_requests: @merge_requests.to_json,
is_last_page: @last_page.to_json,
empty_state_svg_path: image_path('illustrations/merge_requests.svg'),
merge_commits_csv_export_path: group_security_merge_commit_reports_path(@group, format: :csv),
group_path: @group.full_path } }
#js-compliance-report{ data: { merge_commits_csv_export_path: group_security_merge_commit_reports_path(@group, format: :csv), group_path: @group.full_path } }
......@@ -25,29 +25,6 @@ RSpec.describe Groups::Security::ComplianceDashboardsController do
it { is_expected.to have_gitlab_http_status(:success) }
context 'when there are no merge requests' do
it 'does not receive merge request collection' do
subject
expect(assigns(:merge_requests)).to be_empty
end
end
context 'when there are merge requests' do
let(:project) { create(:project, namespace: group) }
let(:mr_1) { create(:merge_request, source_project: project, state: :merged) }
let(:mr_2) { create(:merge_request, source_project: project, source_branch: 'A', state: :merged) }
before do
create(:event, :merged, project: project, target: mr_1, author: user)
end
it 'receives merge requests collection' do
subject
expect(assigns(:merge_requests)).not_to be_empty
end
end
it_behaves_like 'tracking unique visits', :show do
let(:request_params) { { group_id: group.to_param } }
let(:target_id) { 'g_compliance_dashboard' }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe MergeRequestsComplianceFinder do
subject { described_class.new(current_user, search_params) }
let_it_be(:current_user) { create(:admin) }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, namespace: group) }
let_it_be(:project_2) { create(:project, namespace: group) }
let_it_be(:search_params) { { group_id: group.id } }
let(:mr_1) { create(:merge_request, source_project: project, state: :merged) }
let(:mr_2) { create(:merge_request, source_project: project_2, state: :merged) }
let(:mr_3) { create(:merge_request, source_project: project, source_branch: 'A', state: :merged) }
let(:mr_4) { create(:merge_request, source_project: project_2, source_branch: 'A', state: :merged) }
before do
create(:event, :merged, project: project_2, target: mr_4, author: current_user, created_at: 50.minutes.ago)
create(:event, :merged, project: project_2, target: mr_2, author: current_user, created_at: 40.minutes.ago)
create(:event, :merged, project: project, target: mr_3, author: current_user, created_at: 30.minutes.ago)
create(:event, :merged, project: project, target: mr_1, author: current_user, created_at: 20.minutes.ago)
end
context 'when there are merge requests from projects in group' do
it 'shows only most recent Merge Request from each project' do
expect(subject.execute).to contain_exactly(mr_1, mr_2)
end
context 'when there are merge requests from projects in group and subgroups' do
let(:subgroup) { create(:group, parent: group) }
let(:sub_project) { create(:project, namespace: subgroup) }
let(:mr_5) { create(:merge_request, source_project: sub_project, state: :merged) }
let(:mr_6) { create(:merge_request, source_project: sub_project, state: :merged) }
before do
create(:event, :merged, project: sub_project, target: mr_6, author: current_user, created_at: 30.minutes.ago)
create(:event, :merged, project: sub_project, target: mr_5, author: current_user, created_at: 10.minutes.ago)
end
it 'shows Merge Requests from most recent to least recent' do
expect(subject.execute).to eq([mr_5, mr_1, mr_2])
end
end
end
end
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ComplianceDashboard component when there are merge requests and the show tabs cookie is true matches the snapshot 1`] = `
<div
class="compliance-dashboard"
>
<header>
<div
class="gl-mt-5 d-flex"
>
<h4
class="gl-flex-grow-1 gl-my-0"
>
Compliance report
</h4>
<merge-commits-export-button-stub
mergecommitscsvexportpath="/csv"
/>
</div>
<p>
Here you will find recent merge request activity
</p>
</header>
<gl-tabs-stub
queryparamname="tab"
value="0"
>
<b-tab-stub
tag="div"
title=""
titlelinkclass="gl-tab-nav-item"
>
<merge-requests-grid-stub
mergerequests="[object Object],[object Object]"
/>
<span>
Merge Requests
</span>
</b-tab-stub>
</gl-tabs-stub>
<merge-request-drawer-stub
mergerequest="[object Object]"
project="[object Object]"
z-index="252"
/>
</div>
`;
exports[`ComplianceDashboard component when there are merge requests matches the snapshot 1`] = `
<div
class="compliance-dashboard"
>
<header>
<div
class="gl-mt-5 d-flex"
>
<h4
class="gl-flex-grow-1 gl-my-0"
>
Compliance report
</h4>
<merge-commits-export-button-stub
mergecommitscsvexportpath="/csv"
/>
</div>
<p>
Here you will find recent merge request activity
</p>
</header>
<merge-requests-grid-stub
mergerequests="[object Object],[object Object]"
/>
<merge-request-drawer-stub
mergerequest="[object Object]"
project="[object Object]"
z-index="252"
/>
</div>
`;
exports[`ComplianceDashboard component when there are no merge requests matches the snapshot 1`] = `
<empty-state-stub
imagepath="empty.svg"
/>
`;
import { GlTabs, GlTab } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Cookies from 'js-cookie';
import { nextTick } from 'vue';
import ComplianceDashboard from 'ee/compliance_dashboard/components/dashboard.vue';
import MergeRequestDrawer from 'ee/compliance_dashboard/components/drawer.vue';
import MergeRequestGrid from 'ee/compliance_dashboard/components/merge_requests/grid.vue';
import MergeCommitsExportButton from 'ee/compliance_dashboard/components/shared/merge_commits_export_button.vue';
import { COMPLIANCE_TAB_COOKIE_KEY } from 'ee/compliance_dashboard/constants';
import { mapDashboardToDrawerData } from 'ee/compliance_dashboard/utils';
import { createMergeRequests } from '../mock_data';
describe('ComplianceDashboard component', () => {
let wrapper;
const isLastPage = false;
const mergeRequests = createMergeRequests({ count: 2 });
const mergeCommitsCsvExportPath = '/csv';
const findMergeRequestsGrid = () => wrapper.findComponent(MergeRequestGrid);
const findMergeRequestsDrawer = () => wrapper.findComponent(MergeRequestDrawer);
const findMergeCommitsExportButton = () => wrapper.findComponent(MergeCommitsExportButton);
const findDashboardTabs = () => wrapper.findComponent(GlTabs);
const createComponent = (props = {}) => {
return shallowMount(ComplianceDashboard, {
propsData: {
mergeRequests,
isLastPage,
mergeCommitsCsvExportPath,
emptyStateSvgPath: 'empty.svg',
...props,
},
stubs: {
GlTab,
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('when there are merge requests', () => {
beforeEach(() => {
Cookies.set(COMPLIANCE_TAB_COOKIE_KEY, false);
wrapper = createComponent();
});
afterEach(() => {
Cookies.remove(COMPLIANCE_TAB_COOKIE_KEY);
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('renders the merge requests', () => {
expect(findMergeRequestsGrid().exists()).toBe(true);
});
it('sets the MergeRequestGrid properties', () => {
expect(findMergeRequestsGrid().props('mergeRequests')).toBe(mergeRequests);
expect(findMergeRequestsGrid().props('isLastPage')).toBe(isLastPage);
});
it('renders the merge commit export button', () => {
expect(findMergeCommitsExportButton().exists()).toBe(true);
});
describe('and the show tabs cookie is true', () => {
beforeEach(() => {
Cookies.set(COMPLIANCE_TAB_COOKIE_KEY, true);
wrapper = createComponent();
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('renders the dashboard tabs', () => {
expect(findDashboardTabs().exists()).toBe(true);
});
});
});
describe('when there are no merge requests', () => {
beforeEach(() => {
wrapper = createComponent({ mergeRequests: [] });
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('does not render merge requests', () => {
expect(findMergeRequestsGrid().exists()).toBe(false);
});
});
describe('when the merge commit export link is not present', () => {
beforeEach(() => {
wrapper = createComponent({ mergeCommitsCsvExportPath: '' });
});
it('does not render the merge commit export button', async () => {
await nextTick();
expect(findMergeCommitsExportButton().exists()).toBe(false);
});
});
describe('with the merge request drawer', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('opens the drawer', async () => {
const drawerData = mapDashboardToDrawerData(mergeRequests[0]);
await findMergeRequestsGrid().vm.$emit('toggleDrawer', mergeRequests[0]);
expect(findMergeRequestsDrawer().props('showDrawer')).toBe(true);
expect(findMergeRequestsDrawer().props('mergeRequest')).toStrictEqual(
drawerData.mergeRequest,
);
expect(findMergeRequestsDrawer().props('project')).toStrictEqual(drawerData.project);
});
it('closes the drawer via the drawer close event', async () => {
await findMergeRequestsDrawer().vm.$emit('close');
expect(findMergeRequestsDrawer().props('showDrawer')).toBe(false);
expect(findMergeRequestsDrawer().props('mergeRequest')).toStrictEqual({});
expect(findMergeRequestsDrawer().props('project')).toStrictEqual({});
});
it('closes the drawer via the grid toggle event', async () => {
await findMergeRequestsGrid().vm.$emit('toggleDrawer', mergeRequests[0]);
await findMergeRequestsGrid().vm.$emit('toggleDrawer', mergeRequests[0]);
expect(findMergeRequestsDrawer().props('showDrawer')).toBe(false);
expect(findMergeRequestsDrawer().props('mergeRequest')).toStrictEqual({});
expect(findMergeRequestsDrawer().props('project')).toStrictEqual({});
});
it('swaps the drawer when a new merge request is selected', async () => {
const drawerData = mapDashboardToDrawerData(mergeRequests[1]);
await findMergeRequestsGrid().vm.$emit('toggleDrawer', mergeRequests[0]);
await findMergeRequestsGrid().vm.$emit('toggleDrawer', mergeRequests[1]);
expect(findMergeRequestsDrawer().props('showDrawer')).toBe(true);
expect(findMergeRequestsDrawer().props('mergeRequest')).toStrictEqual(
drawerData.mergeRequest,
);
expect(findMergeRequestsDrawer().props('project')).toStrictEqual(drawerData.project);
});
});
});
import { GlEmptyState } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import EmptyState from 'ee/compliance_dashboard/components/empty_state.vue';
const IMAGE_PATH = 'empty.svg';
describe('EmptyState component', () => {
let wrapper;
const emptyStateProp = (prop) => wrapper.findComponent(GlEmptyState).props(prop);
const createComponent = (props = {}) => {
return shallowMount(EmptyState, {
propsData: {
imagePath: IMAGE_PATH,
...props,
},
});
};
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('behaviour', () => {
it('sets the empty SVG path', () => {
expect(emptyStateProp('svgPath')).toBe(IMAGE_PATH);
});
it('sets the description', () => {
expect(emptyStateProp('description')).toBe(
'The compliance report captures merged changes that violate compliance best practices.',
);
});
});
});
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MergeRequest component when there are approvers matches snapshot 1`] = `
<div
class="gl-display-flex gl-align-items-center gl-justify-content-end"
data-testid="approvers"
>
<span
class="gl-text-gray-500"
>
approved by:
</span>
<gl-avatars-inline-stub
avatars="[object Object]"
avatarsize="24"
badgesronlytext="-1 additional approvers"
badgetooltipprop="name"
class="gl-display-inline-flex gl-lg-display-none! gl-ml-3"
collapsed="true"
maxvisible="2"
/>
<gl-link-stub
class="gl-avatar-link gl-display-none gl-lg-display-inline-flex! gl-align-items-center gl-justify-content-end gl-ml-3 gl-text-gray-900 author-link js-user-link"
data-name="User 0"
data-user-id="0"
href="http://localhost:3000/user-0"
title="User 0"
>
<gl-avatar-stub
alt="avatar"
class="gl-mr-2"
entityid="0"
entityname="User 0"
shape="circle"
size="16"
src="https://0"
/>
<span>
User 0
</span>
</gl-link-stub>
<!---->
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MergeRequestsGrid component when initialized matches the snapshot 1`] = `
<div>
<div
class="dashboard-grid gl-display-grid gl-grid-tpl-rows-auto"
>
<grid-column-heading-stub
heading="Merge Request"
/>
<grid-column-heading-stub
class="gl-text-center"
heading="Approval Status"
/>
<grid-column-heading-stub
class="gl-text-center"
heading="Pipeline"
/>
<grid-column-heading-stub
class="gl-text-right"
heading="Updates"
/>
<div
class="dashboard-merge-request dashboard-grid gl-display-grid gl-grid-tpl-rows-auto gl-hover-bg-blue-50 gl-hover-text-decoration-none gl-hover-cursor-pointer"
data-testid="merge-request-drawer-toggle"
tabindex="0"
>
<div
data-testid="merge-request"
>
<a
data-testid="merge-request-link"
href=""
>
Merge request 0
</a>
</div>
<status-stub
status="[object Object]"
/>
<status-stub
status="[object Object]"
/>
<div
class="gl-text-right gl-border-b-solid gl-border-b-1 gl-border-b-gray-100 gl-p-5 gl-relative"
>
<approvers-stub
approvers=""
/>
<!---->
<time-ago-tooltip-stub
class="gl-text-gray-500"
cssclass=""
time="2020-01-01T00:00:00.000Z"
tooltipplacement="bottom"
/>
</div>
</div>
<div
class="dashboard-merge-request dashboard-grid gl-display-grid gl-grid-tpl-rows-auto gl-hover-bg-blue-50 gl-hover-text-decoration-none gl-hover-cursor-pointer"
data-testid="merge-request-drawer-toggle"
tabindex="0"
>
<div
data-testid="merge-request"
>
<a
data-testid="merge-request-link"
href=""
>
Merge request 1
</a>
</div>
<status-stub
status="[object Object]"
/>
<status-stub
status="[object Object]"
/>
<div
class="gl-text-right gl-border-b-solid gl-border-b-1 gl-border-b-gray-100 gl-p-5 gl-relative"
>
<approvers-stub
approvers=""
/>
<!---->
<time-ago-tooltip-stub
class="gl-text-gray-500"
cssclass=""
time="2020-01-01T00:00:00.000Z"
tooltipplacement="bottom"
/>
</div>
</div>
</div>
<pagination-stub
class="gl-mt-5"
/>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MergeRequest component when there is a merge request matches the snapshot 1`] = `
<div
class="gl-grid-col-start-1 gl-border-b-solid gl-border-b-1 gl-border-b-gray-100 gl-p-5"
data-testid="merge-request"
>
<div>
<a
class="gl-text-gray-900 gl-font-weight-bold"
href="/h5bp/html5-boilerplate/-/merge_requests/1"
>
Merge request 1
</a>
</div>
<span
class="gl-text-gray-500"
>
project!1
</span>
<span
class="issuable-authored gl-text-gray-500 gl-display-inline-flex gl-align-items-center"
>
- created by:
<gl-avatar-link-stub
class="gl-display-inline-flex gl-align-items-center gl-ml-3 gl-text-gray-900 author-link js-user-link"
data-name="User 1"
data-user-id="1"
href="http://localhost:3000/user-1"
title="User 1"
>
<gl-avatar-stub
alt="avatar"
class="gl-mr-2"
entityid="1"
entityname="User 1"
shape="circle"
size="16"
src="https://1"
/>
<span>
User 1
</span>
</gl-avatar-link-stub>
</span>
<div>
<compliance-framework-label-stub
color="#009966"
description="General Data Protection Regulation"
name="GDPR"
/>
</div>
</div>
`;
import { GlAvatarLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Approvers from 'ee/compliance_dashboard/components/merge_requests/approvers.vue';
import { PRESENTABLE_APPROVERS_LIMIT } from 'ee/compliance_dashboard/constants';
import { createApprovers } from '../../mock_data';
describe('MergeRequest component', () => {
let wrapper;
const findMessage = () => wrapper.find('[data-testid="approvers"]');
const findCounter = () => wrapper.find('.avatar-counter');
const findAvatarLinks = () => wrapper.findAllComponents(GlAvatarLink);
const createComponent = (approvers = []) => {
return shallowMount(Approvers, {
propsData: {
approvers,
},
stubs: {
GlAvatarLink,
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('when there are no approvers', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('displays the "no approvers" message', () => {
expect(findMessage().text()).toEqual('no approvers');
});
});
describe('when there are approvers', () => {
beforeEach(() => {
wrapper = createComponent(createApprovers(1));
});
it('matches snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
});
describe('when the amount of approvers matches the presentable limit', () => {
const approvers = createApprovers(PRESENTABLE_APPROVERS_LIMIT);
beforeEach(() => {
wrapper = createComponent(approvers);
});
it('does not display the additional approvers count', () => {
expect(findCounter().exists()).toEqual(false);
});
it(`displays ${PRESENTABLE_APPROVERS_LIMIT} user avatar links`, () => {
expect(findAvatarLinks().length).toEqual(PRESENTABLE_APPROVERS_LIMIT);
});
});
describe('when the amount of approvers is over the presentable limit', () => {
const additional = 1;
beforeEach(() => {
wrapper = createComponent(createApprovers(PRESENTABLE_APPROVERS_LIMIT + additional));
});
it(`displays only ${PRESENTABLE_APPROVERS_LIMIT} user avatar links`, () => {
expect(findAvatarLinks().length).toEqual(PRESENTABLE_APPROVERS_LIMIT);
});
it('displays additional approvers count', () => {
expect(findCounter().exists()).toEqual(true);
expect(findCounter().text()).toEqual(`+ ${additional}`);
});
});
});
import Approvers from 'ee/compliance_dashboard/components/merge_requests/approvers.vue';
import MergeRequestsGrid from 'ee/compliance_dashboard/components/merge_requests/grid.vue';
import Status from 'ee/compliance_dashboard/components/merge_requests/status.vue';
import BranchDetails from 'ee/compliance_dashboard/components/shared/branch_details.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { createMergeRequests, mergedAt } from '../../mock_data';
describe('MergeRequestsGrid component', () => {
let wrapper;
const findMergeRequestDrawerToggles = () =>
wrapper.findAllByTestId('merge-request-drawer-toggle');
const findMergeRequests = () => wrapper.findAllByTestId('merge-request');
const findMergeRequestLinks = () => wrapper.findAllByTestId('merge-request-link');
const findTime = () => wrapper.findComponent(TimeAgoTooltip);
const findStatuses = () => wrapper.findAllComponents(Status);
const findApprovers = () => wrapper.findComponent(Approvers);
const findBranchDetails = () => wrapper.findComponent(BranchDetails);
const createComponent = (mergeRequests = {}) => {
return shallowMountExtended(MergeRequestsGrid, {
propsData: {
mergeRequests,
isLastPage: false,
},
stubs: {
MergeRequest: {
props: { mergeRequest: Object },
template: `<div data-testid="merge-request"><a href="" data-testid="merge-request-link">{{ mergeRequest.title }}</a></div>`,
},
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('when initialized', () => {
beforeEach(() => {
wrapper = createComponent(createMergeRequests({ count: 2, props: {} }));
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('renders a list of merge requests', () => {
expect(findMergeRequests()).toHaveLength(2);
});
it('renders the approvers list', () => {
expect(findApprovers().exists()).toBe(true);
});
it('renders the "merged at" time', () => {
expect(findTime().props('time')).toEqual(mergedAt());
});
});
describe('statuses', () => {
const mergeRequest = createMergeRequests({ count: 1 });
beforeEach(() => {
wrapper = createComponent(mergeRequest);
});
it('passes the correct props to the statuses', () => {
findStatuses().wrappers.forEach((status) => {
const { type, data } = status.props('status');
switch (type) {
case 'pipeline':
expect(data).toEqual(mergeRequest[0].pipeline_status);
break;
case 'approval':
expect(data).toEqual(mergeRequest[0].approval_status);
break;
default:
throw new Error('Unknown status type');
}
});
});
});
describe('branch details', () => {
it('does not render if there are no branch details', () => {
wrapper = createComponent(createMergeRequests({ count: 2, props: {} }));
expect(findBranchDetails().exists()).toBe(false);
});
it('renders if there are branch details', () => {
wrapper = createComponent(
createMergeRequests({
count: 2,
props: { target_branch: 'main', source_branch: 'feature' },
}),
);
expect(findBranchDetails().exists()).toBe(true);
});
});
describe.each(['click', 'keypress.enter'])('when the %s event is triggered', (event) => {
const mergeRequest = createMergeRequests({ count: 1 });
beforeEach(() => {
wrapper = createComponent(mergeRequest, true);
});
it('toggles the drawer when a merge request drawer toggle is the target', () => {
findMergeRequestDrawerToggles().at(0).trigger(event);
expect(wrapper.emitted('toggleDrawer')[0][0]).toStrictEqual(mergeRequest[0]);
});
it('does not toggle the drawer if an inner link is the target', () => {
findMergeRequestLinks().at(0).trigger(event);
expect(wrapper.emitted('toggleDrawer')).toBe(undefined);
});
});
});
import { GlAvatarLink, GlAvatar } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import MergeRequest from 'ee/compliance_dashboard/components/merge_requests/merge_request.vue';
import ComplianceFrameworkLabel from 'ee/vue_shared/components/compliance_framework_label/compliance_framework_label.vue';
import { complianceFramework } from 'ee_jest/vue_shared/components/compliance_framework_label/mock_data';
import { createMergeRequest } from '../../mock_data';
describe('MergeRequest component', () => {
let wrapper;
const findAuthorAvatarLink = () => wrapper.find('.issuable-authored').findComponent(GlAvatarLink);
const findComplianceFrameworkLabel = () => wrapper.findComponent(ComplianceFrameworkLabel);
const createComponent = (mergeRequest) => {
return shallowMount(MergeRequest, {
propsData: {
mergeRequest,
},
stubs: {
CiIcon: {
props: { status: Object },
template: `<div class="ci-icon">{{ status.group }}</div>`,
},
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('when there is a merge request', () => {
const mergeRequest = createMergeRequest({
props: {
compliance_management_framework: complianceFramework,
},
});
beforeEach(() => {
wrapper = createComponent(mergeRequest);
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('renders the title', () => {
expect(wrapper.text()).toContain(mergeRequest.title);
});
it('renders the issuable reference', () => {
expect(wrapper.text()).toContain(mergeRequest.issuable_reference);
});
it('renders the author avatar', () => {
expect(findAuthorAvatarLink().findComponent(GlAvatar).exists()).toEqual(true);
});
it('renders the author name', () => {
expect(findAuthorAvatarLink().text()).toEqual(mergeRequest.author.name);
});
it('renders the compliance framework label', () => {
const { color, description, name } = complianceFramework;
expect(findComplianceFrameworkLabel().props()).toStrictEqual({
color,
description,
name,
});
});
});
describe('when there is a merge request without a compliance framework', () => {
const mergeRequest = createMergeRequest();
beforeEach(() => {
wrapper = createComponent(mergeRequest);
});
it('does not render the compliance framework label', () => {
expect(findComplianceFrameworkLabel().exists()).toBe(false);
});
});
});
import { shallowMount } from '@vue/test-utils';
import Status from 'ee/compliance_dashboard/components/merge_requests/status.vue';
import Approval from 'ee/compliance_dashboard/components/merge_requests/statuses/approval.vue';
import Pipeline from 'ee/compliance_dashboard/components/merge_requests/statuses/pipeline.vue';
describe('Status component', () => {
let wrapper;
const createComponent = (status) => {
return shallowMount(Status, {
propsData: { status },
});
};
const checkStatusComponentExists = (status, exists) => {
switch (status.type) {
case 'approval':
return expect(wrapper.findComponent(Approval).exists()).toBe(exists);
case 'pipeline':
return expect(wrapper.findComponent(Pipeline).exists()).toBe(exists);
default:
throw new Error(`Unknown status type: ${status.type}`);
}
};
afterEach(() => {
wrapper.destroy();
});
describe('rendering', () => {
it.each`
type | data
${'approval'} | ${null}
${'approval'} | ${''}
${'pipeline'} | ${{}}
`('does not render if given the status $value', (status) => {
wrapper = createComponent(status);
checkStatusComponentExists(status, false);
});
it.each`
type | data
${'approval'} | ${'success'}
${'pipeline'} | ${{ group: 'warning' }}
`('renders if given the status $value', (status) => {
wrapper = createComponent(status);
checkStatusComponentExists(status, true);
});
});
});
import { GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Approval from 'ee/compliance_dashboard/components/merge_requests/statuses/approval.vue';
describe('ApprovalStatus component', () => {
let wrapper;
const findIcon = () => wrapper.find('.ci-icon');
const findLink = () => wrapper.findComponent(GlLink);
const createComponent = (status) => {
return shallowMount(Approval, {
propsData: { status },
stubs: {
CiIcon: {
props: { status: Object },
template: `<div class="ci-icon">{{ status.icon }}</div>`,
},
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('with an approval status', () => {
const approvalStatus = 'success';
beforeEach(() => {
wrapper = createComponent(approvalStatus);
});
it('links to the approval status', () => {
expect(findLink().attributes('href')).toBe(
'/help/user/compliance/compliance_report/index#separation-of-duties',
);
});
it('renders an icon with the approval status', () => {
expect(findIcon().text()).toEqual(`status_${approvalStatus}`);
});
describe.each`
status | icon | group | tooltip
${'success'} | ${'status_success'} | ${'success'} | ${'Adheres to separation of duties'}
${'warning'} | ${'status_warning'} | ${'success-with-warnings'} | ${'At least one rule does not adhere to separation of duties'}
${'failed'} | ${'status_failed'} | ${'failed'} | ${'Fails to adhere to separation of duties'}
`('returns the correct values for $status', ({ status, icon, group, tooltip }) => {
beforeEach(() => {
wrapper = createComponent(status);
});
it('returns the correct icon', () => {
expect(wrapper.vm.icon).toEqual(icon);
});
it('returns the correct group', () => {
expect(wrapper.vm.group).toEqual(group);
});
it('returns the correct tooltip', () => {
expect(wrapper.vm.tooltip).toEqual(tooltip);
});
});
});
});
import { GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Pipeline from 'ee/compliance_dashboard/components/merge_requests/statuses/pipeline.vue';
import { createPipelineStatus } from '../../../mock_data';
describe('Pipeline component', () => {
let wrapper;
const findCiIcon = () => wrapper.find('.ci-icon');
const findCiLink = () => wrapper.findComponent(GlLink);
const createComponent = (status) => {
return shallowMount(Pipeline, {
propsData: { status },
stubs: {
CiIcon: {
props: { status: Object },
template: `<div class="ci-icon">{{ status.group }}</div>`,
},
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('with a pipeline', () => {
const pipeline = createPipelineStatus('success');
beforeEach(() => {
wrapper = createComponent(pipeline);
});
it('links to the pipeline', () => {
expect(findCiLink().attributes('href')).toEqual(pipeline.details_path);
});
it('renders a CI icon with the pipeline status', () => {
expect(findCiIcon().text()).toEqual(pipeline.group);
});
});
});
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GridColumnHeading component behaviour matches the screenshot 1`] = `
<p
class="gl-text-gray-500 gl-border-b-solid gl-border-b-2 gl-border-b-gray-100 gl-mb-0 gl-p-5"
>
Test heading
</p>
`;
import { shallowMount } from '@vue/test-utils';
import GridColumnHeading from 'ee/compliance_dashboard/components/shared/grid_column_heading.vue';
describe('GridColumnHeading component', () => {
let wrapper;
const createComponent = (heading) => {
return shallowMount(GridColumnHeading, {
propsData: { heading },
});
};
afterEach(() => {
wrapper.destroy();
});
describe('behaviour', () => {
beforeEach(() => {
wrapper = createComponent('Test heading');
});
it('matches the screenshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('has the the heading text', () => {
expect(wrapper.text()).toEqual('Test heading');
});
});
});
import { GlPagination } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Pagination from 'ee/compliance_dashboard/components/shared/pagination.vue';
import setWindowLocation from 'helpers/set_window_location_helper';
describe('Pagination component', () => {
let wrapper;
const origin = 'https://localhost';
const findGlPagination = () => wrapper.findComponent(GlPagination);
const getLink = (query) => wrapper.find(query).element.getAttribute('href');
const findPrevPageLink = () => getLink('a.prev-page-item');
const findNextPageLink = () => getLink('a.next-page-item');
const createComponent = (isLastPage = false) => {
return shallowMount(Pagination, {
propsData: {
isLastPage,
},
stubs: {
GlPagination,
},
});
};
beforeEach(() => {
setWindowLocation(origin);
});
afterEach(() => {
wrapper.destroy();
});
describe('when initialized', () => {
beforeEach(() => {
setWindowLocation('?page=2');
wrapper = createComponent();
});
it('should get the page number from the URL', () => {
expect(findGlPagination().props().value).toBe(2);
});
it('should create a link to the previous page', () => {
expect(findPrevPageLink()).toBe(`${origin}/?page=1`);
});
it('should create a link to the next page', () => {
expect(findNextPageLink()).toBe(`${origin}/?page=3`);
});
});
describe('when on last page', () => {
beforeEach(() => {
setWindowLocation('?page=2');
wrapper = createComponent(true);
});
it('should not have a nextPage if on the last page', () => {
expect(findGlPagination().props().nextPage).toBe(null);
});
});
describe('when there is only one page', () => {
beforeEach(() => {
setWindowLocation('?page=1');
wrapper = createComponent(true);
});
it('should not display if there is only one page of results', () => {
expect(findGlPagination().exists()).toEqual(false);
});
});
});
import { GlAvatar, GlAvatarLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import UserAvatar from 'ee/compliance_dashboard/components/shared/user_avatar.vue';
import { DRAWER_AVATAR_SIZE } from 'ee/compliance_dashboard/constants';
import { createUser } from '../../mock_data';
import { mapViolations } from 'ee/compliance_dashboard/graphql/mappers';
import { createComplianceViolation } from '../../mock_data';
describe('UserAvatar component', () => {
let wrapper;
const user = convertObjectPropsToCamelCase(createUser(1));
const { violatingUser: user } = mapViolations([createComplianceViolation()])[0];
const findAvatar = () => wrapper.findComponent(GlAvatar);
const findAvatarLink = () => wrapper.findComponent(GlAvatarLink);
......
import { shallowMount } from '@vue/test-utils';
import ViolationReason from 'ee/compliance_dashboard/components/violations/reason.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import UserAvatar from 'ee/compliance_dashboard/components/shared/user_avatar.vue';
import { MERGE_REQUEST_VIOLATION_MESSAGES } from 'ee/compliance_dashboard/constants';
import { createUser } from '../../mock_data';
import { mapViolations } from 'ee/compliance_dashboard/graphql/mappers';
import { createComplianceViolation } from '../../mock_data';
describe('ViolationReason component', () => {
let wrapper;
const user = convertObjectPropsToCamelCase(createUser(1));
const { violatingUser: user } = mapViolations([createComplianceViolation()])[0];
const reasons = Object.keys(MERGE_REQUEST_VIOLATION_MESSAGES);
const findAvatar = () => wrapper.findComponent(UserAvatar);
......
export const createUser = (id) => ({
id,
id: `gid://gitlab/User/${id}`,
avatarUrl: `https://${id}`,
name: `User ${id}`,
state: 'active',
username: `user-${id}`,
webUrl: `http://localhost:3000/user-${id}`,
__typename: 'UserCore',
});
export const mergedAt = () => {
const date = new Date();
date.setFullYear(2020, 0, 1);
date.setHours(0, 0, 0, 0);
return date.toISOString();
};
export const createPipelineStatus = (status) => ({
details_path: '/h5bp/html5-boilerplate/-/pipelines/58',
favicon: '',
group: status,
has_details: true,
icon: `status_${status}`,
illustration: null,
label: status,
text: status,
tooltip: status,
});
export const createMergeRequest = ({ id = 1, props } = {}) => {
const mergeRequest = {
id,
approved_by_users: [],
committers: [],
participants: [],
issuable_reference: 'project!1',
reference: '!1',
merged_at: mergedAt(),
milestone: null,
path: `/h5bp/html5-boilerplate/-/merge_requests/${id}`,
title: `Merge request ${id}`,
author: createUser(id),
merged_by: createUser(id),
pipeline_status: createPipelineStatus('success'),
approval_status: 'success',
project: {
avatar_url: '/foo/bar.png',
name: 'Foo',
web_url: 'https://foo.com/project',
},
};
return { ...mergeRequest, ...props };
};
export const createApprovers = (count) => {
return Array(count)
.fill(null)
.map((_, id) => createUser(id));
};
export const createMergeRequests = ({ count = 1, props = {} } = {}) => {
return Array(count)
.fill(null)
.map((_, id) =>
createMergeRequest({
id,
props,
}),
);
.map((_, id) => ({ ...createUser(id), id }));
};
export const createDefaultProjects = (count) => {
......@@ -98,49 +40,24 @@ export const createComplianceViolation = (id) => ({
id: `gid://gitlab/MergeRequests::ComplianceViolation/${id}`,
severityLevel: 'HIGH',
reason: 'APPROVED_BY_COMMITTER',
violatingUser: {
id: 'gid://gitlab/User/21',
name: 'Miranda Friesen',
username: 'karren.medhurst',
avatarUrl: 'https://www.gravatar.com/avatar/9102aef461ba77d0fa0f37daffb834ac?s=80&d=identicon',
webUrl: 'http://gdk.test:3000/karren.medhurst',
__typename: 'UserCore',
},
violatingUser: createUser(1),
mergeRequest: {
id: `gid://gitlab/MergeRequest/1`,
title: `Merge request 1`,
mergedAt: '2022-03-06T16:39:12Z',
webUrl: 'http://gdk.test:3000/gitlab-org/gitlab-shell/-/merge_requests/56',
author: {
id: 'gid://gitlab/User/1',
name: 'Administrator',
username: 'root',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
webUrl: 'http://gdk.test:3000/root',
__typename: 'UserCore',
},
mergeUser: null,
author: createUser(2),
mergeUser: createUser(1),
committers: {
nodes: [],
nodes: [createUser(1)],
__typename: 'UserCoreConnection',
},
participants: {
nodes: [
{
id: 'gid://gitlab/User/1',
name: 'Administrator',
username: 'root',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
webUrl: 'http://gdk.test:3000/root',
__typename: 'UserCore',
},
],
nodes: [createUser(1), createUser(2)],
__typename: 'UserCoreConnection',
},
approvedBy: {
nodes: [],
nodes: [createUser(1)],
__typename: 'UserCoreConnection',
},
ref: '!56',
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe MergeRequestSerializer do
let_it_be(:user) { create(:user) }
let_it_be(:resource) { create(:merge_request, :merged, description: "Description") }
let(:json_entity) do
described_class.new(current_user: user)
.represent(resource, serializer: serializer)
.with_indifferent_access
end
context 'compliance_dashboard merge request serialization' do
let(:serializer) { 'compliance_dashboard' }
it 'includes compliance_dashboard attributes' do
expect(json_entity).to include(
:id, :title, :merged_at, :milestone, :path, :issuable_reference, :approved_by_users
)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe MergeRequestComplianceEntity do
include Gitlab::Routing
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
let_it_be(:merge_request) { create(:merge_request, :merged) }
let(:request) { double('request', current_user: user, project: project) }
let(:entity) { described_class.new(merge_request.reload, request: request) }
describe '.as_json' do
subject { entity.as_json }
it 'includes merge request attributes for compliance' do
expect(subject).to include(
:id,
:title,
:merged_at,
:milestone,
:path,
:issuable_reference,
:reference,
:author,
:approved_by_users,
:committers,
:participants,
:merged_by,
:approval_status,
:target_branch,
:target_branch_uri,
:source_branch,
:source_branch_uri,
:compliance_management_framework,
:project
)
end
it 'builds the merge request metrics' do
expect_any_instance_of(MergeRequestMetricsHelper) do |instance|
expect(instance).to receive(:build_metrics).with(merge_request)
end
subject
end
describe 'with an approver' do
let_it_be(:approver) { create(:user) }
let_it_be(:approval) { create :approval, merge_request: merge_request, user: approver }
before_all do
project.add_developer(approver)
end
it 'includes only set of approver details' do
expect(subject[:approved_by_users].count).to eq(1)
end
it 'includes approver user details' do
expect(subject[:approved_by_users][0][:id]).to eq(approver.id)
end
end
describe 'with a head pipeline' do
let_it_be(:pipeline) { create(:ci_empty_pipeline, status: :success, project: project, head_pipeline_of: merge_request) }
describe 'and the user cannot read the pipeline' do
it 'does not include pipeline status attribute' do
expect(subject).not_to have_key(:pipeline_status)
end
end
describe 'and the user can read the pipeline' do
before do
project.add_developer(user)
end
it 'includes pipeline status attribute' do
expect(subject).to have_key(:pipeline_status)
end
end
end
context 'with an approval status' do
let_it_be(:committers_approval_enabled) { false }
let_it_be(:authors_approval_enabled) { false }
let_it_be(:approvals_required) { 2 }
shared_examples 'the approval status' do
before do
allow(merge_request).to receive(:authors_can_approve?).and_return(authors_approval_enabled)
allow(merge_request).to receive(:committers_can_approve?).and_return(committers_approval_enabled)
allow(merge_request).to receive(:approvals_required).and_return(approvals_required)
end
it 'is correct' do
expect(subject[:approval_status]).to eq(status)
end
end
context 'all approval checks pass' do
let_it_be(:status) { :success }
it_behaves_like 'the approval status'
end
context 'only some of the approval checks pass' do
let_it_be(:authors_approval_enabled) { true }
let_it_be(:status) { :warning }
it_behaves_like 'the approval status'
end
context 'none of the approval checks pass' do
let_it_be(:committers_approval_enabled) { true }
let_it_be(:authors_approval_enabled) { true }
let_it_be(:approvals_required) { 0 }
let_it_be(:status) { :failed }
it_behaves_like 'the approval status'
end
end
end
end
......@@ -1276,9 +1276,6 @@ msgid_plural "+%d more"
msgstr[0] ""
msgstr[1] ""
msgid "+%{approvers} more approvers"
msgstr ""
msgid "+%{extra} more"
msgstr ""
......@@ -1596,9 +1593,6 @@ msgstr ""
msgid "A member of the abuse team will review your report as soon as possible."
msgstr ""
msgid "A merge request hasn't yet been merged"
msgstr ""
msgid "A new Auto DevOps pipeline has been created, go to the Pipelines page for details"
msgstr ""
......@@ -4514,9 +4508,6 @@ msgstr ""
msgid "Applying suggestions..."
msgstr ""
msgid "Approval Status"
msgstr ""
msgid "Approval rules"
msgstr ""
......@@ -4708,15 +4699,6 @@ msgstr ""
msgid "ApprovalSettings|This setting is configured in %{groupName} and can only be changed in the group settings by an administrator or group owner."
msgstr ""
msgid "ApprovalStatusTooltip|Adheres to separation of duties"
msgstr ""
msgid "ApprovalStatusTooltip|At least one rule does not adhere to separation of duties"
msgstr ""
msgid "ApprovalStatusTooltip|Fails to adhere to separation of duties"
msgstr ""
msgid "Approvals are optional."
msgstr ""
......@@ -9259,9 +9241,6 @@ msgstr ""
msgid "Compliance report"
msgstr ""
msgid "ComplianceDashboard|created by:"
msgstr ""
msgid "ComplianceFrameworks|Add framework"
msgstr ""
......@@ -18550,9 +18529,6 @@ msgstr ""
msgid "Helps reduce request volume for protected paths."
msgstr ""
msgid "Here you will find recent merge request activity"
msgstr ""
msgid "Hi %{username}!"
msgstr ""
......@@ -37528,9 +37504,6 @@ msgstr ""
msgid "The comparison view may be inaccurate due to merge conflicts."
msgstr ""
msgid "The compliance report captures merged changes that violate compliance best practices."
msgstr ""
msgid "The compliance report shows the merge request violations merged in protected environments."
msgstr ""
......@@ -40474,9 +40447,6 @@ msgstr ""
msgid "Updated date"
msgstr ""
msgid "Updates"
msgstr ""
msgid "Updating"
msgstr ""
......@@ -43960,9 +43930,6 @@ msgid_plural "approvals"
msgstr[0] ""
msgstr[1] ""
msgid "approved by: "
msgstr ""
msgid "archived"
msgstr ""
......@@ -44881,9 +44848,6 @@ msgid_plural "merge requests"
msgstr[0] ""
msgstr[1] ""
msgid "merged %{timeAgo}"
msgstr ""
msgid "metric_id must be unique across a project"
msgstr ""
......@@ -45302,9 +45266,6 @@ msgstr ""
msgid "new merge request"
msgstr ""
msgid "no approvers"
msgstr ""
msgid "no expiration"
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment