Commit 56dfb49c authored by Daniel Tian's avatar Daniel Tian Committed by Savas Vedova

Move vulnerabilities GraphQL into its own component

parent fff3d03e
<script> <script>
import { GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
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 createFlash from '~/flash';
import { s__ } from '~/locale';
import VulnerabilityReportHeader from '../shared/vulnerability_report/vulnerability_report_header.vue'; import VulnerabilityReportHeader from '../shared/vulnerability_report/vulnerability_report_header.vue';
import ReportNotConfiguredGroup from '../shared/empty_states/report_not_configured_group.vue'; import ReportNotConfiguredGroup from '../shared/empty_states/report_not_configured_group.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 VulnerabilityListGraphql from '../shared/vulnerability_report/vulnerability_list_graphql.vue';
import VulnerabilityFilters from '../shared/vulnerability_report/vulnerability_filters.vue'; import VulnerabilityFilters from '../shared/vulnerability_report/vulnerability_filters.vue';
import { FIELDS, FILTERS } from '../shared/vulnerability_report/constants'; import { FIELDS, FILTERS } from '../shared/vulnerability_report/constants';
export default { export default {
components: { components: {
VulnerabilityCounts, VulnerabilityCounts,
VulnerabilityList, VulnerabilityListGraphql,
VulnerabilityFilters, VulnerabilityFilters,
GlIntersectionObserver,
GlLoadingIcon,
ReportNotConfiguredGroup, ReportNotConfiguredGroup,
VulnerabilityReportHeader, VulnerabilityReportHeader,
}, },
inject: ['groupFullPath', 'canViewFalsePositive', 'canAdminVulnerability', 'hasProjects'], inject: ['canAdminVulnerability', 'hasProjects'],
data() { data() {
return { return {
vulnerabilities: [],
filters: undefined, filters: undefined,
sort: undefined,
pageInfo: undefined,
}; };
}, },
apollo: {
vulnerabilities: {
query: vulnerabilitiesQuery,
errorPolicy: 'none',
variables() {
return {
fullPath: this.groupFullPath,
sort: this.sort,
vetEnabled: this.canViewFalsePositive,
...this.filters,
};
},
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.',
),
});
},
skip() {
return !this.filters;
},
},
},
computed: { computed: {
// 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() { fields() {
return [ return [
// Add the checkbox field if the user can use the bulk select feature. // Add the checkbox field if the user can use the bulk select feature.
...@@ -85,29 +37,9 @@ export default { ...@@ -85,29 +37,9 @@ export default {
}, },
}, },
methods: { methods: {
updateSort(sort) {
// Clear out the vulnerabilities so that the skeleton loader is shown.
this.vulnerabilities = [];
this.sort = sort;
},
updateFilters(filters) { updateFilters(filters) {
// Clear out the vulnerabilities so that the skeleton loader is shown.
this.vulnerabilities = [];
this.filters = filters; this.filters = filters;
}, },
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,
];
});
},
});
},
}, },
filtersToShow: [ filtersToShow: [
FILTERS.STATUS, FILTERS.STATUS,
...@@ -116,6 +48,7 @@ export default { ...@@ -116,6 +48,7 @@ export default {
FILTERS.ACTIVITY, FILTERS.ACTIVITY,
FILTERS.PROJECT, FILTERS.PROJECT,
], ],
vulnerabilitiesQuery,
}; };
</script> </script>
...@@ -133,17 +66,12 @@ export default { ...@@ -133,17 +66,12 @@ export default {
@filters-changed="updateFilters" @filters-changed="updateFilters"
/> />
<vulnerability-list <vulnerability-list-graphql
class="gl-mt-6" class="gl-mt-6"
:is-loading="isLoadingInitialVulnerabilities" :query="$options.vulnerabilitiesQuery"
:vulnerabilities="vulnerabilities"
:fields="fields" :fields="fields"
should-show-project-namespace :filters="filters"
@sort-changed="updateSort" show-project-namespace
/> />
<gl-intersection-observer v-if="hasNextPage" @appear="fetchNextPage">
<gl-loading-icon v-if="isLoadingVulnerabilities" size="md" />
</gl-intersection-observer>
</div> </div>
</template> </template>
<script>
import { GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui';
import { produce } from 'immer';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import VulnerabilityList from './vulnerability_list.vue';
// Deep searches an object for a key called 'vulnerabilities'. If it's not found, it will traverse
// down the object's first property until either it's found, or there's nothing left to search. Note
// that this will only check the first property of any object, not all of them.
const deepFindVulnerabilities = (data) => {
let currentData = data;
while (currentData !== undefined && currentData.vulnerabilities === undefined) {
[currentData] = Object.values(currentData);
}
return currentData?.vulnerabilities;
};
export default {
components: { GlLoadingIcon, GlIntersectionObserver, VulnerabilityList },
inject: ['fullPath', 'canViewFalsePositive'],
props: {
query: {
type: Object,
required: true,
},
filters: {
type: Object,
required: false,
default: null,
},
fields: {
type: Array,
required: true,
},
showProjectNamespace: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
vulnerabilities: [],
sort: undefined,
pageInfo: undefined,
};
},
apollo: {
vulnerabilities: {
query() {
return this.query;
},
errorPolicy: 'none',
variables() {
return {
fullPath: this.fullPath,
sort: this.sort,
vetEnabled: this.canViewFalsePositive,
...this.filters,
};
},
update(data) {
const vulnerabilities = deepFindVulnerabilities(data);
this.pageInfo = vulnerabilities.pageInfo;
return vulnerabilities.nodes;
},
error() {
createFlash({
message: s__(
'SecurityReports|Error fetching the vulnerability list. Please check your network connection and try again.',
),
});
},
skip() {
return !this.filters;
},
},
},
computed: {
// Used to show the infinite scrolling loading spinner.
isLoadingVulnerabilities() {
return this.$apollo.queries.vulnerabilities.loading;
},
// Used to show the initial skeleton loader.
isLoadingInitialVulnerabilities() {
return this.isLoadingVulnerabilities && this.vulnerabilities.length <= 0;
},
hasNextPage() {
return this.pageInfo?.hasNextPage;
},
},
watch: {
filters() {
// Clear out the vulnerabilities so that the skeleton loader is shown.
this.vulnerabilities = [];
},
},
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) => {
deepFindVulnerabilities(draftData).nodes.unshift(...this.vulnerabilities);
});
},
});
},
},
};
</script>
<template>
<div>
<vulnerability-list
:is-loading="isLoadingInitialVulnerabilities"
:vulnerabilities="vulnerabilities"
:fields="fields"
:should-show-project-namespace="showProjectNamespace"
@sort-changed="updateSort"
/>
<gl-intersection-observer v-if="hasNextPage" @appear="fetchNextPage">
<gl-loading-icon v-if="isLoadingVulnerabilities" size="md" />
</gl-intersection-observer>
</div>
</template>
import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import { GlIntersectionObserver } from '@gitlab/ui'; import VulnerabilityListGraphql from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_list_graphql.vue';
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 VulnerabilityFilters from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_filters.vue'; import VulnerabilityFilters from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_filters.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import vulnerabilitiesQuery from 'ee/security_dashboard/graphql/queries/group_vulnerabilities.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import createFlash from '~/flash';
jest.mock('~/flash');
const localVue = createLocalVue();
localVue.use(VueApollo);
const groupFullPath = 'path';
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 = ({ const createWrapper = () => {
vulnerabilitiesHandler = vulnerabilitiesRequestHandler,
canViewFalsePositive = true,
filters = {},
} = {}) => {
wrapper = shallowMountExtended(VulnerabilityReportDevelopment, { wrapper = shallowMountExtended(VulnerabilityReportDevelopment, {
localVue,
apolloProvider: createMockApollo([[vulnerabilitiesQuery, vulnerabilitiesHandler]]),
provide: { provide: {
groupFullPath,
canViewFalsePositive,
canAdminVulnerability: true, canAdminVulnerability: true,
hasProjects: true, hasProjects: true,
}, },
data: () => ({ filters }),
}); });
}; };
const findVulnerabilityCounts = () => wrapper.findComponent(VulnerabilityCounts); const findVulnerabilityCounts = () => wrapper.findComponent(VulnerabilityCounts);
const findVulnerabilityFilters = () => wrapper.findComponent(VulnerabilityFilters); const findVulnerabilityFilters = () => wrapper.findComponent(VulnerabilityFilters);
const findVulnerabilityList = () => wrapper.findComponent(VulnerabilityList); const findVulnerabilityListGraphql = () => wrapper.findComponent(VulnerabilityListGraphql);
const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
vulnerabilitiesRequestHandler.mockClear();
});
describe('vulnerability counts component', () => {
it('receives the filters prop from the filters component', () => {
const filters = {}; // Object itself does not matter, we're only checking that it's passed.
createWrapper({ filters });
expect(findVulnerabilityCounts().props('filters')).toBe(filters);
});
});
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('does not call the query if filters are not ready', () => {
createWrapper({ filters: null });
expect(vulnerabilitiesRequestHandler).not.toHaveBeenCalled();
});
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();
const vulnerabilities = [];
await wrapper.setData({ vulnerabilities });
expect(findVulnerabilityList().props('vulnerabilities')).toEqual(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);
});
}); });
describe('vulnerability filters component', () => { describe('vulnerability filters component', () => {
it('will pass data from filters-changed event to vulnerabilities GraphQL query', async () => { it('will pass data from filters-changed event to the counts and list components', async () => {
const vulnerabilitiesHandler = jest.fn().mockResolvedValue(); createWrapper();
createWrapper({ vulnerabilitiesHandler });
// Sanity check, the report component will call this the first time it's mounted.
expect(vulnerabilitiesHandler).toHaveBeenCalledTimes(1);
const data = { a: 1 }; const data = { a: 1 };
findVulnerabilityFilters().vm.$emit('filters-changed', data); findVulnerabilityFilters().vm.$emit('filters-changed', data);
await nextTick(); await nextTick();
expect(vulnerabilitiesHandler).toHaveBeenCalledTimes(2); expect(findVulnerabilityCounts().props('filters')).toBe(data);
expect(vulnerabilitiesHandler).toHaveBeenCalledWith(expect.objectContaining(data)); expect(findVulnerabilityListGraphql().props('filters')).toBe(data);
}); });
}); });
}); });
import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import { nextTick } from 'vue';
import { GlIntersectionObserver } from '@gitlab/ui';
import VulnerabilityListGraphql from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_list_graphql.vue';
import VulnerabilityList from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_list.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import vulnerabilitiesQuery from 'ee/security_dashboard/graphql/queries/group_vulnerabilities.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import createFlash from '~/flash';
jest.mock('~/flash');
const localVue = createLocalVue();
localVue.use(VueApollo);
const fullPath = 'path';
const createVulnerabilitiesRequestHandler = ({ hasNextPage }) =>
jest.fn().mockResolvedValue({
data: {
group: {
vulnerabilities: {
nodes: [],
pageInfo: { endCursor: 'abc', hasNextPage },
},
},
},
});
const vulnerabilitiesRequestHandler = createVulnerabilitiesRequestHandler({ hasNextPage: true });
describe('Vulnerability list GraphQL component', () => {
let wrapper;
const createWrapper = ({
vulnerabilitiesHandler = vulnerabilitiesRequestHandler,
canViewFalsePositive = false,
showProjectNamespace = false,
filters = {},
fields = [],
} = {}) => {
wrapper = shallowMountExtended(VulnerabilityListGraphql, {
localVue,
apolloProvider: createMockApollo([[vulnerabilitiesQuery, vulnerabilitiesHandler]]),
provide: {
fullPath,
canViewFalsePositive,
},
propsData: {
query: vulnerabilitiesQuery,
filters,
fields,
showProjectNamespace,
},
});
};
const findVulnerabilityList = () => wrapper.findComponent(VulnerabilityList);
const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
afterEach(() => {
wrapper.destroy();
vulnerabilitiesRequestHandler.mockClear();
});
describe('vulnerabilities query', () => {
it('calls the query once with the expected fullPath variable', () => {
createWrapper();
expect(vulnerabilitiesRequestHandler).toHaveBeenCalledTimes(1);
expect(vulnerabilitiesRequestHandler).toHaveBeenCalledWith(
expect.objectContaining({ fullPath }),
);
});
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('does not call the query if filters are not ready', () => {
createWrapper({ filters: null });
expect(vulnerabilitiesRequestHandler).not.toHaveBeenCalled();
});
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 props', async () => {
const fields = ['abc'];
const showProjectNamespace = true;
createWrapper({ fields, showProjectNamespace });
expect(findVulnerabilityList().props('fields')).toBe(fields);
expect(findVulnerabilityList().props('shouldShowProjectNamespace')).toBe(
showProjectNamespace,
);
});
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);
});
});
});
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