Commit 621c2a08 authored by Daniel Tian's avatar Daniel Tian Committed by Savas Vedova

Move vulnerabilities count query into count component

parent eb04b06a
<script> <script>
import { GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui'; import { GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
import produce from 'immer'; import produce from 'immer';
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 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';
...@@ -25,35 +24,13 @@ export default { ...@@ -25,35 +24,13 @@ export default {
inject: ['groupFullPath', 'canViewFalsePositive', 'canAdminVulnerability', 'hasProjects'], inject: ['groupFullPath', 'canViewFalsePositive', 'canAdminVulnerability', 'hasProjects'],
data() { data() {
return { return {
counts: {},
vulnerabilities: [], vulnerabilities: [],
filters: [], filters: undefined,
sort: undefined, sort: undefined,
pageInfo: undefined, pageInfo: undefined,
}; };
}, },
apollo: { apollo: {
counts: {
query: countsQuery,
errorPolicy: 'none',
variables() {
return {
fullPath: this.groupFullPath,
isGroup: true,
...this.filters,
};
},
update({ group }) {
return group.vulnerabilitySeveritiesCount;
},
error() {
createFlash({
message: s__(
'SecurityReports|Error fetching the vulnerability counts. Please check your network connection and try again.',
),
});
},
},
vulnerabilities: { vulnerabilities: {
query: vulnerabilitiesQuery, query: vulnerabilitiesQuery,
errorPolicy: 'none', errorPolicy: 'none',
...@@ -76,12 +53,12 @@ export default { ...@@ -76,12 +53,12 @@ export default {
), ),
}); });
}, },
skip() {
return !this.filters;
}, },
}, },
computed: {
isLoadingCounts() {
return this.$apollo.queries.counts.loading;
}, },
computed: {
// Used to show the loading icon at the bottom of the vulnerabilities list. // Used to show the loading icon at the bottom of the vulnerabilities list.
isLoadingVulnerabilities() { isLoadingVulnerabilities() {
return this.$apollo.queries.vulnerabilities.loading; return this.$apollo.queries.vulnerabilities.loading;
...@@ -148,7 +125,7 @@ export default { ...@@ -148,7 +125,7 @@ export default {
<div v-else> <div v-else>
<vulnerability-report-header /> <vulnerability-report-header />
<vulnerability-counts class="gl-mt-7" :counts="counts" :is-loading="isLoadingCounts" /> <vulnerability-counts class="gl-mt-7" :filters="filters" />
<vulnerability-filters <vulnerability-filters
:filters="$options.filtersToShow" :filters="$options.filtersToShow"
......
<script> <script>
import { GlCard, GlSkeletonLoading } from '@gitlab/ui'; import { GlCard, GlSkeletonLoading } from '@gitlab/ui';
import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue'; import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue';
import { SEVERITIES } from 'ee/security_dashboard/store/modules/vulnerabilities/constants'; import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import countsQuery from 'ee/security_dashboard/graphql/queries/vulnerability_severities_count.query.graphql';
import { SEVERITIES } from '~/vulnerabilities/constants';
export default { export default {
components: { GlCard, GlSkeletonLoading, SeverityBadge }, components: { GlCard, GlSkeletonLoading, SeverityBadge },
inject: ['fullPath', 'dashboardType'],
props: { props: {
counts: { filters: {
type: Object, type: Object,
required: true,
},
isLoading: {
type: Boolean,
required: false, required: false,
default: false, default: null,
},
},
data() {
return {
counts: {},
};
},
apollo: {
counts: {
query: countsQuery,
errorPolicy: 'none',
variables() {
return {
fullPath: this.fullPath,
isProject: this.dashboardType === DASHBOARD_TYPES.PROJECT,
isGroup: this.dashboardType === DASHBOARD_TYPES.GROUP,
isInstance: this.dashboardType === DASHBOARD_TYPES.INSTANCE,
...this.filters,
};
},
update(data) {
return data[this.dashboardType].vulnerabilitySeveritiesCount;
},
error() {
createFlash({
message: s__(
'SecurityReports|Error fetching the vulnerability counts. Please check your network connection and try again.',
),
});
},
skip() {
return !this.filters;
},
}, },
}, },
computed: { computed: {
isLoadingCounts() {
return !this.filters || this.$apollo.queries.counts.loading;
},
severityCounts() { severityCounts() {
return SEVERITIES.map((severity) => ({ return SEVERITIES.map((severity) => ({
severity, severity,
...@@ -39,9 +76,10 @@ export default { ...@@ -39,9 +76,10 @@ export default {
<template #header> <template #header>
<severity-badge :severity="severity" class="gl-text-center!" /> <severity-badge :severity="severity" class="gl-text-center!" />
</template> </template>
<template #default> <template #default>
<gl-skeleton-loading <gl-skeleton-loading
v-if="isLoading" v-if="isLoadingCounts"
:lines="1" :lines="1"
class="gl-display-flex gl-align-items-center" class="gl-display-flex gl-align-items-center"
/> />
......
...@@ -77,6 +77,7 @@ export default (el, dashboardType) => { ...@@ -77,6 +77,7 @@ export default (el, dashboardType) => {
vulnerabilitiesExportEndpoint, vulnerabilitiesExportEndpoint,
groupFullPath, groupFullPath,
projectFullPath, projectFullPath,
fullPath: projectFullPath || groupFullPath,
autoFixDocumentation, autoFixDocumentation,
autoFixMrsPath, autoFixMrsPath,
canAdminVulnerability: parseBoolean(canAdminVulnerability), canAdminVulnerability: parseBoolean(canAdminVulnerability),
......
...@@ -7,7 +7,6 @@ import VulnerabilityReportDevelopment from 'ee/security_dashboard/components/gro ...@@ -7,7 +7,6 @@ import VulnerabilityReportDevelopment from 'ee/security_dashboard/components/gro
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 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 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'; import createFlash from '~/flash';
...@@ -17,17 +16,8 @@ jest.mock('~/flash'); ...@@ -17,17 +16,8 @@ jest.mock('~/flash');
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(VueApollo); localVue.use(VueApollo);
const counts = { critical: 1, high: 2, info: 3, low: 4, medium: 5, unknown: 6 };
const groupFullPath = 'path'; const groupFullPath = 'path';
const countsRequestHandler = jest.fn().mockResolvedValue({
data: {
group: {
vulnerabilitySeveritiesCount: counts,
},
},
});
const createVulnerabilitiesRequestHandler = ({ hasNextPage }) => const createVulnerabilitiesRequestHandler = ({ hasNextPage }) =>
jest.fn().mockResolvedValue({ jest.fn().mockResolvedValue({
data: { data: {
...@@ -46,79 +36,39 @@ describe('Vulnerability counts component', () => { ...@@ -46,79 +36,39 @@ describe('Vulnerability counts component', () => {
let wrapper; let wrapper;
const createWrapper = ({ const createWrapper = ({
countsHandler = countsRequestHandler,
vulnerabilitiesHandler = vulnerabilitiesRequestHandler, vulnerabilitiesHandler = vulnerabilitiesRequestHandler,
canViewFalsePositive = true, canViewFalsePositive = true,
filters = {},
} = {}) => { } = {}) => {
// 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([[vulnerabilitiesQuery, vulnerabilitiesHandler]]),
provide: { provide: {
groupFullPath, groupFullPath,
canViewFalsePositive, 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 findVulnerabilityList = () => wrapper.findComponent(VulnerabilityList); const findVulnerabilityList = () => wrapper.findComponent(VulnerabilityList);
const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver); const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
countsRequestHandler.mockClear();
vulnerabilitiesRequestHandler.mockClear(); vulnerabilitiesRequestHandler.mockClear();
}); });
describe('vulnerability counts query', () => {
it('calls the query once with the expected data', () => {
createWrapper();
expect(countsRequestHandler).toHaveBeenCalledTimes(1);
expect(countsRequestHandler).toHaveBeenCalledWith(
expect.objectContaining({
isGroup: true,
fullPath: groupFullPath,
}),
);
});
it('shows an error message if the query fails', async () => {
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', () => { describe('vulnerability counts component', () => {
it('gets the expected isLoading prop from the counts query loading state', async () => { it('receives the filters prop from the filters component', () => {
createWrapper(); const filters = {}; // Object itself does not matter, we're only checking that it's passed.
// The query will be loading until we use nextTick() to give micro-tasks a chance to run. createWrapper({ filters });
expect(findVulnerabilityCounts().props('isLoading')).toBe(true);
await nextTick();
expect(findVulnerabilityCounts().props('isLoading')).toBe(false);
});
it('gets the expected counts prop', async () => {
createWrapper();
await nextTick();
expect(findVulnerabilityCounts().props('counts')).toMatchObject(counts); expect(findVulnerabilityCounts().props('filters')).toBe(filters);
}); });
}); });
...@@ -143,6 +93,12 @@ describe('Vulnerability counts component', () => { ...@@ -143,6 +93,12 @@ describe('Vulnerability counts component', () => {
}, },
); );
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 () => { it('shows an error message if the query fails', async () => {
const vulnerabilitiesHandler = jest.fn().mockRejectedValue(new Error()); const vulnerabilitiesHandler = jest.fn().mockRejectedValue(new Error());
createWrapper({ vulnerabilitiesHandler }); createWrapper({ vulnerabilitiesHandler });
...@@ -158,13 +114,11 @@ describe('Vulnerability counts component', () => { ...@@ -158,13 +114,11 @@ describe('Vulnerability counts component', () => {
describe('vulnerability list component', () => { describe('vulnerability list component', () => {
it('gets the expected vulnerabilities prop', async () => { it('gets the expected vulnerabilities prop', async () => {
createWrapper(); createWrapper();
await nextTick();
const vulnerabilities = []; const vulnerabilities = [];
wrapper.setData({ vulnerabilities }); await wrapper.setData({ vulnerabilities });
await nextTick();
expect(findVulnerabilityList().props('vulnerabilities')).toBe(vulnerabilities); expect(findVulnerabilityList().props('vulnerabilities')).toEqual(vulnerabilities);
}); });
it('calls the vulnerabilities query with the data from the sort-changed event', async () => { it('calls the vulnerabilities query with the data from the sort-changed event', async () => {
...@@ -203,6 +157,7 @@ describe('Vulnerability counts component', () => { ...@@ -203,6 +157,7 @@ describe('Vulnerability counts component', () => {
createWrapper({ createWrapper({
vulnerabilitiesHandler: createVulnerabilitiesRequestHandler({ hasNextPage: false }), vulnerabilitiesHandler: createVulnerabilitiesRequestHandler({ hasNextPage: false }),
}); });
await nextTick(); await nextTick();
expect(findIntersectionObserver().exists()).toBe(false); expect(findIntersectionObserver().exists()).toBe(false);
...@@ -210,20 +165,16 @@ describe('Vulnerability counts component', () => { ...@@ -210,20 +165,16 @@ describe('Vulnerability counts component', () => {
}); });
describe('vulnerability filters component', () => { describe('vulnerability filters component', () => {
it('will pass data from filters-changed event to GraphQL queries', async () => { it('will pass data from filters-changed event to vulnerabilities GraphQL query', async () => {
const countsHandler = jest.fn().mockResolvedValue();
const vulnerabilitiesHandler = jest.fn().mockResolvedValue(); const vulnerabilitiesHandler = jest.fn().mockResolvedValue();
createWrapper({ countsHandler, vulnerabilitiesHandler }); createWrapper({ vulnerabilitiesHandler });
// Sanity check, the report component will call these the first time it's mounted. // Sanity check, the report component will call this the first time it's mounted.
expect(countsHandler).toHaveBeenCalledTimes(1);
expect(vulnerabilitiesHandler).toHaveBeenCalledTimes(1); expect(vulnerabilitiesHandler).toHaveBeenCalledTimes(1);
const data = { a: 1 }; const data = { a: 1 };
wrapper.findComponent(VulnerabilityFilters).vm.$emit('filters-changed', data); findVulnerabilityFilters().vm.$emit('filters-changed', data);
await nextTick(); await nextTick();
expect(countsHandler).toHaveBeenCalledTimes(2);
expect(countsHandler).toHaveBeenCalledWith(expect.objectContaining(data));
expect(vulnerabilitiesHandler).toHaveBeenCalledTimes(2); expect(vulnerabilitiesHandler).toHaveBeenCalledTimes(2);
expect(vulnerabilitiesHandler).toHaveBeenCalledWith(expect.objectContaining(data)); expect(vulnerabilitiesHandler).toHaveBeenCalledWith(expect.objectContaining(data));
}); });
......
import { GlCard, GlSkeletonLoading } from '@gitlab/ui'; import { GlCard, GlSkeletonLoading } from '@gitlab/ui';
import { nextTick } from 'vue';
import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
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 { mountExtended } from 'helpers/vue_test_utils_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper';
import { import { DASHBOARD_TYPES, SEVERITY_LEVELS } from 'ee/security_dashboard/store/constants';
CRITICAL, import createFlash from '~/flash';
HIGH, import createMockApollo from 'helpers/mock_apollo_helper';
MEDIUM, import countsQuery from 'ee/security_dashboard/graphql/queries/vulnerability_severities_count.query.graphql';
INFO, import { SEVERITIES } from '~/vulnerabilities/constants';
LOW,
UNKNOWN, jest.mock('~/flash');
SEVERITIES,
} from 'ee/security_dashboard/store/modules/vulnerabilities/constants'; const localVue = createLocalVue();
import { SEVERITY_LEVELS } from 'ee/security_dashboard/store/constants'; localVue.use(VueApollo);
const fullPath = 'path';
const counts = { critical: 1, high: 2, info: 3, low: 4, medium: 5, unknown: 6 };
const getCountsRequestHandler = ({
data = counts,
dashboardType = DASHBOARD_TYPES.PROJECT,
} = {}) => {
return jest.fn().mockResolvedValue({
data: {
[dashboardType]: {
vulnerabilitySeveritiesCount: data,
},
},
});
};
const defaultCountsRequestHandler = getCountsRequestHandler();
describe('Vulnerability counts component', () => { describe('Vulnerability counts component', () => {
let wrapper; let wrapper;
const createWrapper = (props) => { const createWrapper = ({
dashboardType = DASHBOARD_TYPES.PROJECT,
filters = {},
countsHandler = defaultCountsRequestHandler,
} = {}) => {
wrapper = mountExtended(VulnerabilityCounts, { wrapper = mountExtended(VulnerabilityCounts, {
propsData: props, localVue,
apolloProvider: createMockApollo([[countsQuery, countsHandler]]),
provide: {
fullPath,
dashboardType,
},
propsData: { filters },
}); });
}; };
...@@ -28,17 +59,70 @@ describe('Vulnerability counts component', () => { ...@@ -28,17 +59,70 @@ describe('Vulnerability counts component', () => {
wrapper.destroy(); wrapper.destroy();
}); });
it('should show a skeleton loading component for each count when the isLoading prop is true', () => { describe('vulnerability counts query', () => {
createWrapper({ isLoading: true, counts: {} }); it('calls the query once with the expected data', () => {
const filters = { a: 1, b: 2 };
createWrapper({ filters });
expect(defaultCountsRequestHandler).toHaveBeenCalledTimes(1);
expect(defaultCountsRequestHandler).toHaveBeenCalledWith(
expect.objectContaining({ ...filters, fullPath }),
);
});
it('does not call the query if filters are not ready', () => {
createWrapper({ filters: null });
expect(defaultCountsRequestHandler).not.toHaveBeenCalled();
});
it('shows an error message if the query fails', async () => {
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);
});
it.each([DASHBOARD_TYPES.PROJECT, DASHBOARD_TYPES.GROUP, DASHBOARD_TYPES.INSTANCE])(
'sets the correct variable for the %s dashboard',
async (dashboardType) => {
createWrapper({ dashboardType });
await nextTick();
expect(defaultCountsRequestHandler).toHaveBeenCalledWith(
expect.objectContaining({
isProject: dashboardType === DASHBOARD_TYPES.PROJECT,
isGroup: dashboardType === DASHBOARD_TYPES.GROUP,
isInstance: dashboardType === DASHBOARD_TYPES.INSTANCE,
}),
);
},
);
});
it('shows a skeleton loading component for each count when the query is loading', () => {
createWrapper();
findCards().wrappers.forEach((card) => {
expect(card.findComponent(GlSkeletonLoading).exists()).toBe(true);
});
});
it('shows a skeleton loading component for each count when there are no filters', () => {
createWrapper({ filters: null });
findCards().wrappers.forEach((card) => { findCards().wrappers.forEach((card) => {
expect(card.findComponent(GlSkeletonLoading).exists()).toBe(true); expect(card.findComponent(GlSkeletonLoading).exists()).toBe(true);
}); });
}); });
it('should show a card for each severity with the correct count', () => { it('should show a card for each severity with the correct count', async () => {
const counts = { [CRITICAL]: 1, [HIGH]: 2, [MEDIUM]: 3, [LOW]: 4, [INFO]: 5, [UNKNOWN]: 6 }; createWrapper();
createWrapper({ counts }); await nextTick();
// Check that there are exactly the same number of cards as there are severities. // Check that there are exactly the same number of cards as there are severities.
expect(findCards()).toHaveLength(Object.keys(counts).length); expect(findCards()).toHaveLength(Object.keys(counts).length);
...@@ -51,8 +135,13 @@ describe('Vulnerability counts component', () => { ...@@ -51,8 +135,13 @@ describe('Vulnerability counts component', () => {
}); });
}); });
it('should show zero for the count when there is no value for that severity', () => { it('should show zero for the count when there is no value for that severity', async () => {
createWrapper({ counts: {} }); const handler = getCountsRequestHandler({ data: {} });
createWrapper({ countsHandler: handler });
// Have to wait 2 ticks here, one for the query to finish loading, and one more for the
// computed property to update.
await nextTick();
await nextTick();
SEVERITIES.forEach((severity) => { SEVERITIES.forEach((severity) => {
expect(findCardWithSeverity(severity).text()).toContain('0'); expect(findCardWithSeverity(severity).text()).toContain('0');
......
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