Commit 175d0cc9 authored by Terri Chu's avatar Terri Chu

Merge branch '338790-use-vulnerability-list-on-group-report' into 'master'

Use new vulnerability list on new group report

See merge request gitlab-org/gitlab!72972
parents 776bc31d f6007a2a
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
import { GlAlert, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui'; import { GlAlert, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui';
import produce from 'immer'; import produce from 'immer';
import vulnerabilitiesQuery from 'ee/security_dashboard/graphql/queries/group_vulnerabilities.query.graphql'; import vulnerabilitiesQuery from 'ee/security_dashboard/graphql/queries/group_vulnerabilities.query.graphql';
import { preparePageInfo } from 'ee/security_dashboard/helpers';
import { VULNERABILITIES_PER_PAGE } from 'ee/security_dashboard/store/constants'; import { VULNERABILITIES_PER_PAGE } from 'ee/security_dashboard/store/constants';
import VulnerabilityList from '../shared/vulnerability_list.vue'; import VulnerabilityList from '../shared/vulnerability_list.vue';
...@@ -49,7 +48,7 @@ export default { ...@@ -49,7 +48,7 @@ export default {
}, },
update: ({ group }) => group.vulnerabilities.nodes, update: ({ group }) => group.vulnerabilities.nodes,
result({ data }) { result({ data }) {
this.pageInfo = preparePageInfo(data?.group?.vulnerabilities?.pageInfo); this.pageInfo = data?.group?.vulnerabilities?.pageInfo;
}, },
error() { error() {
this.errorLoadingVulnerabilities = true; this.errorLoadingVulnerabilities = true;
......
<script> <script>
import { GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
import produce from 'immer';
import countsQuery from 'ee/security_dashboard/graphql/queries/vulnerability_severities_count.query.graphql'; import countsQuery from 'ee/security_dashboard/graphql/queries/vulnerability_severities_count.query.graphql';
import vulnerabilitiesQuery from 'ee/security_dashboard/graphql/queries/group_vulnerabilities.query.graphql';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import DashboardNotConfiguredGroup from '../shared/empty_states/group_dashboard_not_configured.vue';
import VulnerabilityCounts from '../shared/vulnerability_report/vulnerability_counts.vue'; import VulnerabilityCounts from '../shared/vulnerability_report/vulnerability_counts.vue';
import VulnerabilityList from '../shared/vulnerability_report/vulnerability_list.vue';
import { FIELDS } from '../shared/vulnerability_report/constants';
const { CHECKBOX, DETECTED, STATUS, SEVERITY, DESCRIPTION, IDENTIFIER, TOOL, ACTIVITY } = FIELDS;
export default { export default {
components: { VulnerabilityCounts }, components: {
inject: ['groupFullPath'], VulnerabilityCounts,
VulnerabilityList,
GlIntersectionObserver,
GlLoadingIcon,
DashboardNotConfiguredGroup,
},
inject: ['groupFullPath', 'canViewFalsePositive', 'canAdminVulnerability', 'hasProjects'],
data() { data() {
return { return {
counts: {}, counts: {},
vulnerabilities: [],
sort: undefined,
pageInfo: undefined,
}; };
}, },
apollo: { apollo: {
counts: { counts: {
query: countsQuery, query: countsQuery,
errorPolicy: 'none',
variables() { variables() {
return { return {
fullPath: this.groupFullPath, fullPath: this.groupFullPath,
...@@ -32,15 +50,98 @@ export default { ...@@ -32,15 +50,98 @@ export default {
}); });
}, },
}, },
vulnerabilities: {
query: vulnerabilitiesQuery,
errorPolicy: 'none',
variables() {
return {
fullPath: this.groupFullPath,
sort: this.sort,
vetEnabled: this.canViewFalsePositive,
};
},
update({ group }) {
this.pageInfo = group.vulnerabilities.pageInfo;
return group.vulnerabilities.nodes;
},
error() {
createFlash({
message: s__(
'SecurityReports|Error fetching the vulnerability list. Please check your network connection and try again.',
),
});
},
},
}, },
computed: { computed: {
isCountsLoading() { isLoadingCounts() {
return this.$apollo.queries.counts.loading; return this.$apollo.queries.counts.loading;
}, },
// Used to show the loading icon at the bottom of the vulnerabilities list.
isLoadingVulnerabilities() {
return this.$apollo.queries.vulnerabilities.loading;
},
// Used to show the initial skeleton loader for the vulnerabilities list.
isLoadingInitialVulnerabilities() {
return this.isLoadingVulnerabilities && this.vulnerabilities.length <= 0;
},
hasNextPage() {
return this.pageInfo?.hasNextPage;
},
fields() {
return [
// Add the checkbox field if the user can use the bulk select feature.
...[this.canAdminVulnerability ? CHECKBOX : []],
DETECTED,
STATUS,
SEVERITY,
DESCRIPTION,
IDENTIFIER,
TOOL,
ACTIVITY,
];
},
},
methods: {
updateSort(sort) {
// Clear out the vulnerabilities so that the skeleton loader is shown.
this.vulnerabilities = [];
this.sort = sort;
},
fetchNextPage() {
this.$apollo.queries.vulnerabilities.fetchMore({
variables: { after: this.pageInfo.endCursor },
updateQuery: (previousResult, { fetchMoreResult }) => {
return produce(fetchMoreResult, (draftData) => {
draftData.group.vulnerabilities.nodes = [
...previousResult.group.vulnerabilities.nodes,
...draftData.group.vulnerabilities.nodes,
];
});
},
});
},
}, },
}; };
</script> </script>
<template> <template>
<vulnerability-counts :counts="counts" :is-loading="isCountsLoading" /> <dashboard-not-configured-group v-if="!hasProjects" />
<div v-else class="gl-mt-5">
<vulnerability-counts :counts="counts" :is-loading="isLoadingCounts" />
<vulnerability-list
class="gl-mt-5"
:is-loading="isLoadingInitialVulnerabilities"
:vulnerabilities="vulnerabilities"
:fields="fields"
should-show-project-namespace
@sort-changed="updateSort"
/>
<gl-intersection-observer v-if="hasNextPage" @appear="fetchNextPage">
<gl-loading-icon v-if="isLoadingVulnerabilities" size="md" />
</gl-intersection-observer>
</div>
</template> </template>
#import "~/graphql_shared/fragments/pageInfoCursorsOnly.fragment.graphql"
#import "../fragments/vulnerability.fragment.graphql" #import "../fragments/vulnerability.fragment.graphql"
query groupVulnerabilities( query groupVulnerabilities(
$fullPath: ID! $fullPath: ID!
$after: String $after: String
$first: Int $first: Int = 20
$projectId: [ID!] $projectId: [ID!]
$severity: [VulnerabilitySeverity!] $severity: [VulnerabilitySeverity!]
$reportType: [VulnerabilityReportType!] $reportType: [VulnerabilityReportType!]
...@@ -34,7 +33,8 @@ query groupVulnerabilities( ...@@ -34,7 +33,8 @@ query groupVulnerabilities(
...VulnerabilityFragment ...VulnerabilityFragment
} }
pageInfo { pageInfo {
...PageInfo endCursor
hasNextPage
} }
} }
} }
......
...@@ -21,6 +21,7 @@ export default (el, dashboardType) => { ...@@ -21,6 +21,7 @@ export default (el, dashboardType) => {
notEnabledScannersHelpPath, notEnabledScannersHelpPath,
noPipelineRunScannersHelpPath, noPipelineRunScannersHelpPath,
hasVulnerabilities, hasVulnerabilities,
hasProjects,
scanners, scanners,
securityDashboardHelpPath, securityDashboardHelpPath,
vulnerabilitiesExportEndpoint, vulnerabilitiesExportEndpoint,
...@@ -80,6 +81,7 @@ export default (el, dashboardType) => { ...@@ -80,6 +81,7 @@ export default (el, dashboardType) => {
autoFixMrsPath, autoFixMrsPath,
canAdminVulnerability: parseBoolean(canAdminVulnerability), canAdminVulnerability: parseBoolean(canAdminVulnerability),
hasVulnerabilities: parseBoolean(hasVulnerabilities), hasVulnerabilities: parseBoolean(hasVulnerabilities),
hasProjects: parseBoolean(hasProjects),
scanners: scanners ? JSON.parse(scanners) : [], scanners: scanners ? JSON.parse(scanners) : [],
hasJiraVulnerabilitiesIntegrationEnabled: parseBoolean( hasJiraVulnerabilitiesIntegrationEnabled: parseBoolean(
hasJiraVulnerabilitiesIntegrationEnabled, hasJiraVulnerabilitiesIntegrationEnabled,
......
...@@ -30,7 +30,8 @@ module Groups::SecurityFeaturesHelper ...@@ -30,7 +30,8 @@ module Groups::SecurityFeaturesHelper
scanners: VulnerabilityScanners::ListService.new(group).execute.to_json, scanners: VulnerabilityScanners::ListService.new(group).execute.to_json,
can_admin_vulnerability: can?(current_user, :admin_vulnerability, group).to_s, can_admin_vulnerability: can?(current_user, :admin_vulnerability, group).to_s,
false_positive_doc_url: help_page_path('user/application_security/vulnerabilities/index'), false_positive_doc_url: help_page_path('user/application_security/vulnerabilities/index'),
can_view_false_positive: group.licensed_feature_available?(:sast_fp_reduction).to_s can_view_false_positive: group.licensed_feature_available?(:sast_fp_reduction).to_s,
has_projects: group.projects.any?.to_s
} }
end end
end end
...@@ -212,7 +212,7 @@ describe('Group Security Dashboard Vulnerabilities Component', () => { ...@@ -212,7 +212,7 @@ describe('Group Security Dashboard Vulnerabilities Component', () => {
group: { group: {
vulnerabilities: { vulnerabilities: {
nodes: [], nodes: [],
pageInfo: { startCursor: '', endCursor: '' }, pageInfo: { endCursor: '', hasNextPage: '' },
}, },
}, },
}, },
......
import { createLocalVue } from '@vue/test-utils'; import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import { GlIntersectionObserver } from '@gitlab/ui';
import VulnerabilityList from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_list.vue';
import VulnerabilityReportDevelopment from 'ee/security_dashboard/components/group/vulnerability_report_development.vue'; import VulnerabilityReportDevelopment from 'ee/security_dashboard/components/group/vulnerability_report_development.vue';
import VulnerabilityCounts from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_counts.vue'; import VulnerabilityCounts from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_counts.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import countsQuery from 'ee/security_dashboard/graphql/queries/vulnerability_severities_count.query.graphql'; import countsQuery from 'ee/security_dashboard/graphql/queries/vulnerability_severities_count.query.graphql';
import vulnerabilitiesQuery from 'ee/security_dashboard/graphql/queries/group_vulnerabilities.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import createFlash from '~/flash';
jest.mock('~/flash');
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(VueApollo); localVue.use(VueApollo);
...@@ -21,27 +27,59 @@ const countsRequestHandler = jest.fn().mockResolvedValue({ ...@@ -21,27 +27,59 @@ const countsRequestHandler = jest.fn().mockResolvedValue({
}, },
}); });
const createVulnerabilitiesRequestHandler = ({ hasNextPage }) =>
jest.fn().mockResolvedValue({
data: {
group: {
vulnerabilities: {
nodes: [],
pageInfo: { endCursor: 'abc', hasNextPage },
},
},
},
});
const vulnerabilitiesRequestHandler = createVulnerabilitiesRequestHandler({ hasNextPage: true });
describe('Vulnerability counts component', () => { describe('Vulnerability counts component', () => {
let wrapper; let wrapper;
const createWrapper = ({ queries }) => { const createWrapper = ({
countsHandler = countsRequestHandler,
vulnerabilitiesHandler = vulnerabilitiesRequestHandler,
canViewFalsePositive = true,
} = {}) => {
// Use the default request handlers if they weren't provided.
const queries = [
[countsQuery, countsHandler],
[vulnerabilitiesQuery, vulnerabilitiesHandler],
];
wrapper = shallowMountExtended(VulnerabilityReportDevelopment, { wrapper = shallowMountExtended(VulnerabilityReportDevelopment, {
localVue, localVue,
apolloProvider: createMockApollo(queries), apolloProvider: createMockApollo(queries),
provide: { groupFullPath }, provide: {
groupFullPath,
canViewFalsePositive,
canAdminVulnerability: true,
hasProjects: true,
},
}); });
}; };
const findVulnerabilityCounts = () => wrapper.findComponent(VulnerabilityCounts); const findVulnerabilityCounts = () => wrapper.findComponent(VulnerabilityCounts);
const findVulnerabilityList = () => wrapper.findComponent(VulnerabilityList);
const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
countsRequestHandler.mockClear(); countsRequestHandler.mockClear();
vulnerabilitiesRequestHandler.mockClear();
}); });
describe('vulnerability counts query', () => { describe('vulnerability counts query', () => {
it('calls the counts query once with the expected data', () => { it('calls the query once with the expected data', () => {
createWrapper({ queries: [[countsQuery, countsRequestHandler]] }); createWrapper();
expect(countsRequestHandler).toHaveBeenCalledTimes(1); expect(countsRequestHandler).toHaveBeenCalledTimes(1);
expect(countsRequestHandler).toHaveBeenCalledWith( expect(countsRequestHandler).toHaveBeenCalledWith(
...@@ -52,8 +90,22 @@ describe('Vulnerability counts component', () => { ...@@ -52,8 +90,22 @@ describe('Vulnerability counts component', () => {
); );
}); });
it('passes the isLoading prop with the expected values', async () => { it('shows an error message if the query fails', async () => {
createWrapper({ queries: [[countsQuery, countsRequestHandler]] }); const countsHandler = jest.fn().mockRejectedValue(new Error());
createWrapper({ countsHandler });
// Have to wait 2 ticks here, one for the query to finish loading, and one more for the
// GraphQL error handler to be called.
await nextTick();
await nextTick();
expect(createFlash).toHaveBeenCalledTimes(1);
});
});
describe('vulnerability counts component', () => {
it('gets the expected isLoading prop from the counts query loading state', async () => {
createWrapper();
// The query will be loading until we use nextTick() to give micro-tasks a chance to run.
expect(findVulnerabilityCounts().props('isLoading')).toBe(true); expect(findVulnerabilityCounts().props('isLoading')).toBe(true);
await nextTick(); await nextTick();
...@@ -61,12 +113,98 @@ describe('Vulnerability counts component', () => { ...@@ -61,12 +113,98 @@ describe('Vulnerability counts component', () => {
expect(findVulnerabilityCounts().props('isLoading')).toBe(false); expect(findVulnerabilityCounts().props('isLoading')).toBe(false);
}); });
it('passes the results of the counts query to the vulnerability counts component', async () => { it('gets the expected counts prop', async () => {
createWrapper({ queries: [[countsQuery, countsRequestHandler]] }); createWrapper();
await nextTick(); await nextTick();
expect(findVulnerabilityCounts().props('counts')).toMatchObject(counts); expect(findVulnerabilityCounts().props('counts')).toMatchObject(counts);
}); });
}); });
describe('group vulnerabilities query', () => {
it('calls the query once with the expected fullPath variable', () => {
createWrapper();
expect(vulnerabilitiesRequestHandler).toHaveBeenCalledTimes(1);
expect(vulnerabilitiesRequestHandler).toHaveBeenCalledWith(
expect.objectContaining({ fullPath: groupFullPath }),
);
});
it.each([true, false])(
'calls the query with the expected vetEnabled property when canViewFalsePositive is %s',
(canViewFalsePositive) => {
createWrapper({ canViewFalsePositive });
expect(vulnerabilitiesRequestHandler).toHaveBeenCalledWith(
expect.objectContaining({ vetEnabled: canViewFalsePositive }),
);
},
);
it('shows an error message if the query fails', async () => {
const vulnerabilitiesHandler = jest.fn().mockRejectedValue(new Error());
createWrapper({ vulnerabilitiesHandler });
// Have to wait 2 ticks here, one for the query to finish loading, and one more for the
// GraphQL error handler to be called.
await nextTick();
await nextTick();
expect(createFlash).toHaveBeenCalledTimes(1);
});
});
describe('vulnerability list component', () => {
it('gets the expected vulnerabilities prop', async () => {
createWrapper();
await nextTick();
const vulnerabilities = [];
wrapper.setData({ vulnerabilities });
await nextTick();
expect(findVulnerabilityList().props('vulnerabilities')).toBe(vulnerabilities);
});
it('calls the vulnerabilities query with the data from the sort-changed event', async () => {
createWrapper();
// First call should be undefined, which uses the default sort.
expect(vulnerabilitiesRequestHandler).toHaveBeenCalledWith(
expect.objectContaining({ sort: undefined }),
);
const sort = 'sort';
findVulnerabilityList().vm.$emit('sort-changed', sort);
await nextTick();
expect(vulnerabilitiesRequestHandler).toHaveBeenCalledWith(expect.objectContaining({ sort }));
});
});
describe('intersection observer', () => {
it('is not shown when the vulnerabilities query is loading for the first time', () => {
createWrapper();
expect(findIntersectionObserver().exists()).toBe(false);
});
it('will fetch more data when the appear event is fired', async () => {
createWrapper();
await nextTick();
const spy = jest.spyOn(wrapper.vm.$apollo.queries.vulnerabilities, 'fetchMore');
findIntersectionObserver().vm.$emit('appear');
expect(spy).toHaveBeenCalledTimes(1);
});
it('is not shown if there is no next page', async () => {
createWrapper({
vulnerabilitiesHandler: createVulnerabilitiesRequestHandler({ hasNextPage: false }),
});
await nextTick();
expect(findIntersectionObserver().exists()).toBe(false);
});
});
}); });
...@@ -80,7 +80,8 @@ RSpec.describe Groups::SecurityFeaturesHelper do ...@@ -80,7 +80,8 @@ RSpec.describe Groups::SecurityFeaturesHelper do
vulnerabilities_export_endpoint: "/api/v4/security/groups/#{group.id}/vulnerability_exports", vulnerabilities_export_endpoint: "/api/v4/security/groups/#{group.id}/vulnerability_exports",
scanners: '[]', scanners: '[]',
can_admin_vulnerability: 'true', can_admin_vulnerability: 'true',
can_view_false_positive: 'false' can_view_false_positive: 'false',
has_projects: 'false'
} }
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment