Commit c9ef3f77 authored by Jiaan Louw's avatar Jiaan Louw Committed by Jose Ivan Vargas

Update compliance report to fetch violations from GraphQL

Remove the local resolver and fetch the merge request compliance
violations from our GraphQL API.
parent 40b9f8bc
......@@ -2,10 +2,9 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import { queryToObject } from '~/lib/utils/url_utility';
import resolvers from './graphql/resolvers';
import ComplianceDashboard from './components/dashboard.vue';
import ComplianceReport from './components/report.vue';
import { buildDefaultFilterParams } from './utils';
export default () => {
const el = document.getElementById('js-compliance-report');
......@@ -22,10 +21,10 @@ export default () => {
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(resolvers),
defaultClient: createDefaultClient(),
});
const defaultQuery = queryToObject(window.location.search, { gatherArrays: true });
const defaultFilterParams = buildDefaultFilterParams(window.location.search);
return new Vue({
el,
......@@ -35,7 +34,7 @@ export default () => {
props: {
mergeCommitsCsvExportPath,
groupPath,
defaultQuery,
defaultFilterParams,
},
}),
});
......
......@@ -63,7 +63,7 @@ export default {
<template>
<div>
<gl-dropdown split>
<gl-dropdown data-testid="merge-commit-dropdown" split>
<template #button-content>
<gl-button
ref="listMergeCommitsButton"
......
......@@ -8,7 +8,7 @@ import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
import UrlSync from '~/vue_shared/components/url_sync.vue';
import { helpPagePath } from '~/helpers/help_page_helper';
import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue';
import complianceViolationsQuery from '../graphql/compliance_violations.query.graphql';
import getComplianceViolationsQuery from '../graphql/compliance_violations.query.graphql';
import { mapViolations } from '../graphql/mappers';
import { DEFAULT_SORT, GRAPHQL_PAGE_SIZE } from '../constants';
import { parseViolationsQueryFilter } from '../utils';
......@@ -44,17 +44,17 @@ export default {
type: String,
required: true,
},
defaultQuery: {
defaultFilterParams: {
type: Object,
required: true,
},
},
data() {
const sortParam = this.defaultQuery.sort || DEFAULT_SORT;
const sortParam = this.defaultFilterParams.sort || DEFAULT_SORT;
const { sortBy, sortDesc } = sortStringToObject(sortParam);
return {
urlQuery: { ...this.defaultQuery },
urlQuery: { ...this.defaultFilterParams },
queryError: false,
violations: {
list: [],
......@@ -74,11 +74,11 @@ export default {
},
apollo: {
violations: {
query: complianceViolationsQuery,
query: getComplianceViolationsQuery,
variables() {
return {
fullPath: this.groupPath,
filter: parseViolationsQueryFilter(this.urlQuery),
filters: parseViolationsQueryFilter(this.urlQuery),
sort: this.sortParam,
first: GRAPHQL_PAGE_SIZE,
...this.paginationCursors,
......@@ -140,12 +140,19 @@ export default {
this.drawerProject = {};
},
updateUrlQuery({ projectIds = [], ...rest }) {
this.resetPagination();
this.urlQuery = {
// Clear the URL param when the id array is empty
projectIds: projectIds?.length > 0 ? projectIds : null,
...rest,
};
},
resetPagination() {
this.paginationCursors = {
before: null,
after: null,
};
},
loadPrevPage(startCursor) {
this.paginationCursors = {
before: startCursor,
......@@ -234,7 +241,7 @@ export default {
</header>
<violation-filter
:group-path="groupPath"
:default-query="defaultQuery"
:default-query="defaultFilterParams"
@filters-changed="updateUrlQuery"
/>
<gl-table
......
......@@ -2,7 +2,7 @@
import { GlDaterangePicker } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue';
import { getDateInPast, pikadayToString, parsePikadayDate } from '~/lib/utils/datetime_utility';
import { pikadayToString, parsePikadayDate } from '~/lib/utils/datetime_utility';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import getGroupProjects from '../../graphql/violation_group_projects.query.graphql';
......@@ -38,12 +38,10 @@ export default {
},
computed: {
defaultStartDate() {
const startDate = this.defaultQuery.mergedAfter;
return startDate ? parsePikadayDate(startDate) : getDateInPast(CURRENT_DATE, 30);
return parsePikadayDate(this.defaultQuery.mergedAfter);
},
defaultEndDate() {
const endDate = this.defaultQuery.mergedBefore;
return endDate ? parsePikadayDate(endDate) : CURRENT_DATE;
return parsePikadayDate(this.defaultQuery.mergedBefore);
},
},
async created() {
......@@ -103,6 +101,7 @@ export default {
}}</label>
<projects-dropdown-filter
v-if="showProjectFilter"
data-testid="violations-project-dropdown"
class="gl-mb-2 gl-lg-mb-0 compliance-filter-dropdown-input"
:group-namespace="groupPath"
:query-params="$options.projectsFilterParams"
......@@ -115,6 +114,7 @@ export default {
<gl-daterange-picker
class="gl-display-flex gl-w-full gl-mb-5"
data-testid="violations-date-range-picker"
:default-start-date="defaultStartDate"
:default-end-date="defaultEndDate"
:default-max-date="$options.defaultMaxDate"
......
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
# TODO: Add the correct filter type once it has been added in https://gitlab.com/gitlab-org/gitlab/-/issues/347325
query getComplianceViolations(
$fullPath: ID!
$filter: Object
$sort: String
$filters: ComplianceViolationInput
$sort: ComplianceViolationSort
$after: String
$before: String
$first: Int
) {
group(
fullPath: $fullPath
filter: $filter
sort: $sort
after: $after
before: $before
first: $first
) @client {
group(fullPath: $fullPath) {
id
mergeRequestViolations {
mergeRequestViolations(
filters: $filters
sort: $sort
after: $after
before: $before
first: $first
) {
nodes {
id
severityLevel
......@@ -76,7 +74,7 @@ query getComplianceViolations(
webUrl
}
}
ref
ref: reference
fullRef: reference(full: true)
sourceBranch
sourceBranchExists
......
// 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,
severityLevel: 'HIGH',
reason: 'APPROVED_BY_COMMITTER',
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/1',
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',
},
mergeUser: {
__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',
},
],
},
fullRef: 'gitlab-shell!1',
ref: '!1',
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: 'GDPR',
description: 'General Data Protection Regulation',
color: '#009966',
},
],
},
},
},
},
{
__typename: 'MergeRequestViolation',
id: 2,
severityLevel: 'HIGH',
reason: 'APPROVED_BY_INSUFFICIENT_USERS',
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: 25,
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-test/-/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',
},
mergeUser: {
__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',
},
],
},
fullRef: 'gitlab-test!2',
ref: '!2',
sourceBranch: 'ut-171ad4e264',
sourceBranchExists: false,
targetBranch: 'master',
targetBranchExists: true,
project: {
__typename: 'Project',
id: 2,
avatarUrl: null,
name: 'Gitlab Test',
webUrl: 'https://gdk.localhost:3443/gitlab-org/gitlab-test',
complianceFrameworks: {
__typename: 'ComplianceFrameworks',
nodes: [
{
__typename: 'ComplianceFrameworks',
id: 2,
name: 'SOX',
description: 'A framework',
color: '#00FF00',
},
],
},
},
},
},
],
pageInfo: {
__typename: 'PageInfo',
hasNextPage: true,
hasPreviousPage: false,
startCursor: 'eyJpZCI6IjMzMjkwNjMzIn0',
endCursor: 'eyJpZCI6IjMzMjkwNjI5In0',
},
},
};
},
},
};
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { convertToGraphQLIds } from '~/graphql_shared/utils';
import { TYPE_PROJECT } from '~/graphql_shared/constants';
import { formatDate } from '~/lib/utils/datetime_utility';
import { formatDate, getDateInPast, pikadayToString } from '~/lib/utils/datetime_utility';
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,
......@@ -24,8 +26,14 @@ export const convertProjectIdsToGraphQl = (projectIds) =>
projectIds.filter((id) => Boolean(id)),
);
export const parseViolationsQueryFilter = ({ createdBefore, createdAfter, projectIds }) => ({
export const parseViolationsQueryFilter = ({ mergedBefore, mergedAfter, projectIds }) => ({
projectIds: projectIds ? convertProjectIdsToGraphQl(projectIds) : [],
createdBefore: formatDate(createdBefore, ISO_SHORT_FORMAT),
createdAfter: formatDate(createdAfter, ISO_SHORT_FORMAT),
mergedBefore: formatDate(mergedBefore, ISO_SHORT_FORMAT),
mergedAfter: formatDate(mergedAfter, ISO_SHORT_FORMAT),
});
export const buildDefaultFilterParams = (queryString) => ({
mergedAfter: pikadayToString(getDateInPast(CURRENT_DATE, 30)),
mergedBefore: pikadayToString(CURRENT_DATE),
...queryToObject(queryString, { gatherArrays: true }),
});
......@@ -9,14 +9,35 @@ RSpec.describe 'Compliance Dashboard', :js do
let_it_be(:project) { create(:project, :repository, :public, namespace: group) }
let_it_be(:project_2) { create(:project, :repository, :public, namespace: group) }
shared_examples 'exports a merge commit-specific CSV' do
it 'downloads a commit chain of custory report', :aggregate_failures do
page.within('[data-testid="merge-commit-dropdown"]') 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
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
before do
stub_licensed_features(group_level_compliance_dashboard: true)
group.add_owner(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)
......@@ -45,24 +66,7 @@ RSpec.describe 'Compliance Dashboard', :js do
end
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
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
it_behaves_like 'exports a merge commit-specific CSV'
end
end
end
......@@ -81,5 +85,95 @@ RSpec.describe 'Compliance Dashboard', :js do
expect(page).to have_content 'Date merged'
end
end
context 'when there are no compliance violations' do
it 'shows an empty state' do
expect(page).to have_content('No violations found')
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') }
context 'chain of custody report' do
it_behaves_like 'exports a merge commit-specific CSV'
end
context 'and there is a compliance violation' do
let_it_be(:violation) { create(:compliance_violation, :approved_by_committer, severity_level: :high, merge_request: merge_request, violating_user: user) }
let_it_be(:violation_2) { create(:compliance_violation, :approved_by_merge_request_author, severity_level: :medium, merge_request: merge_request_2, violating_user: user) }
before do
merge_request.metrics.update!(merged_at: 1.day.ago)
merge_request_2.metrics.update!(merged_at: 7.days.ago)
wait_for_requests
end
it 'shows the compliance violations with details', :aggregate_failures do
expect(all('tbody > tr').count).to eq(2)
expect(first_row).to have_content('High')
expect(first_row).to have_content('Approved by committer')
expect(first_row).to have_content(merge_request.title)
expect(first_row).to have_content('1 day ago')
end
it 'can sort the violations by clicking on a column header' do
click_column_header 'Severity'
expect(first_row).to have_content(merge_request_2.title)
end
context 'violations filter' do
it 'can filter by date range' do
set_date_range(7.days.ago.to_date, 6.days.ago.to_date)
expect(page).to have_content(merge_request_2.title)
expect(page).not_to have_content(merge_request.title)
end
it 'can filter by project id' do
filter_by_project(merge_request_2.project)
expect(page).to have_content(merge_request_2.title)
expect(page).not_to have_content(merge_request.title)
end
end
end
end
end
def first_row
find('tbody tr', match: :first)
end
def set_date_range(start_date, end_date)
page.within('[data-testid="violations-date-range-picker"]') do
all('input')[0].set(start_date)
all('input')[0].native.send_keys(:return)
all('input')[1].set(end_date)
all('input')[1].native.send_keys(:return)
end
end
def filter_by_project(project)
page.within('[data-testid="violations-project-dropdown"]') do
find('.dropdown-toggle').click
find('input[aria-label="Search"]').set(project.name)
wait_for_requests
find('.dropdown-item').click
end
page.find('body').click
end
def click_column_header(name)
page.within('thead') do
find('div', text: name).click
wait_for_requests
end
end
end
......@@ -8,9 +8,9 @@ import Reference from 'ee/compliance_dashboard/components/drawer_sections/refere
import Reviewers from 'ee/compliance_dashboard/components/drawer_sections/reviewers.vue';
import { getContentWrapperHeight } from 'ee/threat_monitoring/utils';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import resolvers from 'ee/compliance_dashboard/graphql/resolvers';
import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
import { mapViolations } from 'ee/compliance_dashboard/graphql/mappers';
import { createComplianceViolation } from '../mock_data';
jest.mock('ee/threat_monitoring/utils', () => ({
getContentWrapperHeight: jest.fn(),
......@@ -18,7 +18,7 @@ jest.mock('ee/threat_monitoring/utils', () => ({
describe('MergeRequestDrawer component', () => {
let wrapper;
const defaultData = mapViolations(resolvers.Query.group().mergeRequestViolations.nodes)[0];
const defaultData = mapViolations([createComplianceViolation()])[0];
const data = {
id: defaultData.id,
mergeRequest: {
......
......@@ -9,8 +9,8 @@ import MergeRequestDrawer from 'ee/compliance_dashboard/components/drawer.vue';
import MergeCommitsExportButton from 'ee/compliance_dashboard/components/merge_requests/merge_commits_export_button.vue';
import ViolationReason from 'ee/compliance_dashboard/components/violations/reason.vue';
import ViolationFilter from 'ee/compliance_dashboard/components/violations/filter.vue';
import getComplianceViolationsQuery from 'ee/compliance_dashboard/graphql/compliance_violations.query.graphql';
import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue';
import resolvers from 'ee/compliance_dashboard/graphql/resolvers';
import { mapViolations } from 'ee/compliance_dashboard/graphql/mappers';
import { stripTypenames } from 'helpers/graphql_helpers';
import waitForPromises from 'helpers/wait_for_promises';
......@@ -21,24 +21,30 @@ import { stubComponent } from 'helpers/stub_component';
import { sortObjectToString } from '~/lib/utils/table_utility';
import { parseViolationsQueryFilter } from 'ee/compliance_dashboard/utils';
import { DEFAULT_SORT, GRAPHQL_PAGE_SIZE } from 'ee/compliance_dashboard/constants';
import { createComplianceViolationsResponse } from '../mock_data';
Vue.use(VueApollo);
describe('ComplianceReport component', () => {
let wrapper;
let mockResolver;
const mergeCommitsCsvExportPath = '/csv';
const groupPath = 'group-path';
const mergedAfter = '2021-11-16';
const mergedBefore = '2021-12-15';
const defaultQuery = {
const defaultFilterParams = {
projectIds: ['20'],
mergedAfter,
mergedBefore,
sort: DEFAULT_SORT,
};
const mockGraphQlError = new Error('GraphQL networkError');
const violationsResponse = createComplianceViolationsResponse({ count: 2 });
const violations = violationsResponse.data.group.mergeRequestViolations.nodes;
const sentryError = new Error('GraphQL networkError');
const mockGraphQlSuccess = jest.fn().mockResolvedValue(violationsResponse);
const mockGraphQlLoading = jest.fn().mockResolvedValue(new Promise(() => {}));
const mockGraphQlError = jest.fn().mockRejectedValue(sentryError);
const findSubheading = () => wrapper.findByTestId('subheading');
const findErrorMessage = () => wrapper.findComponent(GlAlert);
......@@ -54,8 +60,8 @@ describe('ComplianceReport component', () => {
const findUrlSync = () => wrapper.findComponent(UrlSync);
const findTableHeaders = () => findViolationsTable().findAll('th div');
const findTablesFirstRowData = () =>
findViolationsTable().findAll('tbody > tr').at(0).findAll('td');
const findTableRowData = (idx) =>
findViolationsTable().findAll('tbody > tr').at(idx).findAll('td');
const findSelectedRows = () => findViolationsTable().findAll('tr.b-table-row-selected');
const findRow = (idx) => {
......@@ -72,25 +78,22 @@ describe('ComplianceReport component', () => {
await nextTick();
};
const expectApolloVariables = (variables) => [
{},
variables,
expect.anything(),
expect.anything(),
];
function createMockApolloProvider() {
return createMockApollo([], { Query: { group: mockResolver } });
}
const createMockApolloProvider = (resolverMock) => {
return createMockApollo([[getComplianceViolationsQuery, resolverMock]]);
};
const createComponent = (mountFn = shallowMount, props = {}) => {
const createComponent = (
mountFn = shallowMount,
props = {},
resolverMock = mockGraphQlLoading,
) => {
return extendedWrapper(
mountFn(ComplianceReport, {
apolloProvider: createMockApolloProvider(),
apolloProvider: createMockApolloProvider(resolverMock),
propsData: {
mergeCommitsCsvExportPath,
groupPath,
defaultQuery,
defaultFilterParams,
...props,
},
stubs: {
......@@ -104,7 +107,6 @@ describe('ComplianceReport component', () => {
afterEach(() => {
wrapper.destroy();
mockResolver = null;
});
describe('default behavior', () => {
......@@ -131,12 +133,18 @@ describe('ComplianceReport component', () => {
it('does not render an error message', () => {
expect(findErrorMessage().exists()).toBe(false);
});
it('configures the filter', () => {
expect(findViolationFilter().props()).toMatchObject({
groupPath,
defaultQuery: defaultFilterParams,
});
});
});
describe('when initializing', () => {
beforeEach(() => {
mockResolver = jest.fn();
wrapper = createComponent(mount);
wrapper = createComponent(mount, {}, mockGraphQlLoading);
});
it('renders the table loading icon', () => {
......@@ -145,48 +153,46 @@ describe('ComplianceReport component', () => {
});
it('fetches the list of merge request violations with the default filter and sort params', async () => {
expect(mockResolver).toHaveBeenCalledTimes(1);
expect(mockResolver).toHaveBeenCalledWith(
...expectApolloVariables({
fullPath: groupPath,
filter: parseViolationsQueryFilter(defaultQuery),
sort: DEFAULT_SORT,
first: GRAPHQL_PAGE_SIZE,
after: null,
before: null,
}),
);
expect(mockGraphQlLoading).toHaveBeenCalledTimes(1);
expect(mockGraphQlLoading).toHaveBeenCalledWith({
fullPath: groupPath,
filters: parseViolationsQueryFilter(defaultFilterParams),
sort: DEFAULT_SORT,
first: GRAPHQL_PAGE_SIZE,
after: null,
before: null,
});
});
});
describe('when the defaultQuery has a sort param', () => {
const sort = 'SEVERITY_ASC';
describe('when the defaultFilterParams has a sort param', () => {
const sort = 'VIOLATION_ASC';
beforeEach(() => {
mockResolver = jest.fn();
wrapper = createComponent(mount, { defaultQuery: { ...defaultQuery, sort } });
wrapper = createComponent(
mount,
{ defaultFilterParams: { ...defaultFilterParams, sort } },
mockGraphQlLoading,
);
});
it('fetches the list of merge request violations with sort params', async () => {
expect(mockResolver).toHaveBeenCalledTimes(1);
expect(mockResolver).toHaveBeenCalledWith(
...expectApolloVariables({
fullPath: groupPath,
filter: parseViolationsQueryFilter(defaultQuery),
sort,
first: GRAPHQL_PAGE_SIZE,
after: null,
before: null,
}),
);
expect(mockGraphQlLoading).toHaveBeenCalledTimes(1);
expect(mockGraphQlLoading).toHaveBeenCalledWith({
fullPath: groupPath,
filters: parseViolationsQueryFilter(defaultFilterParams),
sort,
first: GRAPHQL_PAGE_SIZE,
after: null,
before: null,
});
});
});
describe('when the query fails', () => {
beforeEach(() => {
jest.spyOn(Sentry, 'captureException');
mockResolver = jest.fn().mockRejectedValue(mockGraphQlError);
wrapper = createComponent();
wrapper = createComponent(shallowMount, {}, mockGraphQlError);
});
it('renders the error message', async () => {
......@@ -196,19 +202,20 @@ describe('ComplianceReport component', () => {
expect(findErrorMessage().text()).toBe(
'Retrieving the compliance report failed. Refresh the page and try again.',
);
expect(Sentry.captureException.mock.calls[0][0].networkError).toBe(mockGraphQlError);
expect(Sentry.captureException.mock.calls[0][0].networkError).toBe(sentryError);
});
});
describe('when there are violations', () => {
beforeEach(() => {
mockResolver = resolvers.Query.group;
wrapper = createComponent(mount);
wrapper = createComponent(mount, {}, mockGraphQlSuccess);
return waitForPromises();
});
it('does not render the table loading icon', () => {
expect(mockGraphQlSuccess).toHaveBeenCalledTimes(1);
expect(findTableLoadingIcon().exists()).toBe(false);
});
......@@ -224,40 +231,37 @@ describe('ComplianceReport component', () => {
]);
});
it('has the correct first row data', () => {
const headerTexts = findTablesFirstRowData().wrappers.map((d) => d.text());
it.each(Object.keys(violations))('has the correct data for row %s', (idx) => {
const rowTexts = findTableRowData(idx).wrappers.map((d) => d.text());
expect(headerTexts).toEqual([
expect(rowTexts).toEqual([
'High',
'Approved by committer',
'Officiis architecto voluptas ut sit qui qui quisquam sequi consectetur porro.',
`Merge request ${idx}`,
'in 1 year',
'View details',
]);
});
it('renders the violation severity badge', () => {
const { severityLevel } = mapViolations(mockResolver().mergeRequestViolations.nodes)[0];
const { severityLevel } = violations[0];
expect(findSeverityBadge().props()).toStrictEqual({ severity: severityLevel });
});
it('renders the violation reason', () => {
const {
violatingUser: { __typename, ...user },
reason,
} = mockResolver().mergeRequestViolations.nodes[0];
const { violatingUser, reason } = violations[0];
expect(findViolationReason().props()).toMatchObject({
reason,
user,
user: stripTypenames(violatingUser),
});
});
it('renders the time ago tooltip', () => {
const {
mergeRequest: { mergedAt },
} = mockResolver().mergeRequestViolations.nodes[0];
} = violations[0];
expect(findTimeAgoTooltip().props('time')).toBe(mergedAt);
});
......@@ -280,7 +284,7 @@ describe('ComplianceReport component', () => {
${selectRow} | ${'row is selected'}
`('when a $eventDescription', ({ rowAction, eventDescription }) => {
it('opens then drawer', async () => {
const drawerData = mapViolations(mockResolver().mergeRequestViolations.nodes)[0];
const drawerData = mapViolations(violations)[0];
await rowAction(0);
......@@ -303,7 +307,7 @@ describe('ComplianceReport component', () => {
});
it(`swaps the drawer when another ${eventDescription}`, async () => {
const drawerData = mapViolations(mockResolver().mergeRequestViolations.nodes)[1];
const drawerData = mapViolations(violations)[1];
await rowAction(0);
await rowAction(1);
......@@ -319,59 +323,40 @@ describe('ComplianceReport component', () => {
});
});
describe('violation filter', () => {
beforeEach(() => {
mockResolver = jest.fn().mockReturnValue(resolvers.Query.group());
wrapper = createComponent(mount);
describe('when the filters changed', () => {
const query = { mergedAfter, mergedBefore, projectIds: [1, 2, 3] };
return waitForPromises();
beforeEach(() => {
return findViolationFilter().vm.$emit('filters-changed', query);
});
it('configures the filter', () => {
expect(findViolationFilter().props()).toMatchObject({
groupPath,
defaultQuery,
});
it('updates the URL query', () => {
expect(findUrlSync().props('query')).toMatchObject(query);
});
describe('when the filters changed', () => {
const query = { mergedAfter, mergedBefore, projectIds: [1, 2, 3] };
beforeEach(() => {
return findViolationFilter().vm.$emit('filters-changed', query);
});
it('updates the URL query', () => {
expect(findUrlSync().props('query')).toMatchObject(query);
});
it('shows the table loading icon', () => {
expect(findTableLoadingIcon().exists()).toBe(true);
});
it('shows the table loading icon', () => {
expect(findTableLoadingIcon().exists()).toBe(true);
});
it('sets the pagination component to disabled', () => {
expect(findPagination().props('disabled')).toBe(true);
});
it('sets the pagination component to disabled', () => {
expect(findPagination().props('disabled')).toBe(true);
});
it('clears the project URL query param if the project array is empty', async () => {
await findViolationFilter().vm.$emit('filters-changed', { ...query, projectIds: [] });
it('clears the project URL query param if the project array is empty', async () => {
await findViolationFilter().vm.$emit('filters-changed', { ...query, projectIds: [] });
expect(findUrlSync().props('query')).toMatchObject({ ...query, projectIds: null });
});
expect(findUrlSync().props('query')).toMatchObject({ ...query, projectIds: null });
});
it('fetches the filtered violations', async () => {
expect(mockResolver).toHaveBeenCalledTimes(2);
expect(mockResolver).toHaveBeenNthCalledWith(
2,
...expectApolloVariables({
fullPath: groupPath,
filter: parseViolationsQueryFilter(query),
sort: DEFAULT_SORT,
first: GRAPHQL_PAGE_SIZE,
after: null,
before: null,
}),
);
it('fetches the filtered violations', async () => {
expect(mockGraphQlSuccess).toHaveBeenCalledTimes(2);
expect(mockGraphQlSuccess).toHaveBeenNthCalledWith(2, {
fullPath: groupPath,
filters: parseViolationsQueryFilter(query),
sort: DEFAULT_SORT,
first: GRAPHQL_PAGE_SIZE,
after: null,
before: null,
});
});
});
......@@ -380,10 +365,6 @@ describe('ComplianceReport component', () => {
const sortState = { sortBy: 'mergedAt', sortDesc: true };
beforeEach(async () => {
mockResolver = jest.fn().mockReturnValue(resolvers.Query.group());
wrapper = createComponent(mount);
await waitForPromises();
await findViolationsTable().vm.$emit('sort-changed', sortState);
});
......@@ -398,31 +379,23 @@ describe('ComplianceReport component', () => {
});
it('fetches the sorted violations', async () => {
expect(mockResolver).toHaveBeenCalledTimes(2);
expect(mockResolver).toHaveBeenNthCalledWith(
2,
...expectApolloVariables({
fullPath: groupPath,
filter: parseViolationsQueryFilter(defaultQuery),
sort: sortObjectToString(sortState),
first: GRAPHQL_PAGE_SIZE,
after: null,
before: null,
}),
);
expect(mockGraphQlSuccess).toHaveBeenCalledTimes(2);
expect(mockGraphQlSuccess).toHaveBeenNthCalledWith(2, {
fullPath: groupPath,
filters: parseViolationsQueryFilter(defaultFilterParams),
sort: sortObjectToString(sortState),
first: GRAPHQL_PAGE_SIZE,
after: null,
before: null,
});
});
});
describe('pagination', () => {
beforeEach(() => {
mockResolver = jest.fn().mockReturnValue(resolvers.Query.group());
wrapper = createComponent(mount);
return waitForPromises();
});
it('renders and configures the pagination', () => {
const pageInfo = stripTypenames(resolvers.Query.group().mergeRequestViolations.pageInfo);
const pageInfo = stripTypenames(
violationsResponse.data.group.mergeRequestViolations.pageInfo,
);
expect(findPagination().props()).toMatchObject({
...pageInfo,
......@@ -440,29 +413,29 @@ describe('ComplianceReport component', () => {
await findPagination().vm.$emit(event, after ?? before);
await waitForPromises();
expect(mockResolver).toHaveBeenCalledTimes(2);
expect(mockResolver).toHaveBeenNthCalledWith(
2,
...expectApolloVariables({
fullPath: groupPath,
filter: parseViolationsQueryFilter(defaultQuery),
first: GRAPHQL_PAGE_SIZE,
sort: DEFAULT_SORT,
after,
before,
}),
);
expect(mockGraphQlSuccess).toHaveBeenCalledTimes(2);
expect(mockGraphQlSuccess).toHaveBeenNthCalledWith(2, {
fullPath: groupPath,
filters: parseViolationsQueryFilter(defaultFilterParams),
first: GRAPHQL_PAGE_SIZE,
sort: DEFAULT_SORT,
after,
before,
});
},
);
describe('when there are no next or previous pages', () => {
beforeEach(() => {
const group = resolvers.Query.group();
group.mergeRequestViolations.pageInfo.hasNextPage = false;
group.mergeRequestViolations.pageInfo.hasPreviousPage = false;
const noPagesResponse = createComplianceViolationsResponse({
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
},
});
const mockResolver = jest.fn().mockResolvedValue(noPagesResponse);
mockResolver = () => jest.fn().mockReturnValue(group);
wrapper = createComponent(mount);
wrapper = createComponent(mount, {}, mockResolver);
return waitForPromises();
});
......@@ -471,20 +444,37 @@ describe('ComplianceReport component', () => {
expect(findPagination().exists()).toBe(false);
});
});
describe('when the next page has been selected', () => {
beforeEach(() => {
return findPagination().vm.$emit('next', 'foo');
});
it('clears the pagination when the filter is updated', async () => {
const query = { projectIds: [1] };
await findViolationFilter().vm.$emit('filters-changed', query);
expect(mockGraphQlSuccess).toHaveBeenCalledTimes(3);
expect(mockGraphQlSuccess).toHaveBeenNthCalledWith(3, {
fullPath: groupPath,
filters: parseViolationsQueryFilter(query),
first: GRAPHQL_PAGE_SIZE,
sort: DEFAULT_SORT,
after: null,
before: null,
});
});
});
});
});
describe('when there are no violations', () => {
beforeEach(() => {
mockResolver = () => ({
__typename: 'Group',
id: 1,
mergeRequestViolations: {
__typename: 'MergeRequestViolations',
nodes: [],
},
});
wrapper = createComponent(mount);
const noViolationsResponse = createComplianceViolationsResponse({ count: 0 });
const mockResolver = jest.fn().mockResolvedValue(noViolationsResponse);
wrapper = createComponent(mount, {}, mockResolver);
return waitForPromises();
});
......
......@@ -3,7 +3,10 @@ import Vue from 'vue';
import { GlDaterangePicker } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ViolationFilter from 'ee/compliance_dashboard/components/violations/filter.vue';
import { convertProjectIdsToGraphQl } from 'ee/compliance_dashboard/utils';
import {
convertProjectIdsToGraphQl,
buildDefaultFilterParams,
} from 'ee/compliance_dashboard/utils';
import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue';
import { getDateInPast, pikadayToString } from '~/lib/utils/datetime_utility';
import { CURRENT_DATE } from 'ee/audit_events/constants';
......@@ -16,6 +19,7 @@ Vue.use(VueApollo);
describe('ViolationFilter component', () => {
let wrapper;
const defaultQuery = buildDefaultFilterParams('');
const groupPath = 'group-path';
const projectIds = ['1', '2'];
const startDate = getDateInPast(CURRENT_DATE, 20);
......@@ -42,7 +46,7 @@ describe('ViolationFilter component', () => {
apolloProvider: mockApollo(mockResponse),
propsData: {
groupPath,
defaultQuery: {},
defaultQuery,
...propsData,
},
});
......@@ -108,7 +112,9 @@ describe('ViolationFilter component', () => {
await findProjectsFilter().vm.$emit('selected', defaultProjects);
expect(wrapper.emitted('filters-changed')).toHaveLength(1);
expect(wrapper.emitted('filters-changed')[0]).toStrictEqual([{ projectIds: expectedIds }]);
expect(wrapper.emitted('filters-changed')[0]).toStrictEqual([
{ ...defaultQuery, projectIds: expectedIds },
]);
});
it('emits a query with a start and end date when a date range has been inputted', async () => {
......@@ -118,37 +124,29 @@ describe('ViolationFilter component', () => {
expect(wrapper.emitted('filters-changed')[0]).toStrictEqual([{ ...dateRangeQuery }]);
});
describe('with a default query', () => {
const defaultQuery = { projectIds, mergedAfter: '2022-01-01', mergedBefore: '2022-01-31' };
it('emits the existing filter query with mutations on each update', async () => {
await findProjectsFilter().vm.$emit('selected', []);
beforeEach(() => {
createComponent({ defaultQuery });
});
it('emits the existing filter query with mutations on each update', async () => {
await findProjectsFilter().vm.$emit('selected', []);
expect(wrapper.emitted('filters-changed')).toHaveLength(1);
expect(wrapper.emitted('filters-changed')[0]).toStrictEqual([
{ ...defaultQuery, projectIds: [] },
]);
expect(wrapper.emitted('filters-changed')).toHaveLength(1);
expect(wrapper.emitted('filters-changed')[0]).toStrictEqual([
{ ...defaultQuery, projectIds: [] },
]);
await findDatePicker().vm.$emit('input', { startDate, endDate });
await findDatePicker().vm.$emit('input', { startDate, endDate });
expect(wrapper.emitted('filters-changed')).toHaveLength(2);
expect(wrapper.emitted('filters-changed')[1]).toStrictEqual([
{
projectIds: [],
...dateRangeQuery,
},
]);
});
expect(wrapper.emitted('filters-changed')).toHaveLength(2);
expect(wrapper.emitted('filters-changed')[1]).toStrictEqual([
{
projectIds: [],
...dateRangeQuery,
},
]);
});
});
describe('projects filter', () => {
it('fetches the project details when the default query contains projectIds', () => {
createComponent({ defaultQuery: { projectIds } });
createComponent({ defaultQuery: { ...defaultQuery, projectIds } });
expect(groupProjectsSuccess).toHaveBeenCalledWith({
groupPath,
......@@ -158,7 +156,7 @@ describe('ViolationFilter component', () => {
describe('when the defaultProjects are being fetched', () => {
beforeEach(async () => {
createComponent({ defaultQuery: { projectIds } }, groupProjectsLoading);
createComponent({ defaultQuery: { ...defaultQuery, projectIds } }, groupProjectsLoading);
await waitForPromises();
});
......@@ -173,7 +171,7 @@ describe('ViolationFilter component', () => {
describe('when the defaultProjects have been fetched', () => {
beforeEach(async () => {
createComponent({ defaultQuery: { projectIds } });
createComponent({ defaultQuery: { ...defaultQuery, projectIds } });
await waitForPromises();
});
......
import { mapViolations } from 'ee/compliance_dashboard/graphql/mappers';
import resolvers from 'ee/compliance_dashboard/graphql/resolvers';
import { createComplianceViolation } from '../mock_data';
describe('mapViolations', () => {
const mockViolations = resolvers.Query.group().mergeRequestViolations.nodes;
it('returns the expected result', () => {
const { mergeRequest } = mapViolations([{ ...mockViolations[0] }])[0];
const { mergeRequest } = mapViolations([createComplianceViolation()])[0];
expect(mergeRequest).toMatchObject({
reference: mergeRequest.ref,
......
......@@ -93,3 +93,105 @@ export const createDefaultProjectsResponse = (projects) => ({
},
},
});
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',
},
mergeRequest: {
id: `gid://gitlab/MergeRequest/${id}`,
title: `Merge request ${id}`,
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,
committers: {
nodes: [],
__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',
},
],
__typename: 'UserCoreConnection',
},
approvedBy: {
nodes: [],
__typename: 'UserCoreConnection',
},
ref: '!56',
fullRef: 'gitlab-org/gitlab-shell!56',
sourceBranch: 'master',
sourceBranchExists: false,
targetBranch: 'feature',
targetBranchExists: false,
project: {
id: 'gid://gitlab/Project/2',
avatarUrl: null,
name: 'Gitlab Shell',
webUrl: 'http://gdk.test:3000/gitlab-org/gitlab-shell',
complianceFrameworks: {
nodes: [
{
id: 'gid://gitlab/ComplianceManagement::Framework/1',
name: 'GDPR',
description: 'asds',
color: '#0000ff',
__typename: 'ComplianceFramework',
},
],
__typename: 'ComplianceFrameworkConnection',
},
__typename: 'Project',
},
__typename: 'MergeRequest',
},
__typename: 'ComplianceViolation',
});
export const createComplianceViolationsResponse = ({ count = 1, pageInfo = {} } = {}) => ({
data: {
group: {
id: 'gid://gitlab/Group/1',
__typename: 'Group',
mergeRequestViolations: {
__typename: 'ComplianceViolationConnection',
nodes: Array(count)
.fill(null)
.map((_, id) => createComplianceViolation(id)),
pageInfo: {
endCursor: 'abc',
hasNextPage: true,
hasPreviousPage: false,
startCursor: 'abc',
__typename: 'PageInfo',
...pageInfo,
},
},
},
},
});
import * as utils from 'ee/compliance_dashboard/utils';
import { queryToObject } from '~/lib/utils/url_utility';
jest.mock('ee/audit_events/constants', () => ({
CURRENT_DATE: new Date('2022 2 28'),
}));
describe('compliance report utils', () => {
const projectIds = ['1', '2'];
......@@ -8,14 +13,14 @@ describe('compliance report utils', () => {
it('returns the expected result', () => {
const query = {
projectIds,
createdAfter: '2021-12-06',
createdBefore: '2022-01-06',
mergedAfter: '2021-12-06',
mergedBefore: '2022-01-06',
};
expect(utils.parseViolationsQueryFilter(query)).toStrictEqual({
projectIds: projectGraphQlIds,
createdAfter: query.createdAfter,
createdBefore: query.createdBefore,
mergedAfter: query.mergedAfter,
mergedBefore: query.mergedBefore,
});
});
});
......@@ -25,4 +30,24 @@ describe('compliance report utils', () => {
expect(utils.convertProjectIdsToGraphQl(projectIds)).toStrictEqual(projectGraphQlIds);
});
});
describe('buildDefaultFilterParams', () => {
it('returns the expected result with the default date range of 30 days', () => {
const queryString = 'projectIds[]=20';
expect(utils.buildDefaultFilterParams(queryString)).toStrictEqual({
mergedAfter: '2022-01-29',
mergedBefore: '2022-02-28',
projectIds: ['20'],
});
});
it('return the expected result when the query contains dates', () => {
const queryString = 'mergedAfter=2022-02-09&mergedBefore=2022-03-11&projectIds[]=20';
expect(utils.buildDefaultFilterParams(queryString)).toStrictEqual(
queryToObject(queryString, { gatherArrays: true }),
);
});
});
});
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