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 Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils'; import { parseBoolean } from '~/lib/utils/common_utils';
import resolvers from './graphql/resolvers';
import ComplianceDashboard from './components/dashboard.vue'; import ComplianceDashboard from './components/dashboard.vue';
import ComplianceReport from './components/report.vue';
export default () => { export default () => {
const el = document.getElementById('js-compliance-report'); const el = document.getElementById('js-compliance-report');
const { mergeRequests, emptyStateSvgPath, isLastPage, mergeCommitsCsvExportPath } = el.dataset; 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({ return new Vue({
el, el,
render: (createElement) => 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 ...@@ -6,6 +6,9 @@ class Groups::Security::ComplianceDashboardsController < Groups::ApplicationCont
layout 'group' layout 'group'
before_action :authorize_compliance_dashboard! 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' 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,6 +13,13 @@ RSpec.describe 'Compliance Dashboard', :js do ...@@ -13,6 +13,13 @@ RSpec.describe 'Compliance Dashboard', :js do
stub_licensed_features(group_level_compliance_dashboard: true) stub_licensed_features(group_level_compliance_dashboard: true)
group.add_owner(user) group.add_owner(user)
sign_in(user) sign_in(user)
end
# 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) visit group_security_compliance_dashboard_path(group)
end end
...@@ -58,4 +65,21 @@ RSpec.describe 'Compliance Dashboard', :js do ...@@ -58,4 +65,21 @@ RSpec.describe 'Compliance Dashboard', :js do
end end
end end
end end
end
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
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
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 "" ...@@ -11087,6 +11087,9 @@ msgstr ""
msgid "Date" msgid "Date"
msgstr "" msgstr ""
msgid "Date merged"
msgstr ""
msgid "Date picker" msgid "Date picker"
msgstr "" msgstr ""
...@@ -29916,6 +29919,9 @@ msgstr "" ...@@ -29916,6 +29919,9 @@ msgstr ""
msgid "Resync" msgid "Resync"
msgstr "" msgstr ""
msgid "Retrieving the compliance report failed. Please refresh the page and try again."
msgstr ""
msgid "Retry" msgid "Retry"
msgstr "" msgstr ""
...@@ -34764,6 +34770,9 @@ msgstr "" ...@@ -34764,6 +34770,9 @@ msgstr ""
msgid "The compliance report captures merged changes that violate compliance best practices." msgid "The compliance report captures merged changes that violate compliance best practices."
msgstr "" 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." msgid "The connection will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
msgstr "" msgstr ""
...@@ -38726,6 +38735,9 @@ msgstr "" ...@@ -38726,6 +38735,9 @@ msgstr ""
msgid "Viewing commit" msgid "Viewing commit"
msgstr "" msgstr ""
msgid "Violation"
msgstr ""
msgid "Visibility" msgid "Visibility"
msgstr "" 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