diff --git a/.eslint_todo/vue-require-name-property.mjs b/.eslint_todo/vue-require-name-property.mjs index ea735e87929d71b9cf20b705bf6a9d571c1a5e21..dc9a2f03a8d4b5a47b5b5d4d46c96c3358954daa 100644 --- a/.eslint_todo/vue-require-name-property.mjs +++ b/.eslint_todo/vue-require-name-property.mjs @@ -1944,7 +1944,6 @@ export default { 'ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_counts.vue', 'ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_filters.vue', 'ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_list.vue', - 'ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_list_graphql.vue', 'ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_list_status.vue', 'ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_report_header.vue', 'ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_report_tab.vue', diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_list.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_list.vue index e76b02bc7eebd84c6b68a7c26a79ddf4747d3a7c..34d759633ed6aad48885ee5a57e39e275370e69f 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_list.vue +++ b/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_list.vue @@ -128,6 +128,11 @@ export default { sortDesc: true, }), }, + loadingMoreCount: { + type: Number, + required: false, + default: 0, + }, }, data() { return { @@ -607,7 +612,13 @@ export default { - - +
+ +
+ + diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_list_graphql.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_list_graphql.vue index 8fe77102f4d61472d498df2dd89807c4c34e9f3d..3ec61e2511d214de185b6094f5b8f46735441b59 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_list_graphql.vue +++ b/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_list_graphql.vue @@ -1,6 +1,7 @@ @@ -250,12 +337,12 @@ export default { :page-size="pageSize" :should-show-project-namespace="showProjectNamespace" :portal-name="portalName" + :loading-more-count="secondBatchLoadingCount" @vulnerability-clicked="$emit('vulnerability-clicked', $event)" /> -
diff --git a/ee/spec/frontend/security_dashboard/components/shared/vulnerability_report/vulnerability_list_graphql_spec.js b/ee/spec/frontend/security_dashboard/components/shared/vulnerability_report/vulnerability_list_graphql_spec.js index 43716aca5bfa8e34e9702723c2d57a7b4c8dae75..f9ca89d4df6d1202646d4d32ce16a32c33827f3d 100644 --- a/ee/spec/frontend/security_dashboard/components/shared/vulnerability_report/vulnerability_list_graphql_spec.js +++ b/ee/spec/frontend/security_dashboard/components/shared/vulnerability_report/vulnerability_list_graphql_spec.js @@ -5,9 +5,9 @@ import VueRouter from 'vue-router'; import { shallowMount } from '@vue/test-utils'; import VulnerabilityListGraphql, { PAGE_SIZE_STORAGE_KEY, + MAX_GRAPHQL_REQUEST_SIZE, } 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 { isPolicyViolationFilterEnabled } from 'ee/security_dashboard/utils'; import PageSizeSelector from '~/vue_shared/components/page_size_selector.vue'; import { DEFAULT_PAGE_SIZE } from '~/vue_shared/issuable/list/constants'; import groupVulnerabilitiesQuery from 'ee/security_dashboard/graphql/queries/group_vulnerabilities.query.graphql'; @@ -20,7 +20,6 @@ import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import { vulnerabilities } from '../../mock_data'; jest.mock('~/alert'); -jest.mock('ee/security_dashboard/utils'); Vue.use(VueApollo); Vue.use(VueRouter); @@ -443,45 +442,232 @@ describe('Vulnerability list GraphQL component', () => { expect(findLocalStorageSync().props('value')).toBe(pageSize); }); + }); + + describe('sequential batch loading', () => { + const generateVulnerabilities = (count) => + Array.from({ length: count }, (_, i) => ({ + ...vulnerabilities[0], + id: `id_${i}`, + project: { id: `project-${i}`, nameWithNamespace: 'Test / Project' }, + })); + + const createResponse = (nodes, pageInfo = {}) => ({ + data: { + group: { + id: 'group-1', + __typename: 'Group', + vulnerabilities: { + nodes, + pageInfo: { + __typename: 'PageInfo', + startCursor: 'start', + endCursor: 'end', + hasNextPage: false, + hasPreviousPage: false, + ...pageInfo, + }, + }, + }, + }, + }); - it('is shown when loaded and there are no vulnerabilities', async () => { - createWrapper({ vulnerabilitiesHandler: createVulnerabilitiesRequestHandler({}, []) }); + it('when page size is less than or equal to MAX_GRAPHQL_REQUEST_SIZE, does not batch', async () => { + createWrapper(); await waitForPromises(); - expect(findPageSizeSelector().exists()).toBe(true); + expect(vulnerabilitiesRequestHandler).toHaveBeenCalledTimes(1); }); - }); - describe('limit large page size', () => { - const scenarios = [ - { - description: 'when aiExperimentSastFpDetection is enabled', - setup: () => ({ aiExperimentSastFpDetection: true }), - }, - { - description: 'when isPolicyViolationFilterEnabled() is true', - setup: () => { - isPolicyViolationFilterEnabled.mockReturnValue(true); - return {}; - }, - }, - { - description: 'when autoDismissVulnerabilityPolicies is enabled', - setup: () => ({ autoDismissVulnerabilityPolicies: true }), - }, - ]; + describe('when page size is greater than MAX_GRAPHQL_REQUEST_SIZE', () => { + const BATCH_PAGE_SIZE = 100; + + beforeEach(() => { + localStorage.setItem(PAGE_SIZE_STORAGE_KEY, BATCH_PAGE_SIZE); + }); + + it('uses MAX_GRAPHQL_REQUEST_SIZE for first request when page size exceeds it', async () => { + const handler = jest.fn().mockResolvedValue( + createResponse(generateVulnerabilities(MAX_GRAPHQL_REQUEST_SIZE), { + endCursor: 'end-1', + hasNextPage: true, + }), + ); + createWrapper({ vulnerabilitiesHandler: handler }); + await waitForPromises(); + + expect(handler).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ first: MAX_GRAPHQL_REQUEST_SIZE }), + ); + }); + + it('fetches second batch after first batch loads when batching is enabled', async () => { + const handler = jest + .fn() + .mockResolvedValueOnce( + createResponse(generateVulnerabilities(MAX_GRAPHQL_REQUEST_SIZE), { + endCursor: 'end-1', + hasNextPage: true, + }), + ) + .mockResolvedValueOnce( + createResponse(generateVulnerabilities(MAX_GRAPHQL_REQUEST_SIZE), { + hasNextPage: false, + }), + ); + createWrapper({ vulnerabilitiesHandler: handler }); + await waitForPromises(); + + expect(handler).toHaveBeenCalledTimes(2); + expect(handler).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ after: 'end-1', first: MAX_GRAPHQL_REQUEST_SIZE }), + ); + }); + + it('does not fetch second batch when first batch has fewer items than MAX_GRAPHQL_REQUEST_SIZE', async () => { + const handler = jest + .fn() + .mockResolvedValue(createResponse(generateVulnerabilities(30), { hasNextPage: false })); + createWrapper({ vulnerabilitiesHandler: handler }); + await waitForPromises(); + + expect(handler).toHaveBeenCalledTimes(1); + }); - describe.each(scenarios)('$description', ({ setup }) => { - it('sets excludeLargePageSize on page size selector', () => { - createWrapper(setup()); - expect(findPageSizeSelector().props('excludeLargePageSize')).toBe(true); + it('passes loadingMoreCount to VulnerabilityList while loading second batch', async () => { + let resolveSecondBatch; + const handler = jest + .fn() + .mockResolvedValueOnce( + createResponse(generateVulnerabilities(MAX_GRAPHQL_REQUEST_SIZE), { + endCursor: 'end-1', + hasNextPage: true, + }), + ) + .mockReturnValueOnce( + new Promise((resolve) => { + resolveSecondBatch = resolve; + }), + ); + createWrapper({ vulnerabilitiesHandler: handler }); + await waitForPromises(); + + expect(findVulnerabilityList().props('loadingMoreCount')).toBe( + BATCH_PAGE_SIZE - MAX_GRAPHQL_REQUEST_SIZE, + ); + + resolveSecondBatch( + createResponse(generateVulnerabilities(MAX_GRAPHQL_REQUEST_SIZE), { hasNextPage: false }), + ); + await waitForPromises(); + + expect(findVulnerabilityList().props('loadingMoreCount')).toBe(0); + }); + + it('merges both batches into vulnerabilities', async () => { + const handler = jest + .fn() + .mockResolvedValueOnce( + createResponse(generateVulnerabilities(MAX_GRAPHQL_REQUEST_SIZE), { + endCursor: 'end-1', + hasNextPage: true, + }), + ) + .mockResolvedValueOnce( + createResponse(generateVulnerabilities(MAX_GRAPHQL_REQUEST_SIZE), { + hasNextPage: false, + }), + ); + createWrapper({ vulnerabilitiesHandler: handler }); + await waitForPromises(); + + expect(findVulnerabilityList().props('vulnerabilities')).toHaveLength( + MAX_GRAPHQL_REQUEST_SIZE * 2, + ); + }); + + it('uses MAX_GRAPHQL_REQUEST_SIZE for backward pagination when page size exceeds it', async () => { + const handler = jest.fn().mockResolvedValue( + createResponse(generateVulnerabilities(MAX_GRAPHQL_REQUEST_SIZE), { + startCursor: 'start-1', + hasPreviousPage: true, + }), + ); + await router.push({ query: { before: 'some-cursor' } }); + createWrapper({ vulnerabilitiesHandler: handler }); + await waitForPromises(); + + expect(handler).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ last: MAX_GRAPHQL_REQUEST_SIZE, first: null }), + ); + }); + + it('fetches second batch for backward pagination when batching is enabled', async () => { + const handler = jest + .fn() + .mockResolvedValueOnce( + createResponse(generateVulnerabilities(MAX_GRAPHQL_REQUEST_SIZE), { + startCursor: 'start-1', + hasPreviousPage: true, + }), + ) + .mockResolvedValueOnce( + createResponse(generateVulnerabilities(MAX_GRAPHQL_REQUEST_SIZE), { + hasPreviousPage: false, + }), + ); + await router.push({ query: { before: 'some-cursor' } }); + createWrapper({ vulnerabilitiesHandler: handler }); + await waitForPromises(); + + expect(handler).toHaveBeenCalledTimes(2); + expect(handler).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + before: 'start-1', + last: MAX_GRAPHQL_REQUEST_SIZE, + first: null, + }), + ); + }); + + it('does not fetch second batch for backward pagination when first batch has fewer items', async () => { + const handler = jest + .fn() + .mockResolvedValue( + createResponse(generateVulnerabilities(30), { hasPreviousPage: false }), + ); + await router.push({ query: { before: 'some-cursor' } }); + createWrapper({ vulnerabilitiesHandler: handler }); + await waitForPromises(); + + expect(handler).toHaveBeenCalledTimes(1); }); - it('changes page size from 100 to 50', () => { - localStorage.setItem(PAGE_SIZE_STORAGE_KEY, 100); - createWrapper(setup()); + it('merges both batches correctly for backward pagination', async () => { + const handler = jest + .fn() + .mockResolvedValueOnce( + createResponse(generateVulnerabilities(MAX_GRAPHQL_REQUEST_SIZE), { + startCursor: 'start-1', + hasPreviousPage: true, + }), + ) + .mockResolvedValueOnce( + createResponse(generateVulnerabilities(MAX_GRAPHQL_REQUEST_SIZE), { + hasPreviousPage: false, + }), + ); + await router.push({ query: { before: 'some-cursor' } }); + createWrapper({ vulnerabilitiesHandler: handler }); + await waitForPromises(); - expect(findPageSizeSelector().props('value')).toBe(50); + expect(findVulnerabilityList().props('vulnerabilities')).toHaveLength( + MAX_GRAPHQL_REQUEST_SIZE * 2, + ); }); }); }); diff --git a/ee/spec/frontend/security_dashboard/components/shared/vulnerability_report/vulnerability_list_spec.js b/ee/spec/frontend/security_dashboard/components/shared/vulnerability_report/vulnerability_list_spec.js index e100def6475cea9c4a6d09bb83dfbd34f2cd9449..2d93046144fabc3dbbb27ce4b0cc71719fe8e11d 100644 --- a/ee/spec/frontend/security_dashboard/components/shared/vulnerability_report/vulnerability_list_spec.js +++ b/ee/spec/frontend/security_dashboard/components/shared/vulnerability_report/vulnerability_list_spec.js @@ -637,6 +637,36 @@ describe('Vulnerability list component', () => { }); }); + describe('loadingMoreCount prop', () => { + const findLoadingMoreSkeleton = () => wrapper.findByTestId('loading-more-skeleton'); + + it('does not show loading more skeletons when loadingMoreCount is `0`', () => { + createWrapper({ props: { vulnerabilities, loadingMoreCount: 0 } }); + + expect(findLoadingMoreSkeleton().exists()).toBe(false); + }); + + it('shows skeleton loaders when loadingMoreCount is greater than `0`', () => { + const loadingMoreCount = 50; + createWrapper({ props: { vulnerabilities, loadingMoreCount } }); + + expect(findLoadingMoreSkeleton().exists()).toBe(true); + expect(findLoadingMoreSkeleton().findAllComponents(GlSkeletonLoader)).toHaveLength( + loadingMoreCount, + ); + }); + + it('shows vulnerabilities and loading skeletons together', () => { + const loadingMoreCount = 25; + createWrapper({ props: { vulnerabilities, loadingMoreCount } }); + + expect(findAllStatusCells()).toHaveLength(vulnerabilities.length); + expect(findLoadingMoreSkeleton().findAllComponents(GlSkeletonLoader)).toHaveLength( + loadingMoreCount, + ); + }); + }); + describe('operational vulnerabilities', () => { beforeEach(() => { createWrapper({ diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 9d1307399db059629fd600526bb829a6d04f0fe9..ec9035189e621a14c49033e658b3e0478071ab2e 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -62592,6 +62592,9 @@ msgstr "" msgid "SecurityReports|Enter at least 3 characters to view available identifiers." msgstr "" +msgid "SecurityReports|Error fetching additional vulnerabilities." +msgstr "" + msgid "SecurityReports|Error fetching the vulnerability counts. Please check your network connection and try again." msgstr ""