Commit d730ba87 authored by Robert Hunt's avatar Robert Hunt

Create new compliance report component

- Added new default-off feature flag
- Created new compliance report app
- Added GraphQL to the app and optionally render it based upon the
feature flag
- Added new query and mapper
- Added resolver for the API endpoint that doesn't exist yet
- Added specs
parent 22613480
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import resolvers from './graphql/resolvers';
import ComplianceDashboard from './components/dashboard.vue';
import ComplianceReport from './components/report.vue';
export default () => {
const el = document.getElementById('js-compliance-report');
const { mergeRequests, emptyStateSvgPath, isLastPage, mergeCommitsCsvExportPath } = el.dataset;
if (gon.features.complianceViolationsReport) {
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(resolvers),
});
return new Vue({
el,
apolloProvider,
render: (createElement) =>
createElement(ComplianceReport, {
props: {
emptyStateSvgPath,
mergeCommitsCsvExportPath,
},
}),
});
}
return new Vue({
el,
render: (createElement) =>
......
<script>
import { GlAlert, GlLoadingIcon, GlTable } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { __ } from '~/locale';
import { thWidthClass } from '~/lib/utils/table_utility';
import complianceViolationsQuery from '../graphql/compliance_violations.query.graphql';
import { mapResponse } from '../graphql/mappers';
import EmptyState from './empty_state.vue';
import MergeCommitsExportButton from './merge_requests/merge_commits_export_button.vue';
export default {
name: 'ComplianceReport',
components: {
EmptyState,
GlAlert,
GlLoadingIcon,
GlTable,
MergeCommitsExportButton,
},
props: {
emptyStateSvgPath: {
type: String,
required: true,
},
mergeCommitsCsvExportPath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
queryError: false,
violations: [],
};
},
apollo: {
violations: {
query: complianceViolationsQuery,
variables() {
return {
fullPath: 'groups-path',
};
},
update(data) {
return mapResponse(data?.group?.mergeRequestViolations?.nodes || []);
},
error(e) {
Sentry.captureException(e);
this.queryError = true;
},
},
},
computed: {
isLoading() {
return this.$apollo.queries.violations.loading;
},
hasViolations() {
return this.violations.length > 0;
},
hasMergeCommitsCsvExportPath() {
return this.mergeCommitsCsvExportPath !== '';
},
},
fields: [
{
key: 'severity',
label: __('Severity'),
thClass: thWidthClass(10),
},
{
key: 'reason',
label: __('Violation'),
thClass: thWidthClass(25),
},
{
key: 'mergeRequest',
label: __('Merge request'),
thClass: thWidthClass(30),
},
{
key: 'mergedAt',
label: __('Date merged'),
thClass: thWidthClass(20),
},
],
i18n: {
heading: __('Compliance report'),
subheading: __(
'The compliance report shows the merge request violations merged in protected environments.',
),
queryError: __(
'Retrieving the compliance report failed. Please refresh the page and try again.',
),
},
};
</script>
<template>
<section>
<header class="gl-mb-6">
<div class="gl-mt-5 d-flex">
<h2 class="gl-flex-grow-1 gl-my-0">{{ $options.i18n.heading }}</h2>
<merge-commits-export-button
v-if="hasMergeCommitsCsvExportPath"
:merge-commits-csv-export-path="mergeCommitsCsvExportPath"
/>
</div>
<p class="gl-mt-5">{{ $options.i18n.subheading }}</p>
</header>
<gl-loading-icon v-if="isLoading" size="xl" />
<gl-alert
v-else-if="queryError"
variant="danger"
:dismissible="false"
:title="$options.i18n.queryError"
/>
<gl-table
v-else-if="hasViolations"
:fields="$options.fields"
:items="violations"
head-variant="white"
stacked="lg"
thead-class="gl-border-b-solid gl-border-b-1 gl-border-b-gray-100"
/>
<empty-state v-else :image-path="emptyStateSvgPath" />
</section>
</template>
query getComplianceViolations($fullPath: ID!) {
group(fullPath: $fullPath) @client {
id
mergeRequestViolations {
nodes {
id
severity
reason
violatingUser {
id
name
username
avatarUrl
webUrl
}
mergeRequest {
id
title
mergedAt
webUrl
author {
id
name
username
avatarUrl
webUrl
}
mergedBy {
id
name
username
avatarUrl
webUrl
}
committers {
nodes {
id
name
username
avatarUrl
webUrl
}
}
participants {
nodes {
id
name
username
avatarUrl
webUrl
}
}
approvedBy {
nodes {
id
name
username
avatarUrl
webUrl
}
}
ref: reference
fullRef: reference(full: true)
sourceBranch
sourceBranchExists
targetBranch
targetBranchExists
}
project {
id
avatarUrl
name
webUrl
complianceFrameworks {
nodes {
id
name
description
color
}
}
}
}
}
}
}
export const mapResponse = (response) => {
return response.map((item) => {
return {
...item,
mergedAt: item.mergeRequest.mergedAt,
};
});
};
// Note: This is mocking the server response until https://gitlab.com/gitlab-org/gitlab/-/issues/342897 is complete
// These values do not need to be translatable as it will remain behind a development feature flag
// until that issue is merged
/* eslint-disable @gitlab/require-i18n-strings */
export default {
Query: {
group() {
return {
__typename: 'Group',
id: 1,
mergeRequestViolations: {
__typename: 'MergeRequestViolations',
nodes: [
{
__typename: 'MergeRequestViolation',
id: 1,
severity: 1,
reason: 1,
violatingUser: {
__typename: 'Violator',
id: 50,
name: 'John Doe6',
username: 'user6',
avatarUrl:
'https://secure.gravatar.com/avatar/7ff9b8111da2e2109e7b66f37aa632cc?s=80&d=identicon',
webUrl: 'https://gdk.localhost:3443/user6',
},
mergeRequest: {
__typename: 'MergeRequest',
id: 24,
title:
'Officiis architecto voluptas ut sit qui qui quisquam sequi consectetur porro.',
mergedAt: '2021-11-25T11:56:52.215Z',
webUrl: 'https://gdk.localhost:3443/gitlab-org/gitlab-shell/-/merge_requests/2',
author: {
__typename: 'Author',
id: 50,
name: 'John Doe6',
username: 'user6',
avatarUrl:
'https://secure.gravatar.com/avatar/7ff9b8111da2e2109e7b66f37aa632cc?s=80&d=identicon',
webUrl: 'https://gdk.localhost:3443/user6',
},
mergedBy: {
__typename: 'MergedBy',
id: 50,
name: 'John Doe6',
username: 'user6',
avatarUrl:
'https://secure.gravatar.com/avatar/7ff9b8111da2e2109e7b66f37aa632cc?s=80&d=identicon',
webUrl: 'https://gdk.localhost:3443/user6',
},
committers: {
__typename: 'Committers',
nodes: [],
},
participants: {
__typename: 'Participants',
nodes: [
{
__typename: 'User',
id: 50,
name: 'John Doe6',
username: 'user6',
avatarUrl:
'https://secure.gravatar.com/avatar/7ff9b8111da2e2109e7b66f37aa632cc?s=80&d=identicon',
webUrl: 'https://gdk.localhost:3443/user6',
},
],
},
approvedBy: {
__typename: 'ApprovedBy',
nodes: [
{
__typename: 'User',
id: 49,
name: 'John Doe5',
username: 'user5',
avatarUrl:
'https://secure.gravatar.com/avatar/eaafc9b0f704edaf23cd5cf7727df560?s=80&d=identicon',
webUrl: 'https://gdk.localhost:3443/user5',
},
{
__typename: 'ApprovedBy',
id: 48,
name: 'John Doe4',
username: 'user4',
avatarUrl:
'https://secure.gravatar.com/avatar/5c8881fc63652c86cd4b23101268cf84?s=80&d=identicon',
webUrl: 'https://gdk.localhost:3443/user4',
},
],
},
ref: 'gitlab-shell!2',
fullRef: '!2',
sourceBranch: 'ut-171ad4e263',
sourceBranchExists: false,
targetBranch: 'master',
targetBranchExists: true,
},
project: {
__typename: 'Project',
id: 1,
avatarUrl: null,
name: 'Gitlab Shell',
webUrl: 'https://gdk.localhost:3443/gitlab-org/gitlab-shell',
complianceFrameworks: {
__typename: 'ComplianceFrameworks',
nodes: [
{
__typename: 'ComplianceFrameworks',
id: 1,
name: 'SOX',
description: 'A framework',
color: '#00FF00',
},
],
},
},
},
],
},
};
},
},
};
......@@ -6,6 +6,9 @@ class Groups::Security::ComplianceDashboardsController < Groups::ApplicationCont
layout 'group'
before_action :authorize_compliance_dashboard!
before_action do
push_frontend_feature_flag(:compliance_violations_report, @group, type: :development, default_enabled: :yaml)
end
track_redis_hll_event :show, name: 'g_compliance_dashboard'
......
---
name: compliance_violations_report
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/75015
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/346266
milestone: '14.6'
type: development
group: group::compliance
default_enabled: false
......@@ -13,48 +13,72 @@ RSpec.describe 'Compliance Dashboard', :js do
stub_licensed_features(group_level_compliance_dashboard: true)
group.add_owner(user)
sign_in(user)
visit group_security_compliance_dashboard_path(group)
end
context 'when there are no merge requests' do
it 'shows an empty state' do
expect(page).to have_selector('.empty-state')
# TODO: This should be updated to fully test both with and without the feature flag once closer to feature completion
# https://gitlab.com/gitlab-org/gitlab/-/issues/347302
context 'when compliance_violations_report feature is disabled' do
before do
stub_feature_flags(compliance_violations_report: false)
visit group_security_compliance_dashboard_path(group)
end
end
context 'when there are merge requests' do
let_it_be(:merge_request) { create(:merge_request, source_project: project, state: :merged, merge_commit_sha: 'b71a6483b96dc303b66fdcaa212d9db6b10591ce') }
let_it_be(:merge_request_2) { create(:merge_request, source_project: project_2, state: :merged, merge_commit_sha: '24327319d067f4101cd3edd36d023ab5e49a8579') }
before_all do
create(:event, :merged, project: project, target: merge_request, author: user, created_at: 10.minutes.ago)
create(:event, :merged, project: project_2, target: merge_request_2, author: user, created_at: 15.minutes.ago)
context 'when there are no merge requests' do
it 'shows an empty state' do
expect(page).to have_selector('.empty-state')
end
end
it 'shows merge requests with details' do
expect(page).to have_link(merge_request.title)
expect(page).to have_content('merged 10 minutes ago')
expect(page).to have_content('no approvers')
end
context 'when there are merge requests' do
let_it_be(:merge_request) { create(:merge_request, source_project: project, state: :merged, merge_commit_sha: 'b71a6483b96dc303b66fdcaa212d9db6b10591ce') }
let_it_be(:merge_request_2) { create(:merge_request, source_project: project_2, state: :merged, merge_commit_sha: '24327319d067f4101cd3edd36d023ab5e49a8579') }
before_all do
create(:event, :merged, project: project, target: merge_request, author: user, created_at: 10.minutes.ago)
create(:event, :merged, project: project_2, target: merge_request_2, author: user, created_at: 15.minutes.ago)
end
it 'shows merge requests with details' do
expect(page).to have_link(merge_request.title)
expect(page).to have_content('merged 10 minutes ago')
expect(page).to have_content('no approvers')
end
context 'chain of custody report' do
it 'exports a merge commit-specific CSV' do
find('.dropdown-toggle').click
context 'chain of custody report' do
it 'exports a merge commit-specific CSV' do
find('.dropdown-toggle').click
requests = inspect_requests do
page.within('.dropdown-menu') do
find('input[name="commit_sha"]').set(merge_request.merge_commit_sha)
find('button[type="submit"]').click
requests = inspect_requests do
page.within('.dropdown-menu') do
find('input[name="commit_sha"]').set(merge_request.merge_commit_sha)
find('button[type="submit"]').click
end
end
csv_request = requests.find { |req| req.url.match(%r{.csv}) }
expect(csv_request.response_headers['Content-Disposition']).to match(%r{.csv})
expect(csv_request.response_headers['Content-Type']).to eq("text/csv; charset=utf-8")
expect(csv_request.response_headers['Content-Transfer-Encoding']).to eq("binary")
expect(csv_request.body).to match(%r{#{merge_request.merge_commit_sha}})
expect(csv_request.body).not_to match(%r{#{merge_request_2.merge_commit_sha}})
end
end
end
end
csv_request = requests.find { |req| req.url.match(%r{.csv}) }
context 'when compliance_violations_report feature is enabled' do
before do
stub_feature_flags(compliance_violations_report: true)
visit group_security_compliance_dashboard_path(group)
end
expect(csv_request.response_headers['Content-Disposition']).to match(%r{.csv})
expect(csv_request.response_headers['Content-Type']).to eq("text/csv; charset=utf-8")
expect(csv_request.response_headers['Content-Transfer-Encoding']).to eq("binary")
expect(csv_request.body).to match(%r{#{merge_request.merge_commit_sha}})
expect(csv_request.body).not_to match(%r{#{merge_request_2.merge_commit_sha}})
it 'shows the violations report table', :aggregate_failures do
page.within('table') do
expect(page).to have_content 'Severity'
expect(page).to have_content 'Violation'
expect(page).to have_content 'Merge request'
expect(page).to have_content 'Date merged'
end
end
end
......
import { mapResponse } from 'ee/compliance_dashboard/graphql/mappers';
describe('Mappers', () => {
describe('mapResponse', () => {
it('returns an empty array if it receives one', () => {
expect(mapResponse([])).toStrictEqual([]);
});
it('returns the correct array if it receives data', () => {
expect(mapResponse([{ mergeRequest: { mergedAt: '1970-01-01' } }])).toStrictEqual([
{ mergeRequest: { mergedAt: '1970-01-01' }, mergedAt: '1970-01-01' },
]);
});
});
});
import { GlAlert, GlLoadingIcon, GlTable } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import Vue from 'vue';
import * as Sentry from '@sentry/browser';
import ComplianceReport from 'ee/compliance_dashboard/components/report.vue';
import EmptyState from 'ee/compliance_dashboard/components/empty_state.vue';
import MergeCommitsExportButton from 'ee/compliance_dashboard/components/merge_requests/merge_commits_export_button.vue';
import resolvers from 'ee/compliance_dashboard/graphql/resolvers';
import waitForPromises from 'helpers/wait_for_promises';
import createMockApollo from 'helpers/mock_apollo_helper';
Vue.use(VueApollo);
describe('ComplianceReport component', () => {
let wrapper;
let mockResolver;
const mergeCommitsCsvExportPath = '/csv';
const emptyStateSvgPath = 'empty.svg';
const mockGraphQlError = new Error('GraphQL networkError');
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findErrorMessage = () => wrapper.findComponent(GlAlert);
const findViolationsTable = () => wrapper.findComponent(GlTable);
const findEmptyState = () => wrapper.findComponent(EmptyState);
const findMergeCommitsExportButton = () => wrapper.findComponent(MergeCommitsExportButton);
const findTableHeaders = () => findViolationsTable().findAll('th');
const findTablesFirstRowData = () =>
findViolationsTable().findAll('tbody > tr').at(0).findAll('td');
function createMockApolloProvider() {
return createMockApollo([], { Query: { group: mockResolver } });
}
const createComponent = (mountFn = shallowMount, props = {}) => {
return mountFn(ComplianceReport, {
apolloProvider: createMockApolloProvider(),
propsData: {
mergeCommitsCsvExportPath,
emptyStateSvgPath,
...props,
},
stubs: {
GlTable: false,
},
});
};
afterEach(() => {
wrapper.destroy();
mockResolver = null;
});
describe('loading', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('renders the loading icon', () => {
expect(findLoadingIcon().exists()).toBe(true);
expect(findErrorMessage().exists()).toBe(false);
expect(findViolationsTable().exists()).toBe(false);
});
});
describe('when the query fails', () => {
beforeEach(() => {
jest.spyOn(Sentry, 'captureException');
mockResolver = jest.fn().mockRejectedValue(mockGraphQlError);
wrapper = createComponent();
});
it('renders the error message', async () => {
await waitForPromises();
expect(findLoadingIcon().exists()).toBe(false);
expect(findErrorMessage().exists()).toBe(true);
expect(findErrorMessage().props('title')).toBe(
'Retrieving the compliance report failed. Please refresh the page and try again.',
);
expect(Sentry.captureException.mock.calls[0][0].networkError).toBe(mockGraphQlError);
});
});
describe('when there are violations', () => {
beforeEach(() => {
mockResolver = resolvers.Query.group;
wrapper = createComponent(mount);
return waitForPromises();
});
it('renders the merge commit export button', () => {
expect(findMergeCommitsExportButton().exists()).toBe(true);
});
it('renders the violations table', async () => {
expect(findLoadingIcon().exists()).toBe(false);
expect(findErrorMessage().exists()).toBe(false);
expect(findViolationsTable().exists()).toBe(true);
});
it('has the correct table headers', () => {
const headerTexts = findTableHeaders().wrappers.map((h) => h.text());
expect(headerTexts).toStrictEqual(['Severity', 'Violation', 'Merge request', 'Date merged']);
});
// Note: This should be refactored as each table component is created
// Severity: https://gitlab.com/gitlab-org/gitlab/-/issues/342900
// Violation: https://gitlab.com/gitlab-org/gitlab/-/issues/342901
// Merge request and date merged: https://gitlab.com/gitlab-org/gitlab/-/issues/342902
it('has the correct first row data', () => {
const headerTexts = findTablesFirstRowData().wrappers.map((d) => d.text());
expect(headerTexts).toEqual(['1', '1', expect.anything(), '2021-11-25T11:56:52.215Z']);
});
});
describe('when there are no violations', () => {
beforeEach(() => {
mockResolver = () => ({
__typename: 'Group',
id: 1,
mergeRequestViolations: {
__typename: 'MergeRequestViolations',
nodes: [],
},
});
wrapper = createComponent();
return waitForPromises();
});
it('does not render the violations table', () => {
expect(findViolationsTable().exists()).toBe(false);
});
it('renders the empty state', () => {
expect(findEmptyState().exists()).toBe(true);
expect(findEmptyState().props('imagePath')).toBe(emptyStateSvgPath);
});
});
describe('when the merge commit export link is not present', () => {
beforeEach(() => {
wrapper = createComponent(shallowMount, { mergeCommitsCsvExportPath: '' });
});
it('does not render the merge commit export button', () => {
expect(findMergeCommitsExportButton().exists()).toBe(false);
});
});
});
......@@ -11087,6 +11087,9 @@ msgstr ""
msgid "Date"
msgstr ""
msgid "Date merged"
msgstr ""
msgid "Date picker"
msgstr ""
......@@ -29916,6 +29919,9 @@ msgstr ""
msgid "Resync"
msgstr ""
msgid "Retrieving the compliance report failed. Please refresh the page and try again."
msgstr ""
msgid "Retry"
msgstr ""
......@@ -34764,6 +34770,9 @@ 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 ""
msgid "The connection will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
msgstr ""
......@@ -38726,6 +38735,9 @@ msgstr ""
msgid "Viewing commit"
msgstr ""
msgid "Violation"
msgstr ""
msgid "Visibility"
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