From 6c4d391041df08f85341f609126e73c3cafe6804 Mon Sep 17 00:00:00 2001 From: Dave Pisek Date: Wed, 24 Sep 2025 20:16:43 +0200 Subject: [PATCH 01/22] WIP: POC --- .../group_vulnerabilities_over_time_panel.vue | 139 +++++++++++++----- .../shared/over_time_period_selector.vue | 69 +++++++++ .../shared/over_time_severity_filter.vue | 2 +- 3 files changed, 173 insertions(+), 37 deletions(-) create mode 100644 ee/app/assets/javascripts/security_dashboard/components/shared/over_time_period_selector.vue diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/group_vulnerabilities_over_time_panel.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/group_vulnerabilities_over_time_panel.vue index ab7f614b15a39d..fe84ef790316db 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/shared/group_vulnerabilities_over_time_panel.vue +++ b/ee/app/assets/javascripts/security_dashboard/components/shared/group_vulnerabilities_over_time_panel.vue @@ -6,9 +6,9 @@ import { formatDate, getDateInPast } from '~/lib/utils/datetime_utility'; import VulnerabilitiesOverTimeChart from 'ee/security_dashboard/components/shared/charts/open_vulnerabilities_over_time.vue'; import groupVulnerabilitiesOverTime from 'ee/security_dashboard/graphql/queries/group_vulnerabilities_over_time.query.graphql'; import { formatVulnerabilitiesOverTimeData } from 'ee/security_dashboard/utils/chart_utils'; -import { DASHBOARD_LOOKBACK_DAYS } from 'ee/security_dashboard/constants'; import OverTimeSeverityFilter from './over_time_severity_filter.vue'; import OverTimeGroupBy from './over_time_group_by.vue'; +import OverTimePeriodSelector from './over_time_period_selector.vue'; export default { name: 'GroupVulnerabilitiesOverTimePanel', @@ -17,6 +17,7 @@ export default { VulnerabilitiesOverTimeChart, OverTimeGroupBy, OverTimeSeverityFilter, + OverTimePeriodSelector, }, inject: ['groupFullPath'], props: { @@ -25,41 +26,17 @@ export default { required: true, }, }, - apollo: { - vulnerabilitiesOverTime: { - fetchPolicy: fetchPolicies.NETWORK_ONLY, - query: groupVulnerabilitiesOverTime, - variables() { - const lookbackDate = getDateInPast(new Date(), DASHBOARD_LOOKBACK_DAYS); - const startDate = formatDate(lookbackDate, 'isoDate'); - const endDate = formatDate(new Date(), 'isoDate'); - - return { - startDate, - endDate, - projectId: this.filters.projectId, - reportType: this.filters.reportType, - fullPath: this.groupFullPath, - includeBySeverity: this.groupedBy === 'severity', - includeByReportType: this.groupedBy === 'reportType', - severity: this.panelLevelFilters.severity, - }; - }, - update(data) { - const rawData = data.group?.securityMetrics?.vulnerabilitiesOverTime?.nodes || []; - - return formatVulnerabilitiesOverTimeData(rawData, this.groupedBy); - }, - error() { - this.fetchError = true; - }, - }, - }, data() { return { - vulnerabilitiesOverTime: [], fetchError: false, groupedBy: 'severity', + selectedTimePeriod: 30, + isLoading: false, + chartData: { + thirtyDays: [], + sixtyDays: [], + ninetyDays: [], + }, panelLevelFilters: { severity: [], }, @@ -73,7 +50,96 @@ export default { }; }, hasChartData() { - return this.vulnerabilitiesOverTime.length > 0; + return this.selectedChartData.length > 0; + }, + selectedChartData() { + return [ + ...this.chartData.thirtyDays, + ...(this.selectedTimePeriod > 30 ? this.chartData.sixtyDays : []), + ...(this.selectedTimePeriod > 60 ? this.chartData.ninetyDays : []), + ]; + }, + }, + watch: { + selectedTimePeriod() { + this.fetchChartData(); + }, + groupedBy() { + this.fetchChartData(); + }, + filters: { + handler() { + this.fetchChartData(); + }, + deep: true, + }, + panelLevelFilters: { + handler() { + this.fetchChartData(); + }, + deep: true, + }, + }, + mounted() { + this.fetchChartData(); + }, + methods: { + async fetchChartData() { + this.isLoading = true; + this.fetchError = false; + + // Reset all chart data + this.chartData.thirtyDays = []; + this.chartData.sixtyDays = []; + this.chartData.ninetyDays = []; + + const chunks = [ + { + key: 'thirtyDays', + startDays: 30, + endDays: 0, + shouldLoad: true, // Always load the first 30 days + }, + { + key: 'sixtyDays', + startDays: 60, + endDays: 30, + shouldLoad: this.selectedTimePeriod > 30, + }, + { + key: 'ninetyDays', + startDays: 90, + endDays: 60, + shouldLoad: this.selectedTimePeriod > 60, + }, + ].filter((c) => c.shouldLoad); + + try { + await Promise.all(chunks.map(this.loadDataChunk)); + } catch (error) { + this.fetchError = true; + } finally { + this.isLoading = false; + } + }, + + async loadDataChunk({ key, startDays, endDays }) { + const result = await this.$apollo.query({ + query: groupVulnerabilitiesOverTime, + variables: { + projectId: this.filters.projectId, + reportType: this.filters.reportType, + fullPath: this.groupFullPath, + includeBySeverity: this.groupedBy === 'severity', + includeByReportType: this.groupedBy === 'reportType', + severity: this.panelLevelFilters.severity, + startDate: formatDate(getDateInPast(new Date(), startDays), 'isoDate'), + endDate: formatDate(getDateInPast(new Date(), endDays), 'isoDate'), + }, + }); + + const rawData = result.data.group?.securityMetrics?.vulnerabilitiesOverTime?.nodes || []; + this.chartData[key] = formatVulnerabilitiesOverTimeData(rawData, this.groupedBy); }, }, tooltip: { @@ -85,12 +151,13 @@ export default { diff --git a/ee/spec/frontend/security_dashboard/components/shared/vulnerabilities_over_time_panel_base_spec.js b/ee/spec/frontend/security_dashboard/components/shared/vulnerabilities_over_time_panel_base_spec.js new file mode 100644 index 00000000000000..5d02cbcb773b66 --- /dev/null +++ b/ee/spec/frontend/security_dashboard/components/shared/vulnerabilities_over_time_panel_base_spec.js @@ -0,0 +1,577 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ExtendedDashboardPanel from '~/vue_shared/components/customizable_dashboard/extended_dashboard_panel.vue'; +import VulnerabilitiesOverTimeChart from 'ee/security_dashboard/components/shared/charts/open_vulnerabilities_over_time.vue'; +import VulnerabilitiesOverTimePanelBase from 'ee/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue'; +import OverTimeGroupBy from 'ee/security_dashboard/components/shared/over_time_group_by.vue'; +import OverTimeSeverityFilter from 'ee/security_dashboard/components/shared/over_time_severity_filter.vue'; +import OverTimePeriodSelector from 'ee/security_dashboard/components/shared/over_time_period_selector.vue'; +import groupVulnerabilitiesOverTime from 'ee/security_dashboard/graphql/queries/group_vulnerabilities_over_time.query.graphql'; +import projectVulnerabilitiesOverTime from 'ee/security_dashboard/graphql/queries/project_vulnerabilities_over_time.query.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { useFakeDate } from 'helpers/fake_date'; + +Vue.use(VueApollo); +jest.mock('~/alert'); + +describe('VulnerabilitiesOverTimePanelBase', () => { + const todayInIsoFormat = '2022-07-06'; + const thirtyDaysAgoInIsoFormat = '2022-06-06'; + const sixtyDaysAgoInIsoFormat = '2022-05-07'; + const ninetyDaysAgoInIsoFormat = '2022-04-07'; + useFakeDate(todayInIsoFormat); + + let wrapper; + + const mockNamespacePath = 'namespace/path'; + const mockFilters = { reportType: ['SAST'] }; + + const createMockData = (namespaceType) => ({ + data: { + [namespaceType]: { + id: `gid://gitlab/${namespaceType.charAt(0).toUpperCase() + namespaceType.slice(1)}/1`, + securityMetrics: { + vulnerabilitiesOverTime: { + nodes: [ + { + date: '2022-06-01', + bySeverity: [ + { severity: 'CRITICAL', count: 5 }, + { severity: 'HIGH', count: 10 }, + { severity: 'MEDIUM', count: 15 }, + { severity: 'LOW', count: 8 }, + ], + byReportType: [ + { reportType: 'SAST', count: 8 }, + { reportType: 'DEPENDENCY_SCANNING', count: 12 }, + ], + }, + { + date: '2022-06-02', + bySeverity: [ + { severity: 'CRITICAL', count: 6 }, + { severity: 'HIGH', count: 9 }, + { severity: 'MEDIUM', count: 14 }, + { severity: 'LOW', count: 7 }, + ], + byReportType: [ + { reportType: 'DAST', count: 5 }, + { reportType: 'API_FUZZING', count: 3 }, + ], + }, + ], + }, + }, + }, + }, + }); + + const createComponent = ({ + props = {}, + namespaceType = 'group', + mockVulnerabilitiesOverTimeHandler = null, + } = {}) => { + const query = + namespaceType === 'group' ? groupVulnerabilitiesOverTime : projectVulnerabilitiesOverTime; + const defaultMockData = createMockData(namespaceType); + + const vulnerabilitiesOverTimeHandler = + mockVulnerabilitiesOverTimeHandler || jest.fn().mockResolvedValue(defaultMockData); + + const apolloProvider = createMockApollo([[query, vulnerabilitiesOverTimeHandler]]); + + const defaultProps = { + filters: mockFilters, + query, + namespacePath: mockNamespacePath, + namespaceType, + ...props, + }; + + wrapper = shallowMountExtended(VulnerabilitiesOverTimePanelBase, { + apolloProvider, + propsData: defaultProps, + }); + + return { vulnerabilitiesOverTimeHandler }; + }; + + const findExtendedDashboardPanel = () => wrapper.findComponent(ExtendedDashboardPanel); + const findVulnerabilitiesOverTimeChart = () => + wrapper.findComponent(VulnerabilitiesOverTimeChart); + const findOverTimeGroupBy = () => wrapper.findComponent(OverTimeGroupBy); + const findOverTimePeriodSelector = () => wrapper.findComponent(OverTimePeriodSelector); + const findSeverityFilter = () => wrapper.findComponent(OverTimeSeverityFilter); + const findEmptyState = () => wrapper.findByTestId('vulnerabilities-over-time-empty-state'); + + describe('component rendering', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the extended dashboard panel with correct props', () => { + const panel = findExtendedDashboardPanel(); + + expect(panel.props('title')).toBe('Vulnerabilities over time'); + expect(panel.props('tooltip')).toEqual({ + description: 'Vulnerability trends over time', + }); + }); + + it('renders all filter components', () => { + expect(findOverTimePeriodSelector().exists()).toBe(true); + expect(findSeverityFilter().exists()).toBe(true); + expect(findOverTimeGroupBy().exists()).toBe(true); + }); + + it('renders the vulnerabilities over time chart when data is available', async () => { + await waitForPromises(); + + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(true); + }); + + it('passes severity value to OverTimeGroupBy by default', () => { + expect(findOverTimeGroupBy().props('value')).toBe('severity'); + }); + + it('sets initial time period to 30 days', () => { + expect(findOverTimePeriodSelector().props('value')).toBe(30); + }); + }); + + describe('chunked data fetching', () => { + describe('30-day period (default)', () => { + it('fetches only 30-day chunk for default period', async () => { + const { vulnerabilitiesOverTimeHandler } = createComponent(); + await waitForPromises(); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledTimes(1); + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith({ + fullPath: mockNamespacePath, + reportType: mockFilters.reportType, + severity: [], + includeBySeverity: true, + includeByReportType: false, + startDate: thirtyDaysAgoInIsoFormat, + endDate: todayInIsoFormat, + }); + }); + }); + + describe('60-day period', () => { + it('fetches 30-day and 60-day chunks when period is set to 60', async () => { + const { vulnerabilitiesOverTimeHandler } = createComponent(); + + await findOverTimePeriodSelector().vm.$emit('input', 60); + await waitForPromises(); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledTimes(2); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + startDate: thirtyDaysAgoInIsoFormat, + endDate: todayInIsoFormat, + }), + ); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + startDate: sixtyDaysAgoInIsoFormat, + endDate: thirtyDaysAgoInIsoFormat, + }), + ); + }); + }); + + describe('90-day period', () => { + it('fetches all three chunks when period is set to 90', async () => { + const { vulnerabilitiesOverTimeHandler } = createComponent(); + + await findOverTimePeriodSelector().vm.$emit('input', 90); + await waitForPromises(); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledTimes(3); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + startDate: thirtyDaysAgoInIsoFormat, + endDate: todayInIsoFormat, + }), + ); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + startDate: sixtyDaysAgoInIsoFormat, + endDate: thirtyDaysAgoInIsoFormat, + }), + ); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + startDate: ninetyDaysAgoInIsoFormat, + endDate: sixtyDaysAgoInIsoFormat, + }), + ); + }); + }); + }); + + describe('namespace type support', () => { + describe('group namespace', () => { + it('includes projectId in query variables for groups', async () => { + const groupFilters = { projectId: 'gid://gitlab/Project/123', reportType: ['SAST'] }; + const { vulnerabilitiesOverTimeHandler } = createComponent({ + props: { filters: groupFilters }, + namespaceType: 'group', + }); + + await waitForPromises(); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: 'gid://gitlab/Project/123', + }), + ); + }); + + it('extracts data from group field in GraphQL response', async () => { + createComponent({ namespaceType: 'group' }); + await waitForPromises(); + + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(true); + }); + }); + + describe('project namespace', () => { + it('does not include projectId in query variables for projects', async () => { + const { vulnerabilitiesOverTimeHandler } = createComponent({ + namespaceType: 'project', + }); + + await waitForPromises(); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( + expect.not.objectContaining({ + projectId: expect.anything(), + }), + ); + }); + + it('extracts data from project field in GraphQL response', async () => { + createComponent({ namespaceType: 'project' }); + await waitForPromises(); + + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(true); + }); + }); + }); + + describe('filters prop', () => { + it('passes combined filters to VulnerabilitiesOverTimeChart component', async () => { + const customFilters = { + reportType: ['SAST', 'DAST'], + severity: ['HIGH', 'CRITICAL'], + }; + + createComponent({ + props: { filters: customFilters }, + }); + + await waitForPromises(); + + expect(findVulnerabilitiesOverTimeChart().props('filters')).toEqual({ + ...customFilters, + severity: [], + }); + }); + + it('combines props filters with panel level filters', async () => { + const customFilters = { + reportType: ['SAST', 'DAST'], + }; + + const panelLevelFilters = ['HIGH', 'MEDIUM']; + + createComponent({ + props: { filters: customFilters }, + }); + + await waitForPromises(); + + await findSeverityFilter().vm.$emit('input', panelLevelFilters); + await nextTick(); + + expect(findVulnerabilitiesOverTimeChart().props('filters')).toEqual({ + reportType: ['SAST', 'DAST'], + severity: panelLevelFilters, + }); + }); + }); + + describe('group by functionality', () => { + beforeEach(() => { + createComponent(); + }); + + it('switches to report type grouping when report type button is clicked', async () => { + await waitForPromises(); + const overTimeGroupBy = findOverTimeGroupBy(); + + await overTimeGroupBy.vm.$emit('input', 'reportType'); + await nextTick(); + + expect(overTimeGroupBy.props('value')).toBe('reportType'); + expect(findVulnerabilitiesOverTimeChart().props('groupedBy')).toBe('reportType'); + }); + + it('re-fetches data when grouping changes', async () => { + const { vulnerabilitiesOverTimeHandler } = createComponent(); + await waitForPromises(); + + const initialCallCount = vulnerabilitiesOverTimeHandler.mock.calls.length; + + await findOverTimeGroupBy().vm.$emit('input', 'reportType'); + await waitForPromises(); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledTimes(initialCallCount + 1); + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + includeBySeverity: false, + includeByReportType: true, + }), + ); + }); + }); + + describe('severity filter', () => { + beforeEach(() => { + createComponent(); + }); + + it('passes the correct value prop', () => { + expect(findSeverityFilter().props('value')).toEqual([]); + }); + + it('updates the GraphQL query variables when severity filter changes', async () => { + const { vulnerabilitiesOverTimeHandler } = createComponent(); + const appliedFilters = ['CRITICAL', 'HIGH']; + + await waitForPromises(); + const initialCallCount = vulnerabilitiesOverTimeHandler.mock.calls.length; + + await findSeverityFilter().vm.$emit('input', appliedFilters); + await waitForPromises(); + + expect(findSeverityFilter().props('value')).toBe(appliedFilters); + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledTimes(initialCallCount + 1); + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + severity: appliedFilters, + }), + ); + }); + }); + + describe('chart data formatting', () => { + beforeEach(() => { + createComponent(); + }); + + it('correctly formats chart data from the API response for severity grouping', async () => { + await waitForPromises(); + + const expectedChartData = [ + { + name: 'Critical', + id: 'CRITICAL', + data: [ + ['2022-06-01', 5], + ['2022-06-02', 6], + ], + }, + { + name: 'High', + id: 'HIGH', + data: [ + ['2022-06-01', 10], + ['2022-06-02', 9], + ], + }, + { + name: 'Medium', + id: 'MEDIUM', + data: [ + ['2022-06-01', 15], + ['2022-06-02', 14], + ], + }, + { + name: 'Low', + id: 'LOW', + data: [ + ['2022-06-01', 8], + ['2022-06-02', 7], + ], + }, + ]; + + expect(findVulnerabilitiesOverTimeChart().props('chartSeries')).toEqual(expectedChartData); + }); + + it('correctly formats chart data from the API response for report type grouping', async () => { + await findOverTimeGroupBy().vm.$emit('input', 'reportType'); + await waitForPromises(); + + const expectedChartData = [ + { + name: 'SAST', + id: 'SAST', + data: [['2022-06-01', 8]], + }, + { + name: 'Dependency Scanning', + id: 'DEPENDENCY_SCANNING', + data: [['2022-06-01', 12]], + }, + { + name: 'DAST', + id: 'DAST', + data: [['2022-06-02', 5]], + }, + { + name: 'API Fuzzing', + id: 'API_FUZZING', + data: [['2022-06-02', 3]], + }, + ]; + + expect(findVulnerabilitiesOverTimeChart().props('chartSeries')).toEqual(expectedChartData); + }); + + it('returns empty chart data when no vulnerabilities data is available', async () => { + const emptyResponse = { + data: { + group: { + id: 'gid://gitlab/Group/1', + securityMetrics: { + vulnerabilitiesOverTime: { + nodes: [], + }, + }, + }, + }, + }; + + createComponent({ + mockVulnerabilitiesOverTimeHandler: jest.fn().mockResolvedValue(emptyResponse), + }); + await waitForPromises(); + + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); + expect(findEmptyState().text()).toBe('No results found'); + }); + + it('combines data from multiple chunks correctly', async () => { + const mockHandler = jest + .fn() + .mockResolvedValueOnce(createMockData('group')) // 30-day chunk + .mockResolvedValueOnce(createMockData('group')); // 60-day chunk + + createComponent({ + mockVulnerabilitiesOverTimeHandler: mockHandler, + }); + + await findOverTimePeriodSelector().vm.$emit('input', 60); + await waitForPromises(); + + expect(findVulnerabilitiesOverTimeChart().props('chartSeries')).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'Critical', + id: 'CRITICAL', + data: expect.arrayContaining([ + ['2022-06-01', 5], + ['2022-06-02', 6], + ]), + }), + ]), + ); + }); + }); + + describe('loading state', () => { + it('passes loading state to panels base', async () => { + createComponent(); + + expect(findExtendedDashboardPanel().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findExtendedDashboardPanel().props('loading')).toBe(false); + }); + + it('shows loading state when switching time periods', async () => { + createComponent(); + await waitForPromises(); + + expect(findExtendedDashboardPanel().props('loading')).toBe(false); + + await findOverTimePeriodSelector().vm.$emit('input', 60); + + expect(findExtendedDashboardPanel().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findExtendedDashboardPanel().props('loading')).toBe(false); + }); + }); + + describe('error handling', () => { + describe.each` + errorType | mockVulnerabilitiesOverTimeHandler + ${'GraphQL query failures'} | ${jest.fn().mockRejectedValue(new Error('GraphQL query failed'))} + ${'server error responses'} | ${jest.fn().mockResolvedValue({ errors: [{ message: 'Internal server error' }] })} + `('$errorType', ({ mockVulnerabilitiesOverTimeHandler }) => { + beforeEach(async () => { + createComponent({ + mockVulnerabilitiesOverTimeHandler, + }); + + await waitForPromises(); + }); + + it('sets the panel alert state', () => { + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); + }); + + it('does not render the chart component', () => { + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); + }); + + it('renders the correct error message', () => { + expect(findEmptyState().text()).toBe('Something went wrong. Please try again.'); + }); + }); + + it('handles partial chunk failures gracefully', async () => { + const mockHandler = jest + .fn() + .mockResolvedValueOnce(createMockData('group')) // 30-day chunk succeeds + .mockRejectedValueOnce(new Error('Network error')); // 60-day chunk fails + + createComponent({ + mockVulnerabilitiesOverTimeHandler: mockHandler, + }); + + // Switch to 60-day period to trigger multiple chunks + await findOverTimePeriodSelector().vm.$emit('input', 60); + await waitForPromises(); + + // Should show error state when any chunk fails + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); + }); + }); +}); -- GitLab From 786aa884c786b93523dde94286b0646b36fbb327 Mon Sep 17 00:00:00 2001 From: Dave Pisek Date: Fri, 26 Sep 2025 17:15:48 +0200 Subject: [PATCH 08/22] Update translations --- locale/gitlab.pot | 3 --- 1 file changed, 3 deletions(-) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index b654fd07752c42..50929b776dd3f3 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -43188,9 +43188,6 @@ msgstr "" msgid "No data available" msgstr "" -msgid "No data available." -msgstr "" - msgid "No data found for this query." msgstr "" -- GitLab From 7f67815d1000071f68388730b30963edaa0395f7 Mon Sep 17 00:00:00 2001 From: Dave Pisek Date: Fri, 26 Sep 2025 17:19:45 +0200 Subject: [PATCH 09/22] Remove unnecessary watcher --- .../components/shared/over_time_period_selector.vue | 3 --- 1 file changed, 3 deletions(-) diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/over_time_period_selector.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/over_time_period_selector.vue index 0913a0ded39d34..a7ef4ce999e99b 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/shared/over_time_period_selector.vue +++ b/ee/app/assets/javascripts/security_dashboard/components/shared/over_time_period_selector.vue @@ -43,9 +43,6 @@ export default { }, }, watch: { - value(newValue) { - this.selected = newValue; - }, selected(newValue) { this.$emit('input', newValue); }, -- GitLab From ae1171047e7ac0157823b89e91a86b2464c66c04 Mon Sep 17 00:00:00 2001 From: Dave Pisek Date: Fri, 26 Sep 2025 17:22:27 +0200 Subject: [PATCH 10/22] Remove unnused component --- .../vulnerabilities_over_time_wrapper.vue | 175 ------------------ 1 file changed, 175 deletions(-) delete mode 100644 ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_wrapper.vue diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_wrapper.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_wrapper.vue deleted file mode 100644 index b8f5c87a737585..00000000000000 --- a/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_wrapper.vue +++ /dev/null @@ -1,175 +0,0 @@ - - - -- GitLab From 86ec8fe514d2d3dfa98114f98c8a09c9b70b73c6 Mon Sep 17 00:00:00 2001 From: Dave Pisek Date: Mon, 29 Sep 2025 10:07:35 +0200 Subject: [PATCH 11/22] Refactor: simplify and naming --- .../vulnerabilities_over_time_panel_base.vue | 34 ++++++++----------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue index 8f19763f736390..bab4d21f9c7f88 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue +++ b/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue @@ -84,27 +84,21 @@ export default { return variables; }, - chunkConfig() { + lookBackDatesToLoad() { return [ { key: 'thirtyDays', - startDays: 30, - endDays: 0, - shouldLoad: true, // Always load the first 30 days + lookBackDays: 30, }, { key: 'sixtyDays', - startDays: 60, - endDays: 30, - shouldLoad: this.selectedTimePeriod > 30, + lookBackDays: 60, }, { key: 'ninetyDays', - startDays: 90, - endDays: 60, - shouldLoad: this.selectedTimePeriod > 60, + lookBackDays: 90, }, - ].filter((c) => c.shouldLoad); + ].filter((c) => this.selectedTimePeriod >= c.lookBackDays); }, }, watch: { @@ -125,7 +119,7 @@ export default { this.fetchError = false; try { - await Promise.all(this.chunkConfig.map(this.loadDataChunk)); + await Promise.all(this.lookBackDatesToLoad.map(this.loadLookBackWindow)); } catch (error) { this.fetchError = true; } finally { @@ -133,23 +127,23 @@ export default { } }, - async loadDataChunk({ key, startDays, endDays }) { + async loadLookBackWindow({ key, lookBackDays }) { + const startDate = formatDate(getDateInPast(new Date(), lookBackDays), 'isoDate'); + const endDate = formatDate(getDateInPast(new Date(), lookBackDays - 30), 'isoDate'); + const result = await this.$apollo.query({ query: this.query, variables: { ...this.baseQueryVariables, - startDate: formatDate(getDateInPast(new Date(), startDays), 'isoDate'), - endDate: formatDate(getDateInPast(new Date(), endDays), 'isoDate'), + startDate, + endDate, }, }); - const rawData = this.extractRawData(result.data); + const rawData = + result.data[this.namespaceType]?.securityMetrics?.vulnerabilitiesOverTime?.nodes || []; this.chartData[key] = formatVulnerabilitiesOverTimeData(rawData, this.groupedBy); }, - - extractRawData(data) { - return data[this.namespaceType]?.securityMetrics?.vulnerabilitiesOverTime?.nodes || []; - }, }, tooltip: { description: s__('SecurityReports|Vulnerability trends over time'), -- GitLab From c61510eea0410cd435016e932303a7520dad8429 Mon Sep 17 00:00:00 2001 From: Dave Pisek Date: Mon, 29 Sep 2025 16:21:21 +0200 Subject: [PATCH 12/22] WIP: Update transformation --- .../vulnerabilities_over_time_panel_base.vue | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue index bab4d21f9c7f88..7853bd10e9910c 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue +++ b/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue @@ -63,11 +63,34 @@ export default { return this.selectedChartData.length > 0; }, selectedChartData() { - return [ - ...this.chartData.thirtyDays, - ...(this.selectedTimePeriod > 30 ? this.chartData.sixtyDays : []), - ...(this.selectedTimePeriod > 60 ? this.chartData.ninetyDays : []), + // Collect all data arrays based on selected time period + const dataArrays = [ + this.chartData.thirtyDays, + ...(this.selectedTimePeriod > 30 ? [this.chartData.sixtyDays] : []), + ...(this.selectedTimePeriod > 60 ? [this.chartData.ninetyDays] : []), ]; + + // Create a map to merge data by series id/name + const seriesMap = new Map(); + + dataArrays.forEach((dataArray) => { + dataArray.forEach((series) => { + const key = series.id || series.name; + if (seriesMap.has(key)) { + // Merge data arrays for the same series + const existingSeries = seriesMap.get(key); + existingSeries.data = [...existingSeries.data, ...series.data]; + } else { + // Create new series with cloned data + seriesMap.set(key, { + ...series, + data: [...series.data], + }); + } + }); + }); + + return Array.from(seriesMap.values()); }, baseQueryVariables() { const variables = { -- GitLab From fd6b372c7f59fc4cf452d97abc91fa60d0f9c71b Mon Sep 17 00:00:00 2001 From: Dave Pisek Date: Mon, 29 Sep 2025 20:00:16 +0200 Subject: [PATCH 13/22] Add merge functionality --- .../vulnerabilities_over_time_panel_base.vue | 35 +++------------ .../security_dashboard/utils/chart_utils.js | 43 +++++++++++++++---- 2 files changed, 41 insertions(+), 37 deletions(-) diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue index 7853bd10e9910c..2176c0a3f3c126 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue +++ b/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue @@ -3,7 +3,10 @@ import ExtendedDashboardPanel from '~/vue_shared/components/customizable_dashboa import { s__ } from '~/locale'; import { formatDate, getDateInPast } from '~/lib/utils/datetime_utility'; import VulnerabilitiesOverTimeChart from 'ee/security_dashboard/components/shared/charts/open_vulnerabilities_over_time.vue'; -import { formatVulnerabilitiesOverTimeData } from 'ee/security_dashboard/utils/chart_utils'; +import { + formatVulnerabilitiesOverTimeData, + mergeChartSeriesData, +} from 'ee/security_dashboard/utils/chart_utils'; import OverTimeSeverityFilter from './over_time_severity_filter.vue'; import OverTimeGroupBy from './over_time_group_by.vue'; import OverTimePeriodSelector from './over_time_period_selector.vue'; @@ -63,34 +66,10 @@ export default { return this.selectedChartData.length > 0; }, selectedChartData() { - // Collect all data arrays based on selected time period - const dataArrays = [ - this.chartData.thirtyDays, - ...(this.selectedTimePeriod > 30 ? [this.chartData.sixtyDays] : []), - ...(this.selectedTimePeriod > 60 ? [this.chartData.ninetyDays] : []), - ]; - - // Create a map to merge data by series id/name - const seriesMap = new Map(); - - dataArrays.forEach((dataArray) => { - dataArray.forEach((series) => { - const key = series.id || series.name; - if (seriesMap.has(key)) { - // Merge data arrays for the same series - const existingSeries = seriesMap.get(key); - existingSeries.data = [...existingSeries.data, ...series.data]; - } else { - // Create new series with cloned data - seriesMap.set(key, { - ...series, - data: [...series.data], - }); - } - }); + return mergeChartSeriesData({ + chartData: this.chartData, + lookBackDays: this.lookBackDatesToLoad, }); - - return Array.from(seriesMap.values()); }, baseQueryVariables() { const variables = { diff --git a/ee/app/assets/javascripts/security_dashboard/utils/chart_utils.js b/ee/app/assets/javascripts/security_dashboard/utils/chart_utils.js index 1953dc819705b6..6e1f194081c6b8 100644 --- a/ee/app/assets/javascripts/security_dashboard/utils/chart_utils.js +++ b/ee/app/assets/javascripts/security_dashboard/utils/chart_utils.js @@ -23,6 +23,17 @@ export const TAB_FILTERS = { OPERATIONAL: 'OPERATIONAL', }; +const getChartSeriesDataBySeverity = () => { + return { + CRITICAL: { name: SEVERITY_LEVELS.CRITICAL, id: 'CRITICAL', data: [] }, + HIGH: { name: SEVERITY_LEVELS.HIGH, id: 'HIGH', data: [] }, + MEDIUM: { name: SEVERITY_LEVELS.MEDIUM, id: 'MEDIUM', data: [] }, + LOW: { name: SEVERITY_LEVELS.LOW, id: 'LOW', data: [] }, + INFO: { name: SEVERITY_LEVELS.INFO, id: 'INFO', data: [] }, + UNKNOWN: { name: SEVERITY_LEVELS.UNKNOWN, id: 'UNKNOWN', data: [] }, + }; +}; + /** * Formats vulnerability data by severity for chart visualization * @@ -36,14 +47,7 @@ export const TAB_FILTERS = { * see `constructVulnerabilitiesReportWithFiltersPath` for more details */ const formatVulnerabilitiesBySeverity = (vulnerabilitiesOverTime) => { - const chartSeriesDataBySeverity = { - CRITICAL: { name: SEVERITY_LEVELS.CRITICAL, id: 'CRITICAL', data: [] }, - HIGH: { name: SEVERITY_LEVELS.HIGH, id: 'HIGH', data: [] }, - MEDIUM: { name: SEVERITY_LEVELS.MEDIUM, id: 'MEDIUM', data: [] }, - LOW: { name: SEVERITY_LEVELS.LOW, id: 'LOW', data: [] }, - INFO: { name: SEVERITY_LEVELS.INFO, id: 'INFO', data: [] }, - UNKNOWN: { name: SEVERITY_LEVELS.UNKNOWN, id: 'UNKNOWN', data: [] }, - }; + const chartSeriesDataBySeverity = getChartSeriesDataBySeverity(); vulnerabilitiesOverTime.forEach((node) => { const { date, bySeverity = [] } = node; @@ -54,7 +58,7 @@ const formatVulnerabilitiesBySeverity = (vulnerabilitiesOverTime) => { }); }); - return Object.values(chartSeriesDataBySeverity).filter((item) => item.data.length > 0); + return Object.values(chartSeriesDataBySeverity); }; /** @@ -267,3 +271,24 @@ export const generateGrid = ({ totalItems, width, height }) => { return bestGrid; }; + +export const mergeChartSeriesData = ({ chartData, lookBackDays }) => { + const chartSeriesDataBySeverity = getChartSeriesDataBySeverity(); + + lookBackDays.forEach(({ key }) => { + const seriesData = chartData[key] || []; + + seriesData.forEach((series) => { + const { id, data } = series; + if (chartSeriesDataBySeverity[id]) { + // Get existing dates to avoid duplicates + const existingDates = new Set(chartSeriesDataBySeverity[id].data.map(([date]) => date)); + const newData = data.filter(([date]) => !existingDates.has(date)); + + chartSeriesDataBySeverity[id].data.unshift(...newData); + } + }); + }); + + return Object.values(chartSeriesDataBySeverity); +}; -- GitLab From 7743f3fdd58ac954d21b2da7d6aee0319680d455 Mon Sep 17 00:00:00 2001 From: Dave Pisek Date: Mon, 29 Sep 2025 21:07:26 +0200 Subject: [PATCH 14/22] WIP --- .../vulnerabilities_over_time_panel_base.vue | 25 ++++++------------- .../security_dashboard/utils/chart_utils.js | 6 +---- 2 files changed, 9 insertions(+), 22 deletions(-) diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue index 2176c0a3f3c126..7aef0fbf3c89c2 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue +++ b/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue @@ -88,19 +88,10 @@ export default { }, lookBackDatesToLoad() { return [ - { - key: 'thirtyDays', - lookBackDays: 30, - }, - { - key: 'sixtyDays', - lookBackDays: 60, - }, - { - key: 'ninetyDays', - lookBackDays: 90, - }, - ].filter((c) => this.selectedTimePeriod >= c.lookBackDays); + { key: 'thirtyDays', startDays: 30, endDays: 0 }, + { key: 'sixtyDays', startDays: 60, endDays: 31 }, + { key: 'ninetyDays', startDays: 90, endDays: 61 }, + ].filter(({ startDays }) => startDays <= this.selectedTimePeriod); }, }, watch: { @@ -128,10 +119,9 @@ export default { this.isLoading = false; } }, - - async loadLookBackWindow({ key, lookBackDays }) { - const startDate = formatDate(getDateInPast(new Date(), lookBackDays), 'isoDate'); - const endDate = formatDate(getDateInPast(new Date(), lookBackDays - 30), 'isoDate'); + async loadLookBackWindow({ key, startDays, endDays }) { + const startDate = formatDate(getDateInPast(new Date(), startDays), 'isoDate'); + const endDate = formatDate(getDateInPast(new Date(), endDays), 'isoDate'); const result = await this.$apollo.query({ query: this.query, @@ -144,6 +134,7 @@ export default { const rawData = result.data[this.namespaceType]?.securityMetrics?.vulnerabilitiesOverTime?.nodes || []; + this.chartData[key] = formatVulnerabilitiesOverTimeData(rawData, this.groupedBy); }, }, diff --git a/ee/app/assets/javascripts/security_dashboard/utils/chart_utils.js b/ee/app/assets/javascripts/security_dashboard/utils/chart_utils.js index 6e1f194081c6b8..43ff58f706d7ec 100644 --- a/ee/app/assets/javascripts/security_dashboard/utils/chart_utils.js +++ b/ee/app/assets/javascripts/security_dashboard/utils/chart_utils.js @@ -281,11 +281,7 @@ export const mergeChartSeriesData = ({ chartData, lookBackDays }) => { seriesData.forEach((series) => { const { id, data } = series; if (chartSeriesDataBySeverity[id]) { - // Get existing dates to avoid duplicates - const existingDates = new Set(chartSeriesDataBySeverity[id].data.map(([date]) => date)); - const newData = data.filter(([date]) => !existingDates.has(date)); - - chartSeriesDataBySeverity[id].data.unshift(...newData); + chartSeriesDataBySeverity[id].data.unshift(...data); } }); }); -- GitLab From 97d36e7bf3380ae8faaa8e822ed2f296284098c7 Mon Sep 17 00:00:00 2001 From: Dave Pisek Date: Tue, 30 Sep 2025 09:17:46 +0200 Subject: [PATCH 15/22] WIP: Simplify logic --- .../vulnerabilities_over_time_panel_base.vue | 20 +++++----- .../security_dashboard/utils/chart_utils.js | 39 +++++-------------- 2 files changed, 18 insertions(+), 41 deletions(-) diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue index 7aef0fbf3c89c2..e500742d327bbd 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue +++ b/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue @@ -3,10 +3,7 @@ import ExtendedDashboardPanel from '~/vue_shared/components/customizable_dashboa import { s__ } from '~/locale'; import { formatDate, getDateInPast } from '~/lib/utils/datetime_utility'; import VulnerabilitiesOverTimeChart from 'ee/security_dashboard/components/shared/charts/open_vulnerabilities_over_time.vue'; -import { - formatVulnerabilitiesOverTimeData, - mergeChartSeriesData, -} from 'ee/security_dashboard/utils/chart_utils'; +import { formatVulnerabilitiesOverTimeData } from 'ee/security_dashboard/utils/chart_utils'; import OverTimeSeverityFilter from './over_time_severity_filter.vue'; import OverTimeGroupBy from './over_time_group_by.vue'; import OverTimePeriodSelector from './over_time_period_selector.vue'; @@ -66,10 +63,13 @@ export default { return this.selectedChartData.length > 0; }, selectedChartData() { - return mergeChartSeriesData({ - chartData: this.chartData, - lookBackDays: this.lookBackDatesToLoad, - }); + const selectedChartData = [ + ...(this.selectedTimePeriod >= 90 ? this.chartData.ninetyDays : []), + ...(this.selectedTimePeriod >= 60 ? this.chartData.sixtyDays : []), + ...this.chartData.thirtyDays, + ]; + + return formatVulnerabilitiesOverTimeData(selectedChartData, this.groupedBy); }, baseQueryVariables() { const variables = { @@ -132,10 +132,8 @@ export default { }, }); - const rawData = + this.chartData[key] = result.data[this.namespaceType]?.securityMetrics?.vulnerabilitiesOverTime?.nodes || []; - - this.chartData[key] = formatVulnerabilitiesOverTimeData(rawData, this.groupedBy); }, }, tooltip: { diff --git a/ee/app/assets/javascripts/security_dashboard/utils/chart_utils.js b/ee/app/assets/javascripts/security_dashboard/utils/chart_utils.js index 43ff58f706d7ec..1953dc819705b6 100644 --- a/ee/app/assets/javascripts/security_dashboard/utils/chart_utils.js +++ b/ee/app/assets/javascripts/security_dashboard/utils/chart_utils.js @@ -23,17 +23,6 @@ export const TAB_FILTERS = { OPERATIONAL: 'OPERATIONAL', }; -const getChartSeriesDataBySeverity = () => { - return { - CRITICAL: { name: SEVERITY_LEVELS.CRITICAL, id: 'CRITICAL', data: [] }, - HIGH: { name: SEVERITY_LEVELS.HIGH, id: 'HIGH', data: [] }, - MEDIUM: { name: SEVERITY_LEVELS.MEDIUM, id: 'MEDIUM', data: [] }, - LOW: { name: SEVERITY_LEVELS.LOW, id: 'LOW', data: [] }, - INFO: { name: SEVERITY_LEVELS.INFO, id: 'INFO', data: [] }, - UNKNOWN: { name: SEVERITY_LEVELS.UNKNOWN, id: 'UNKNOWN', data: [] }, - }; -}; - /** * Formats vulnerability data by severity for chart visualization * @@ -47,7 +36,14 @@ const getChartSeriesDataBySeverity = () => { * see `constructVulnerabilitiesReportWithFiltersPath` for more details */ const formatVulnerabilitiesBySeverity = (vulnerabilitiesOverTime) => { - const chartSeriesDataBySeverity = getChartSeriesDataBySeverity(); + const chartSeriesDataBySeverity = { + CRITICAL: { name: SEVERITY_LEVELS.CRITICAL, id: 'CRITICAL', data: [] }, + HIGH: { name: SEVERITY_LEVELS.HIGH, id: 'HIGH', data: [] }, + MEDIUM: { name: SEVERITY_LEVELS.MEDIUM, id: 'MEDIUM', data: [] }, + LOW: { name: SEVERITY_LEVELS.LOW, id: 'LOW', data: [] }, + INFO: { name: SEVERITY_LEVELS.INFO, id: 'INFO', data: [] }, + UNKNOWN: { name: SEVERITY_LEVELS.UNKNOWN, id: 'UNKNOWN', data: [] }, + }; vulnerabilitiesOverTime.forEach((node) => { const { date, bySeverity = [] } = node; @@ -58,7 +54,7 @@ const formatVulnerabilitiesBySeverity = (vulnerabilitiesOverTime) => { }); }); - return Object.values(chartSeriesDataBySeverity); + return Object.values(chartSeriesDataBySeverity).filter((item) => item.data.length > 0); }; /** @@ -271,20 +267,3 @@ export const generateGrid = ({ totalItems, width, height }) => { return bestGrid; }; - -export const mergeChartSeriesData = ({ chartData, lookBackDays }) => { - const chartSeriesDataBySeverity = getChartSeriesDataBySeverity(); - - lookBackDays.forEach(({ key }) => { - const seriesData = chartData[key] || []; - - seriesData.forEach((series) => { - const { id, data } = series; - if (chartSeriesDataBySeverity[id]) { - chartSeriesDataBySeverity[id].data.unshift(...data); - } - }); - }); - - return Object.values(chartSeriesDataBySeverity); -}; -- GitLab From 913262f4fabea51a606110bcf51171da631c39e5 Mon Sep 17 00:00:00 2001 From: Dave Pisek Date: Tue, 30 Sep 2025 09:37:45 +0200 Subject: [PATCH 16/22] WIP: adjust specs --- ...up_vulnerabilities_over_time_panel_spec.js | 448 +----------------- ...ct_vulnerabilities_over_time_panel_spec.js | 374 ++------------- 2 files changed, 61 insertions(+), 761 deletions(-) diff --git a/ee/spec/frontend/security_dashboard/components/shared/group_vulnerabilities_over_time_panel_spec.js b/ee/spec/frontend/security_dashboard/components/shared/group_vulnerabilities_over_time_panel_spec.js index 82dd88dba05c9e..8dbe9ce57550e7 100644 --- a/ee/spec/frontend/security_dashboard/components/shared/group_vulnerabilities_over_time_panel_spec.js +++ b/ee/spec/frontend/security_dashboard/components/shared/group_vulnerabilities_over_time_panel_spec.js @@ -1,487 +1,87 @@ -import Vue, { nextTick } from 'vue'; -import VueApollo from 'vue-apollo'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import ExtendedDashboardPanel from '~/vue_shared/components/customizable_dashboard/extended_dashboard_panel.vue'; -import VulnerabilitiesOverTimeChart from 'ee/security_dashboard/components/shared/charts/open_vulnerabilities_over_time.vue'; import GroupVulnerabilitiesOverTimePanel from 'ee/security_dashboard/components/shared/group_vulnerabilities_over_time_panel.vue'; -import OverTimeGroupBy from 'ee/security_dashboard/components/shared/over_time_group_by.vue'; -import OverTimeSeverityFilter from 'ee/security_dashboard/components/shared/over_time_severity_filter.vue'; -import vulnerabilitiesOverTime from 'ee/security_dashboard/graphql/queries/group_vulnerabilities_over_time.query.graphql'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import { useFakeDate } from 'helpers/fake_date'; - -Vue.use(VueApollo); -jest.mock('~/alert'); +import VulnerabilitiesOverTimePanelBase from 'ee/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue'; +import groupVulnerabilitiesOverTime from 'ee/security_dashboard/graphql/queries/group_vulnerabilities_over_time.query.graphql'; describe('GroupVulnerabilitiesOverTimePanel', () => { - const todayInIsoFormat = '2022-07-06'; - const thirtyDaysAgoInIsoFormat = '2022-06-06'; - useFakeDate(todayInIsoFormat); - let wrapper; const mockGroupFullPath = 'group/subgroup'; const mockFilters = { projectId: 'gid://gitlab/Project/123' }; - const defaultMockVulnerabilitiesOverTimeData = { - data: { - group: { - id: 'gid://gitlab/Group/1', - securityMetrics: { - vulnerabilitiesOverTime: { - nodes: [ - { - date: '2025-06-01', - bySeverity: [ - { severity: 'CRITICAL', count: 5 }, - { severity: 'HIGH', count: 10 }, - { severity: 'MEDIUM', count: 15 }, - { severity: 'LOW', count: 8 }, - ], - byReportType: [ - { reportType: 'SAST', count: 8 }, - { reportType: 'DEPENDENCY_SCANNING', count: 12 }, - { reportType: 'CONTAINER_SCANNING', count: 10 }, - ], - }, - { - date: '2025-06-02', - bySeverity: [ - { severity: 'CRITICAL', count: 6 }, - { severity: 'HIGH', count: 9 }, - { severity: 'MEDIUM', count: 14 }, - { severity: 'LOW', count: 7 }, - ], - byReportType: [ - { reportType: 'DAST', count: 5 }, - { reportType: 'API_FUZZING', count: 3 }, - { reportType: 'SAST', count: 6 }, - ], - }, - ], - }, - }, - }, - }, - }; - - const createComponent = ({ props = {}, mockVulnerabilitiesOverTimeHandler = null } = {}) => { - const vulnerabilitiesOverTimeHandler = - mockVulnerabilitiesOverTimeHandler || - jest.fn().mockResolvedValue(defaultMockVulnerabilitiesOverTimeData); - - const apolloProvider = createMockApollo([ - [vulnerabilitiesOverTime, vulnerabilitiesOverTimeHandler], - ]); - + const createComponent = ({ props = {} } = {}) => { wrapper = shallowMountExtended(GroupVulnerabilitiesOverTimePanel, { - apolloProvider, propsData: { filters: mockFilters, ...props, }, provide: { groupFullPath: mockGroupFullPath, - securityVulnerabilitiesPath: '/group/security/vulnerabilities', }, }); - - return { vulnerabilitiesOverTimeHandler }; }; - const findExtendedDashboardPanel = () => wrapper.findComponent(ExtendedDashboardPanel); - const findVulnerabilitiesOverTimeChart = () => - wrapper.findComponent(VulnerabilitiesOverTimeChart); - const findOverTimeGroupBy = () => wrapper.findComponent(OverTimeGroupBy); - const findEmptyState = () => wrapper.findByTestId('vulnerabilities-over-time-empty-state'); - const findSeverityFilter = () => wrapper.findComponent(OverTimeSeverityFilter); + const findVulnerabilitiesOverTimePanelBase = () => + wrapper.findComponent(VulnerabilitiesOverTimePanelBase); describe('component rendering', () => { beforeEach(() => { createComponent(); }); - it('passes the correct title to the panels base', () => { - expect(findExtendedDashboardPanel().props('title')).toBe('Vulnerabilities over time'); + it('renders the VulnerabilitiesOverTimePanelBase component', () => { + expect(findVulnerabilitiesOverTimePanelBase().exists()).toBe(true); }); - it('passes the correct tooltip to the panels base', () => { - expect(findExtendedDashboardPanel().props('tooltip')).toEqual({ - description: 'Vulnerability trends over time', + it('passes the correct props to the base component', () => { + expect(findVulnerabilitiesOverTimePanelBase().props()).toMatchObject({ + filters: mockFilters, + query: groupVulnerabilitiesOverTime, + namespacePath: mockGroupFullPath, + namespaceType: 'group', }); }); - - it('renders the vulnerabilities over time chart when data is available', async () => { - await waitForPromises(); - expect(findVulnerabilitiesOverTimeChart().exists()).toBe(true); - }); - - it('passes severity value to OverTimeGroupBy by default', () => { - expect(findOverTimeGroupBy().props('value')).toBe('severity'); - }); }); describe('filters prop', () => { - it('passes filters to VulnerabilitiesOverTimeChart component', async () => { + it('passes custom filters to the base component', () => { const customFilters = { projectId: 'gid://gitlab/Project/456', reportType: ['SAST', 'DAST'], severity: ['HIGH', 'CRITICAL'], }; - const defaultPanelLevelFilters = { severity: [] }; - - createComponent({ - props: { - filters: customFilters, - }, - }); - - await waitForPromises(); - - expect(findVulnerabilitiesOverTimeChart().props('filters')).toEqual({ - ...customFilters, - ...defaultPanelLevelFilters, - }); - }); - - it('passes filters to VulnerabilitiesOverTimeChart when switching group by', async () => { - const customFilters = { - projectId: 'gid://gitlab/Project/789', - reportType: ['CONTAINER_SCANNING'], - }; - const defaultPanelLevelFilters = { severity: [] }; - createComponent({ props: { filters: customFilters, }, }); - await waitForPromises(); - - // Switch to report type grouping - await findOverTimeGroupBy().vm.$emit('input', 'reportType'); - await waitForPromises(); - - expect(findVulnerabilitiesOverTimeChart().props('filters')).toEqual({ - ...customFilters, - ...defaultPanelLevelFilters, - }); - }); - - it('combines props filters with panel level filters', async () => { - const customFilters = { - projectId: 'gid://gitlab/Project/456', - reportType: ['SAST', 'DAST'], - }; - - const panelLevelFilters = ['HIGH', 'MEDIUM']; - - createComponent({ - props: { - filters: customFilters, - }, - }); - - await waitForPromises(); - - findSeverityFilter().vm.$emit('input', panelLevelFilters); - await nextTick(); - - // The combinedFilters should include both props filters and panel level filters - expect(findVulnerabilitiesOverTimeChart().props('filters')).toEqual({ - projectId: 'gid://gitlab/Project/456', - reportType: ['SAST', 'DAST'], - severity: panelLevelFilters, - }); + expect(findVulnerabilitiesOverTimePanelBase().props('filters')).toEqual(customFilters); }); }); - describe('group by functionality', () => { - beforeEach(() => { + describe('provide values', () => { + it('passes the correct namespace path to the base component', () => { createComponent(); - }); - - it('switches to report type grouping when report type button is clicked', async () => { - await waitForPromises(); - const overTimeGroupBy = findOverTimeGroupBy(); - - await overTimeGroupBy.vm.$emit('input', 'reportType'); - await nextTick(); - expect(overTimeGroupBy.props('value')).toBe('reportType'); + expect(findVulnerabilitiesOverTimePanelBase().props('namespacePath')).toBe(mockGroupFullPath); }); - it('switches back to severity grouping when severity button is clicked', async () => { - await waitForPromises(); - const overTimeGroupBy = findOverTimeGroupBy(); - - await overTimeGroupBy.vm.$emit('input', 'reportType'); - await nextTick(); - - await overTimeGroupBy.vm.$emit('input', 'severity'); - await nextTick(); - - expect(overTimeGroupBy.props('value')).toBe('severity'); - }); - }); - - describe('Apollo query', () => { - beforeEach(() => { + it('passes the correct namespace type to the base component', () => { createComponent(); - }); - - it('fetches vulnerabilities over time data when component is created', () => { - const { vulnerabilitiesOverTimeHandler } = createComponent(); - expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith({ - fullPath: mockGroupFullPath, - projectId: mockFilters.projectId, - startDate: thirtyDaysAgoInIsoFormat, - endDate: todayInIsoFormat, - includeBySeverity: true, - includeByReportType: false, - severity: [], - reportType: undefined, - }); - }); - - it.each(['projectId', 'reportType'])( - 'passes filters to the GraphQL query', - (availableFilterType) => { - const { vulnerabilitiesOverTimeHandler } = createComponent({ - props: { - filters: { [availableFilterType]: ['filterValue'] }, - }, - }); - - expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - [availableFilterType]: ['filterValue'], - projectId: availableFilterType === 'projectId' ? ['filterValue'] : undefined, - reportType: availableFilterType === 'reportType' ? ['filterValue'] : undefined, - }), - ); - }, - ); - - it('does not add unsupported filters that are passed', () => { - const unsupportedFilter = ['filterValue']; - const { vulnerabilitiesOverTimeHandler } = createComponent({ - props: { - filters: { unsupportedFilter }, - }, - }); - - expect(vulnerabilitiesOverTimeHandler).not.toHaveBeenCalledWith( - expect.objectContaining({ - unsupportedFilter, - }), - ); - }); - - it('updates query variables when switching to report type grouping', async () => { - const { vulnerabilitiesOverTimeHandler } = createComponent(); - - await findOverTimeGroupBy().vm.$emit('input', 'reportType'); - await waitForPromises(); - - expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - includeBySeverity: false, - includeByReportType: true, - }), - ); + expect(findVulnerabilitiesOverTimePanelBase().props('namespaceType')).toBe('group'); }); }); - describe('severity filter', () => { - beforeEach(() => { + describe('query prop', () => { + it('passes the correct GraphQL query to the base component', () => { createComponent(); - }); - - it('passes the correct value prop', () => { - expect(findSeverityFilter().props('value')).toEqual([]); - }); - - it('updates the GraphQL query variables when severity filter changes', async () => { - const { vulnerabilitiesOverTimeHandler } = createComponent(); - const appliedFilters = ['CRITICAL', 'HIGH']; - - findSeverityFilter().vm.$emit('input', appliedFilters); - await waitForPromises(); - expect(findSeverityFilter().props('value')).toBe(appliedFilters); - expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - severity: appliedFilters, - }), + expect(findVulnerabilitiesOverTimePanelBase().props('query')).toBe( + groupVulnerabilitiesOverTime, ); }); }); - - describe('chart data formatting', () => { - beforeEach(() => { - createComponent(); - }); - - it('correctly formats chart data from the API response for severity grouping', async () => { - await waitForPromises(); - - const expectedChartData = [ - { - name: 'Critical', - id: 'CRITICAL', - data: [ - ['2025-06-01', 5], - ['2025-06-02', 6], - ], - }, - { - name: 'High', - id: 'HIGH', - data: [ - ['2025-06-01', 10], - ['2025-06-02', 9], - ], - }, - { - name: 'Medium', - id: 'MEDIUM', - data: [ - ['2025-06-01', 15], - ['2025-06-02', 14], - ], - }, - { - name: 'Low', - id: 'LOW', - data: [ - ['2025-06-01', 8], - ['2025-06-02', 7], - ], - }, - ]; - - expect(findVulnerabilitiesOverTimeChart().props('chartSeries')).toEqual(expectedChartData); - }); - - it('passes the correct grouped-by prop for severity grouping', async () => { - await waitForPromises(); - - expect(findVulnerabilitiesOverTimeChart().props('groupedBy')).toBe('severity'); - }); - - it('passes the correct grouped-by prop for report type grouping', async () => { - await findOverTimeGroupBy().vm.$emit('input', 'reportType'); - await waitForPromises(); - - expect(findVulnerabilitiesOverTimeChart().props('groupedBy')).toBe('reportType'); - }); - - it('correctly formats chart data from the API response for report type grouping', async () => { - await findOverTimeGroupBy().vm.$emit('input', 'reportType'); - await waitForPromises(); - - const expectedChartData = [ - { - name: 'SAST', - id: 'SAST', - data: [ - ['2025-06-01', 8], - ['2025-06-02', 6], - ], - }, - { - name: 'Dependency Scanning', - id: 'DEPENDENCY_SCANNING', - data: [['2025-06-01', 12]], - }, - { - name: 'Container Scanning', - id: 'CONTAINER_SCANNING', - data: [['2025-06-01', 10]], - }, - { - name: 'DAST', - id: 'DAST', - data: [['2025-06-02', 5]], - }, - { - name: 'API Fuzzing', - id: 'API_FUZZING', - data: [['2025-06-02', 3]], - }, - ]; - - expect(findVulnerabilitiesOverTimeChart().props('chartSeries')).toEqual(expectedChartData); - }); - - it('returns empty chart data when no vulnerabilities data is available', async () => { - const emptyResponse = { - data: { - group: { - id: 'gid://gitlab/Group/1', - securityMetrics: { - vulnerabilitiesOverTime: { - nodes: [], - }, - }, - }, - }, - }; - - createComponent({ - mockVulnerabilitiesOverTimeHandler: jest.fn().mockResolvedValue(emptyResponse), - }); - await waitForPromises(); - - expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); - expect(findEmptyState().text()).toBe('No data available.'); - }); - }); - - describe('loading state', () => { - beforeEach(() => { - createComponent(); - }); - - it('passes loading state to panels base', async () => { - expect(findExtendedDashboardPanel().props('loading')).toBe(true); - - await waitForPromises(); - - expect(findExtendedDashboardPanel().props('loading')).toBe(false); - }); - }); - - describe('error handling', () => { - describe.each` - errorType | mockVulnerabilitiesOverTimeHandler - ${'GraphQL query failures'} | ${jest.fn().mockRejectedValue(new Error('GraphQL query failed'))} - ${'server error responses'} | ${jest.fn().mockResolvedValue({ errors: [{ message: 'Internal server error' }] })} - `('$errorType', ({ mockVulnerabilitiesOverTimeHandler }) => { - beforeEach(async () => { - createComponent({ - mockVulnerabilitiesOverTimeHandler, - }); - - await waitForPromises(); - }); - - it('sets the panel alert state', () => { - expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); - }); - - it('does not render the chart component', () => { - expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); - }); - - it('renders the correct error message', () => { - expect(findEmptyState().text()).toBe('Something went wrong. Please try again.'); - }); - }); - }); }); diff --git a/ee/spec/frontend/security_dashboard/components/shared/project_vulnerabilities_over_time_panel_spec.js b/ee/spec/frontend/security_dashboard/components/shared/project_vulnerabilities_over_time_panel_spec.js index 65f4237a456e15..30724730705f32 100644 --- a/ee/spec/frontend/security_dashboard/components/shared/project_vulnerabilities_over_time_panel_spec.js +++ b/ee/spec/frontend/security_dashboard/components/shared/project_vulnerabilities_over_time_panel_spec.js @@ -1,388 +1,88 @@ -import Vue, { nextTick } from 'vue'; -import VueApollo from 'vue-apollo'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import ExtendedDashboardPanel from '~/vue_shared/components/customizable_dashboard/extended_dashboard_panel.vue'; -import VulnerabilitiesOverTimeChart from 'ee/security_dashboard/components/shared/charts/open_vulnerabilities_over_time.vue'; import ProjectVulnerabilitiesOverTimePanel from 'ee/security_dashboard/components/shared/project_vulnerabilities_over_time_panel.vue'; -import OverTimeGroupBy from 'ee/security_dashboard/components/shared/over_time_group_by.vue'; -import OverTimeSeverityFilter from 'ee/security_dashboard/components/shared/over_time_severity_filter.vue'; -import vulnerabilitiesOverTime from 'ee/security_dashboard/graphql/queries/project_vulnerabilities_over_time.query.graphql'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import { useFakeDate } from 'helpers/fake_date'; - -Vue.use(VueApollo); -jest.mock('~/alert'); +import VulnerabilitiesOverTimePanelBase from 'ee/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue'; +import projectVulnerabilitiesOverTime from 'ee/security_dashboard/graphql/queries/project_vulnerabilities_over_time.query.graphql'; describe('ProjectVulnerabilitiesOverTimePanel', () => { - const todayInIsoFormat = '2022-07-06'; - const thirtyDaysAgoInIsoFormat = '2022-06-06'; - useFakeDate(todayInIsoFormat); - let wrapper; const mockProjectFullPath = 'project-1'; const mockFilters = { reportType: ['API_FUZZING'] }; - const defaultMockVulnerabilitiesOverTimeData = { - data: { - project: { - id: 'gid://gitlab/Project/1', - securityMetrics: { - vulnerabilitiesOverTime: { - nodes: [ - { - date: '2025-06-01', - bySeverity: [ - { severity: 'CRITICAL', count: 5 }, - { severity: 'HIGH', count: 10 }, - { severity: 'MEDIUM', count: 15 }, - { severity: 'LOW', count: 8 }, - ], - byReportType: [ - { reportType: 'SAST', count: 8 }, - { reportType: 'DEPENDENCY_SCANNING', count: 12 }, - { reportType: 'CONTAINER_SCANNING', count: 10 }, - ], - }, - { - date: '2025-06-02', - bySeverity: [ - { severity: 'CRITICAL', count: 6 }, - { severity: 'HIGH', count: 9 }, - { severity: 'MEDIUM', count: 14 }, - { severity: 'LOW', count: 7 }, - ], - byReportType: [ - { reportType: 'DAST', count: 5 }, - { reportType: 'API_FUZZING', count: 3 }, - { reportType: 'SAST', count: 6 }, - ], - }, - ], - }, - }, - }, - }, - }; - - const createComponent = ({ props = {}, mockVulnerabilitiesOverTimeHandler = null } = {}) => { - const vulnerabilitiesOverTimeHandler = - mockVulnerabilitiesOverTimeHandler || - jest.fn().mockResolvedValue(defaultMockVulnerabilitiesOverTimeData); - - const apolloProvider = createMockApollo([ - [vulnerabilitiesOverTime, vulnerabilitiesOverTimeHandler], - ]); - + const createComponent = ({ props = {} } = {}) => { wrapper = shallowMountExtended(ProjectVulnerabilitiesOverTimePanel, { - apolloProvider, propsData: { filters: mockFilters, ...props, }, provide: { projectFullPath: mockProjectFullPath, - securityVulnerabilitiesPath: '/group/project/security/vulnerabilities', }, }); - - return { vulnerabilitiesOverTimeHandler }; }; - const findExtendedDashboardPanel = () => wrapper.findComponent(ExtendedDashboardPanel); - const findVulnerabilitiesOverTimeChart = () => - wrapper.findComponent(VulnerabilitiesOverTimeChart); - const findOverTimeGroupBy = () => wrapper.findComponent(OverTimeGroupBy); - const findEmptyState = () => wrapper.findByTestId('vulnerabilities-over-time-empty-state'); - const findSeverityFilter = () => wrapper.findComponent(OverTimeSeverityFilter); - - beforeEach(() => { - createComponent(); - }); + const findVulnerabilitiesOverTimePanelBase = () => + wrapper.findComponent(VulnerabilitiesOverTimePanelBase); describe('component rendering', () => { - it('passes the correct title to the panels base', () => { - expect(findExtendedDashboardPanel().props('title')).toBe('Vulnerabilities over time'); - }); - - it('passes the correct tooltip to the panels base', () => { - expect(findExtendedDashboardPanel().props('tooltip')).toEqual({ - description: 'Vulnerability trends over time', - }); - }); - - it('renders the vulnerabilities over time chart', async () => { - await waitForPromises(); - expect(findVulnerabilitiesOverTimeChart().exists()).toBe(true); - }); - - it('passes severity value to OverTimeGroupBy by default', () => { - expect(findOverTimeGroupBy().props('value')).toBe('severity'); - }); - }); - - describe('group by functionality', () => { - it('switches to report type grouping when report type button is clicked', async () => { - await waitForPromises(); - const overTimeGroupBy = findOverTimeGroupBy(); - - await overTimeGroupBy.vm.$emit('input', 'reportType'); - await nextTick(); - - expect(overTimeGroupBy.props('value')).toBe('reportType'); + beforeEach(() => { + createComponent(); }); - it('switches back to severity grouping when severity button is clicked', async () => { - await waitForPromises(); - const overTimeGroupBy = findOverTimeGroupBy(); - - await overTimeGroupBy.vm.$emit('input', 'reportType'); - await nextTick(); - - await overTimeGroupBy.vm.$emit('input', 'severity'); - await nextTick(); - - expect(overTimeGroupBy.props('value')).toBe('severity'); + it('renders the VulnerabilitiesOverTimePanelBase component', () => { + expect(findVulnerabilitiesOverTimePanelBase().exists()).toBe(true); }); - }); - - describe('Apollo query', () => { - it('fetches vulnerabilities over time data when component is created', () => { - const { vulnerabilitiesOverTimeHandler } = createComponent(); - expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith({ - fullPath: mockProjectFullPath, - reportType: mockFilters.reportType, - startDate: thirtyDaysAgoInIsoFormat, - endDate: todayInIsoFormat, - severity: [], - includeBySeverity: true, - includeByReportType: false, + it('passes the correct props to the base component', () => { + expect(findVulnerabilitiesOverTimePanelBase().props()).toMatchObject({ + filters: mockFilters, + query: projectVulnerabilitiesOverTime, + namespacePath: mockProjectFullPath, + namespaceType: 'project', }); }); + }); - it('passes filters to the GraphQL query', () => { - const { vulnerabilitiesOverTimeHandler } = createComponent({ - props: { - filters: { reportType: ['API_FUZZING', 'SAST'] }, - }, - }); - - expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - reportType: ['API_FUZZING', 'SAST'], - }), - ); - }); + describe('filters prop', () => { + it('passes custom filters to the base component', () => { + const customFilters = { + reportType: ['SAST', 'DAST'], + severity: ['HIGH', 'CRITICAL'], + }; - it('does not add unsupported filters that are passed', () => { - const unsupportedFilter = ['filterValue']; - const { vulnerabilitiesOverTimeHandler } = createComponent({ + createComponent({ props: { - filters: { unsupportedFilter }, + filters: customFilters, }, }); - expect(vulnerabilitiesOverTimeHandler).not.toHaveBeenCalledWith( - expect.objectContaining({ - unsupportedFilter, - }), - ); - }); - - it('updates query variables when switching to report type grouping', async () => { - const { vulnerabilitiesOverTimeHandler } = createComponent(); - - await findOverTimeGroupBy().vm.$emit('input', 'reportType'); - await waitForPromises(); - - expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - includeBySeverity: false, - includeByReportType: true, - }), - ); + expect(findVulnerabilitiesOverTimePanelBase().props('filters')).toEqual(customFilters); }); }); - describe('severity filter', () => { - it('passes the correct value prop', () => { - expect(findSeverityFilter().props('value')).toEqual([]); - }); - - it('updates the GraphQL query variables when severity filter changes', async () => { - const { vulnerabilitiesOverTimeHandler } = createComponent(); - const appliedFilters = ['CRITICAL', 'HIGH']; - - findSeverityFilter().vm.$emit('input', appliedFilters); - await waitForPromises(); + describe('provide values', () => { + it('passes the correct namespace path to the base component', () => { + createComponent(); - expect(findSeverityFilter().props('value')).toBe(appliedFilters); - expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - severity: appliedFilters, - }), + expect(findVulnerabilitiesOverTimePanelBase().props('namespacePath')).toBe( + mockProjectFullPath, ); }); - }); - describe('chart data formatting', () => { - it('correctly formats chart data from the API response', async () => { + it('passes the correct namespace type to the base component', () => { createComponent(); - await waitForPromises(); - await nextTick(); - - const expectedChartData = [ - { - name: 'Critical', - id: 'CRITICAL', - data: [ - ['2025-06-01', 5], - ['2025-06-02', 6], - ], - }, - { - name: 'High', - id: 'HIGH', - data: [ - ['2025-06-01', 10], - ['2025-06-02', 9], - ], - }, - { - name: 'Medium', - id: 'MEDIUM', - data: [ - ['2025-06-01', 15], - ['2025-06-02', 14], - ], - }, - { - name: 'Low', - id: 'LOW', - data: [ - ['2025-06-01', 8], - ['2025-06-02', 7], - ], - }, - ]; - - expect(findVulnerabilitiesOverTimeChart().props('chartSeries')).toEqual(expectedChartData); - }); - - it('passes the correct grouped-by prop for severity grouping', async () => { - await waitForPromises(); - - expect(findVulnerabilitiesOverTimeChart().props('groupedBy')).toBe('severity'); - }); - - it('correctly formats chart data from the API response for report type grouping', async () => { - await findOverTimeGroupBy().vm.$emit('input', 'reportType'); - await waitForPromises(); - - const expectedChartData = [ - { - name: 'SAST', - id: 'SAST', - data: [ - ['2025-06-01', 8], - ['2025-06-02', 6], - ], - }, - { - name: 'Dependency Scanning', - id: 'DEPENDENCY_SCANNING', - data: [['2025-06-01', 12]], - }, - { - name: 'Container Scanning', - id: 'CONTAINER_SCANNING', - data: [['2025-06-01', 10]], - }, - { - name: 'DAST', - id: 'DAST', - data: [['2025-06-02', 5]], - }, - { - name: 'API Fuzzing', - id: 'API_FUZZING', - data: [['2025-06-02', 3]], - }, - ]; - - expect(findVulnerabilitiesOverTimeChart().props('chartSeries')).toEqual(expectedChartData); - }); - it('passes the correct grouped-by prop for report type grouping', async () => { - await findOverTimeGroupBy().vm.$emit('input', 'reportType'); - await waitForPromises(); - - expect(findVulnerabilitiesOverTimeChart().props('groupedBy')).toBe('reportType'); - }); - - it('returns empty chart data when no vulnerabilities data is available', async () => { - const emptyResponse = { - data: { - project: { - id: 'gid://gitlab/Project/1', - securityMetrics: { - vulnerabilitiesOverTime: { - nodes: [], - }, - }, - }, - }, - }; - - createComponent({ - mockVulnerabilitiesOverTimeHandler: jest.fn().mockResolvedValue(emptyResponse), - }); - await waitForPromises(); - - expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); - expect(findEmptyState().text()).toBe('No data available.'); + expect(findVulnerabilitiesOverTimePanelBase().props('namespaceType')).toBe('project'); }); }); - describe('loading state', () => { - it('passes loading state to panels base', async () => { + describe('query prop', () => { + it('passes the correct GraphQL query to the base component', () => { createComponent(); - expect(findExtendedDashboardPanel().props('loading')).toBe(true); - - await waitForPromises(); - - expect(findExtendedDashboardPanel().props('loading')).toBe(false); - }); - }); - - describe('error handling', () => { - describe.each` - errorType | mockVulnerabilitiesOverTimeHandler - ${'GraphQL query failures'} | ${jest.fn().mockRejectedValue(new Error('GraphQL query failed'))} - ${'server error responses'} | ${jest.fn().mockResolvedValue({ errors: [{ message: 'Internal server error' }] })} - `('$errorType', ({ mockVulnerabilitiesOverTimeHandler }) => { - beforeEach(async () => { - createComponent({ - mockVulnerabilitiesOverTimeHandler, - }); - - await waitForPromises(); - }); - - it('sets the panel alert state', () => { - expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); - }); - - it('does not render the chart component', () => { - expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); - }); - - it('renders the correct error message', () => { - expect(wrapper.text()).toBe('Something went wrong. Please try again.'); - }); + expect(findVulnerabilitiesOverTimePanelBase().props('query')).toBe( + projectVulnerabilitiesOverTime, + ); }); }); }); -- GitLab From 764c5c932da525f178f8e9aee932f6cf2b8aed56 Mon Sep 17 00:00:00 2001 From: Dave Pisek Date: Tue, 30 Sep 2025 11:36:51 +0200 Subject: [PATCH 17/22] WIP: Fix failing specs --- .../vulnerabilities_over_time_panel_base_spec.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/ee/spec/frontend/security_dashboard/components/shared/vulnerabilities_over_time_panel_base_spec.js b/ee/spec/frontend/security_dashboard/components/shared/vulnerabilities_over_time_panel_base_spec.js index 5d02cbcb773b66..1ff97dd08e3dfc 100644 --- a/ee/spec/frontend/security_dashboard/components/shared/vulnerabilities_over_time_panel_base_spec.js +++ b/ee/spec/frontend/security_dashboard/components/shared/vulnerabilities_over_time_panel_base_spec.js @@ -19,7 +19,9 @@ jest.mock('~/alert'); describe('VulnerabilitiesOverTimePanelBase', () => { const todayInIsoFormat = '2022-07-06'; const thirtyDaysAgoInIsoFormat = '2022-06-06'; + const thirtyOneDaysAgoInIsoFormat = '2022-06-05'; const sixtyDaysAgoInIsoFormat = '2022-05-07'; + const sixtyOneDaysAgoInIsoFormat = '2022-05-06'; const ninetyDaysAgoInIsoFormat = '2022-04-07'; useFakeDate(todayInIsoFormat); @@ -179,7 +181,7 @@ describe('VulnerabilitiesOverTimePanelBase', () => { expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( expect.objectContaining({ startDate: sixtyDaysAgoInIsoFormat, - endDate: thirtyDaysAgoInIsoFormat, + endDate: thirtyOneDaysAgoInIsoFormat, }), ); }); @@ -204,14 +206,14 @@ describe('VulnerabilitiesOverTimePanelBase', () => { expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( expect.objectContaining({ startDate: sixtyDaysAgoInIsoFormat, - endDate: thirtyDaysAgoInIsoFormat, + endDate: thirtyOneDaysAgoInIsoFormat, }), ); expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( expect.objectContaining({ startDate: ninetyDaysAgoInIsoFormat, - endDate: sixtyDaysAgoInIsoFormat, + endDate: sixtyOneDaysAgoInIsoFormat, }), ); }); @@ -320,7 +322,7 @@ describe('VulnerabilitiesOverTimePanelBase', () => { const overTimeGroupBy = findOverTimeGroupBy(); await overTimeGroupBy.vm.$emit('input', 'reportType'); - await nextTick(); + await waitForPromises(); expect(overTimeGroupBy.props('value')).toBe('reportType'); expect(findVulnerabilitiesOverTimeChart().props('groupedBy')).toBe('reportType'); -- GitLab From fecc0e1641efdc2f4b47eedb13568bc8a8a576e3 Mon Sep 17 00:00:00 2001 From: Dave Pisek Date: Tue, 30 Sep 2025 11:56:22 +0200 Subject: [PATCH 18/22] WIP within the WIP --- .../group_vulnerabilities_over_time_panel.vue | 152 ++++- ...roject_vulnerabilities_over_time_panel.vue | 151 ++++- .../vulnerabilities_over_time_panel_base.vue | 176 ------ ...up_vulnerabilities_over_time_panel_spec.js | 376 ++++++++++-- ...ct_vulnerabilities_over_time_panel_spec.js | 401 ++++++++++-- ...lnerabilities_over_time_panel_base_spec.js | 579 ------------------ 6 files changed, 991 insertions(+), 844 deletions(-) delete mode 100644 ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue delete mode 100644 ee/spec/frontend/security_dashboard/components/shared/vulnerabilities_over_time_panel_base_spec.js diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/group_vulnerabilities_over_time_panel.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/group_vulnerabilities_over_time_panel.vue index 92d8c045c4ed00..4428faebee3641 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/shared/group_vulnerabilities_over_time_panel.vue +++ b/ee/app/assets/javascripts/security_dashboard/components/shared/group_vulnerabilities_over_time_panel.vue @@ -1,11 +1,22 @@ diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/project_vulnerabilities_over_time_panel.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/project_vulnerabilities_over_time_panel.vue index d5df3309f66c49..212d0b57f09a9c 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/shared/project_vulnerabilities_over_time_panel.vue +++ b/ee/app/assets/javascripts/security_dashboard/components/shared/project_vulnerabilities_over_time_panel.vue @@ -1,11 +1,22 @@ diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue deleted file mode 100644 index e500742d327bbd..00000000000000 --- a/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue +++ /dev/null @@ -1,176 +0,0 @@ - - - diff --git a/ee/spec/frontend/security_dashboard/components/shared/group_vulnerabilities_over_time_panel_spec.js b/ee/spec/frontend/security_dashboard/components/shared/group_vulnerabilities_over_time_panel_spec.js index 8dbe9ce57550e7..be6060834c549e 100644 --- a/ee/spec/frontend/security_dashboard/components/shared/group_vulnerabilities_over_time_panel_spec.js +++ b/ee/spec/frontend/security_dashboard/components/shared/group_vulnerabilities_over_time_panel_spec.js @@ -1,16 +1,63 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ExtendedDashboardPanel from '~/vue_shared/components/customizable_dashboard/extended_dashboard_panel.vue'; +import VulnerabilitiesOverTimeChart from 'ee/security_dashboard/components/shared/charts/open_vulnerabilities_over_time.vue'; import GroupVulnerabilitiesOverTimePanel from 'ee/security_dashboard/components/shared/group_vulnerabilities_over_time_panel.vue'; -import VulnerabilitiesOverTimePanelBase from 'ee/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue'; +import OverTimeGroupBy from 'ee/security_dashboard/components/shared/over_time_group_by.vue'; +import OverTimeSeverityFilter from 'ee/security_dashboard/components/shared/over_time_severity_filter.vue'; +import OverTimePeriodSelector from 'ee/security_dashboard/components/shared/over_time_period_selector.vue'; import groupVulnerabilitiesOverTime from 'ee/security_dashboard/graphql/queries/group_vulnerabilities_over_time.query.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { useFakeDate } from 'helpers/fake_date'; + +Vue.use(VueApollo); +jest.mock('~/alert'); describe('GroupVulnerabilitiesOverTimePanel', () => { + const todayInIsoFormat = '2022-07-06'; + const thirtyDaysAgoInIsoFormat = '2022-06-06'; + useFakeDate(todayInIsoFormat); + let wrapper; const mockGroupFullPath = 'group/subgroup'; - const mockFilters = { projectId: 'gid://gitlab/Project/123' }; + const mockFilters = { projectId: 'gid://gitlab/Project/123', reportType: ['SAST'] }; + + const createMockData = () => ({ + data: { + group: { + id: 'gid://gitlab/Group/1', + securityMetrics: { + vulnerabilitiesOverTime: { + nodes: [ + { + date: '2022-06-01', + bySeverity: [ + { severity: 'CRITICAL', count: 5 }, + { severity: 'HIGH', count: 10 }, + ], + byReportType: [{ reportType: 'SAST', count: 8 }], + }, + ], + }, + }, + }, + }, + }); + + const createComponent = ({ props = {}, mockVulnerabilitiesOverTimeHandler = null } = {}) => { + const defaultMockData = createMockData(); + const vulnerabilitiesOverTimeHandler = + mockVulnerabilitiesOverTimeHandler || jest.fn().mockResolvedValue(defaultMockData); + + const apolloProvider = createMockApollo([ + [groupVulnerabilitiesOverTime, vulnerabilitiesOverTimeHandler], + ]); - const createComponent = ({ props = {} } = {}) => { wrapper = shallowMountExtended(GroupVulnerabilitiesOverTimePanel, { + apolloProvider, propsData: { filters: mockFilters, ...props, @@ -19,69 +66,326 @@ describe('GroupVulnerabilitiesOverTimePanel', () => { groupFullPath: mockGroupFullPath, }, }); + + return { vulnerabilitiesOverTimeHandler }; }; - const findVulnerabilitiesOverTimePanelBase = () => - wrapper.findComponent(VulnerabilitiesOverTimePanelBase); + const findExtendedDashboardPanel = () => wrapper.findComponent(ExtendedDashboardPanel); + const findVulnerabilitiesOverTimeChart = () => + wrapper.findComponent(VulnerabilitiesOverTimeChart); + const findOverTimeGroupBy = () => wrapper.findComponent(OverTimeGroupBy); + const findOverTimePeriodSelector = () => wrapper.findComponent(OverTimePeriodSelector); + const findSeverityFilter = () => wrapper.findComponent(OverTimeSeverityFilter); + const findEmptyState = () => wrapper.findByTestId('vulnerabilities-over-time-empty-state'); describe('component rendering', () => { beforeEach(() => { createComponent(); }); - it('renders the VulnerabilitiesOverTimePanelBase component', () => { - expect(findVulnerabilitiesOverTimePanelBase().exists()).toBe(true); + it('renders the extended dashboard panel with correct props', () => { + const panel = findExtendedDashboardPanel(); + + expect(panel.props('title')).toBe('Vulnerabilities over time'); + expect(panel.props('tooltip')).toEqual({ + description: 'Vulnerability trends over time', + }); }); - it('passes the correct props to the base component', () => { - expect(findVulnerabilitiesOverTimePanelBase().props()).toMatchObject({ - filters: mockFilters, - query: groupVulnerabilitiesOverTime, - namespacePath: mockGroupFullPath, - namespaceType: 'group', + it('renders all filter components', () => { + expect(findOverTimePeriodSelector().exists()).toBe(true); + expect(findSeverityFilter().exists()).toBe(true); + expect(findOverTimeGroupBy().exists()).toBe(true); + }); + + it('renders the vulnerabilities over time chart when data is available', async () => { + await waitForPromises(); + + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(true); + }); + + it('sets initial time period to 30 days', () => { + expect(findOverTimePeriodSelector().props('value')).toBe(30); + }); + }); + + describe('data fetching', () => { + it('fetches data with correct variables for group namespace', async () => { + const { vulnerabilitiesOverTimeHandler } = createComponent(); + await waitForPromises(); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith({ + fullPath: mockGroupFullPath, + projectId: mockFilters.projectId, + reportType: mockFilters.reportType, + severity: [], + includeBySeverity: true, + includeByReportType: false, + startDate: thirtyDaysAgoInIsoFormat, + endDate: todayInIsoFormat, }); }); + + it('refetches data when time period changes', async () => { + const { vulnerabilitiesOverTimeHandler } = createComponent(); + await waitForPromises(); + + const initialCallCount = vulnerabilitiesOverTimeHandler.mock.calls.length; + + await findOverTimePeriodSelector().vm.$emit('input', 60); + await waitForPromises(); + + expect(vulnerabilitiesOverTimeHandler.mock.calls.length).toBeGreaterThan(initialCallCount); + }); }); - describe('filters prop', () => { - it('passes custom filters to the base component', () => { - const customFilters = { - projectId: 'gid://gitlab/Project/456', - reportType: ['SAST', 'DAST'], - severity: ['HIGH', 'CRITICAL'], - }; + describe('time period selection', () => { + const sixtyDaysAgoInIsoFormat = '2022-05-07'; + const thirtyOneDaysAgoInIsoFormat = '2022-06-05'; + const ninetyDaysAgoInIsoFormat = '2022-04-07'; + const sixtyOneDaysAgoInIsoFormat = '2022-05-06'; - createComponent({ - props: { - filters: customFilters, - }, + it('fetches only 30-day chunk for default period', async () => { + const { vulnerabilitiesOverTimeHandler } = createComponent(); + await waitForPromises(); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledTimes(1); + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith({ + fullPath: mockGroupFullPath, + projectId: mockFilters.projectId, + reportType: mockFilters.reportType, + severity: [], + includeBySeverity: true, + includeByReportType: false, + startDate: thirtyDaysAgoInIsoFormat, + endDate: todayInIsoFormat, }); + }); + + it('fetches 30-day and 60-day chunks when period is set to 60', async () => { + const { vulnerabilitiesOverTimeHandler } = createComponent(); + + await findOverTimePeriodSelector().vm.$emit('input', 60); + await waitForPromises(); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledTimes(2); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + startDate: thirtyDaysAgoInIsoFormat, + endDate: todayInIsoFormat, + }), + ); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + startDate: sixtyDaysAgoInIsoFormat, + endDate: thirtyOneDaysAgoInIsoFormat, + }), + ); + }); + + it('fetches all three chunks when period is set to 90', async () => { + const { vulnerabilitiesOverTimeHandler } = createComponent(); + + await findOverTimePeriodSelector().vm.$emit('input', 90); + await waitForPromises(); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledTimes(3); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + startDate: thirtyDaysAgoInIsoFormat, + endDate: todayInIsoFormat, + }), + ); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + startDate: sixtyDaysAgoInIsoFormat, + endDate: thirtyOneDaysAgoInIsoFormat, + }), + ); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + startDate: ninetyDaysAgoInIsoFormat, + endDate: sixtyOneDaysAgoInIsoFormat, + }), + ); + }); + + it('shows loading state when switching time periods', async () => { + createComponent(); + await waitForPromises(); + + expect(findExtendedDashboardPanel().props('loading')).toBe(false); + + await findOverTimePeriodSelector().vm.$emit('input', 60); + + expect(findExtendedDashboardPanel().props('loading')).toBe(true); - expect(findVulnerabilitiesOverTimePanelBase().props('filters')).toEqual(customFilters); + await waitForPromises(); + + expect(findExtendedDashboardPanel().props('loading')).toBe(false); + }); + }); + + describe('filters', () => { + it('updates GraphQL query when severity filter changes', async () => { + const { vulnerabilitiesOverTimeHandler } = createComponent(); + const appliedFilters = ['CRITICAL', 'HIGH']; + + await waitForPromises(); + const initialCallCount = vulnerabilitiesOverTimeHandler.mock.calls.length; + + await findSeverityFilter().vm.$emit('input', appliedFilters); + await waitForPromises(); + + expect(vulnerabilitiesOverTimeHandler.mock.calls.length).toBeGreaterThan(initialCallCount); + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + severity: appliedFilters, + }), + ); }); }); - describe('provide values', () => { - it('passes the correct namespace path to the base component', () => { + describe('loading state', () => { + it('shows loading state initially', () => { createComponent(); - expect(findVulnerabilitiesOverTimePanelBase().props('namespacePath')).toBe(mockGroupFullPath); + expect(findExtendedDashboardPanel().props('loading')).toBe(true); }); - it('passes the correct namespace type to the base component', () => { + it('hides loading state after data is loaded', async () => { createComponent(); + await waitForPromises(); - expect(findVulnerabilitiesOverTimePanelBase().props('namespaceType')).toBe('group'); + expect(findExtendedDashboardPanel().props('loading')).toBe(false); }); }); - describe('query prop', () => { - it('passes the correct GraphQL query to the base component', () => { - createComponent(); + describe('error handling', () => { + it('shows error state when GraphQL query fails', async () => { + createComponent({ + mockVulnerabilitiesOverTimeHandler: jest.fn().mockRejectedValue(new Error('GraphQL error')), + }); - expect(findVulnerabilitiesOverTimePanelBase().props('query')).toBe( - groupVulnerabilitiesOverTime, - ); + await waitForPromises(); + + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); + expect(findEmptyState().text()).toBe('Something went wrong. Please try again.'); + }); + + it('shows error state when server returns error response', async () => { + createComponent({ + mockVulnerabilitiesOverTimeHandler: jest.fn().mockResolvedValue({ + errors: [{ message: 'Internal server error' }], + }), + }); + + await waitForPromises(); + + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); + expect(findEmptyState().text()).toBe('Something went wrong. Please try again.'); + }); + + it('handles error when switching time periods', async () => { + const mockHandler = jest + .fn() + .mockResolvedValueOnce(createMockData()) // Initial load succeeds + .mockRejectedValueOnce(new Error('Network error')); // Time period change fails + + createComponent({ + mockVulnerabilitiesOverTimeHandler: mockHandler, + }); + + await waitForPromises(); + + // Initial load should succeed + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(false); + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(true); + + // Switch time period - this should fail + await findOverTimePeriodSelector().vm.$emit('input', 60); + await waitForPromises(); + + // Should show error state + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); + expect(findEmptyState().text()).toBe('Something went wrong. Please try again.'); + }); + + it('handles partial chunk failures gracefully', async () => { + const mockHandler = jest + .fn() + .mockResolvedValueOnce(createMockData()) // 30-day chunk succeeds + .mockRejectedValueOnce(new Error('Network timeout')); // 60-day chunk fails + + createComponent({ + mockVulnerabilitiesOverTimeHandler: mockHandler, + }); + + // Switch to 60-day period to trigger multiple chunks + await findOverTimePeriodSelector().vm.$emit('input', 60); + await waitForPromises(); + + // Should show error state when any chunk fails + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); + expect(findEmptyState().text()).toBe('Something went wrong. Please try again.'); + }); + + it('handles error during filter changes', async () => { + const mockHandler = jest + .fn() + .mockResolvedValueOnce(createMockData()) // Initial load succeeds + .mockRejectedValueOnce(new Error('Filter error')); // Filter change fails + + createComponent({ + mockVulnerabilitiesOverTimeHandler: mockHandler, + }); + + await waitForPromises(); + + // Initial load should succeed + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(false); + + // Change severity filter - this should fail + await findSeverityFilter().vm.$emit('input', ['CRITICAL']); + await waitForPromises(); + + // Should show error state + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); + }); + + it('resets error state when retrying after successful data fetch', async () => { + const mockHandler = jest + .fn() + .mockRejectedValueOnce(new Error('Initial error')) // First call fails + .mockResolvedValueOnce(createMockData()); // Retry succeeds + + createComponent({ + mockVulnerabilitiesOverTimeHandler: mockHandler, + }); + + await waitForPromises(); + + // Should show error state initially + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); + + // Trigger retry by changing time period + await findOverTimePeriodSelector().vm.$emit('input', 60); + await waitForPromises(); + + // Should show success state after retry + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(false); + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(true); }); }); }); diff --git a/ee/spec/frontend/security_dashboard/components/shared/project_vulnerabilities_over_time_panel_spec.js b/ee/spec/frontend/security_dashboard/components/shared/project_vulnerabilities_over_time_panel_spec.js index 30724730705f32..9830660961af66 100644 --- a/ee/spec/frontend/security_dashboard/components/shared/project_vulnerabilities_over_time_panel_spec.js +++ b/ee/spec/frontend/security_dashboard/components/shared/project_vulnerabilities_over_time_panel_spec.js @@ -1,16 +1,63 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ExtendedDashboardPanel from '~/vue_shared/components/customizable_dashboard/extended_dashboard_panel.vue'; +import VulnerabilitiesOverTimeChart from 'ee/security_dashboard/components/shared/charts/open_vulnerabilities_over_time.vue'; import ProjectVulnerabilitiesOverTimePanel from 'ee/security_dashboard/components/shared/project_vulnerabilities_over_time_panel.vue'; -import VulnerabilitiesOverTimePanelBase from 'ee/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue'; +import OverTimeGroupBy from 'ee/security_dashboard/components/shared/over_time_group_by.vue'; +import OverTimeSeverityFilter from 'ee/security_dashboard/components/shared/over_time_severity_filter.vue'; +import OverTimePeriodSelector from 'ee/security_dashboard/components/shared/over_time_period_selector.vue'; import projectVulnerabilitiesOverTime from 'ee/security_dashboard/graphql/queries/project_vulnerabilities_over_time.query.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { useFakeDate } from 'helpers/fake_date'; + +Vue.use(VueApollo); +jest.mock('~/alert'); describe('ProjectVulnerabilitiesOverTimePanel', () => { + const todayInIsoFormat = '2022-07-06'; + const thirtyDaysAgoInIsoFormat = '2022-06-06'; + useFakeDate(todayInIsoFormat); + let wrapper; const mockProjectFullPath = 'project-1'; const mockFilters = { reportType: ['API_FUZZING'] }; - const createComponent = ({ props = {} } = {}) => { + const createMockData = () => ({ + data: { + project: { + id: 'gid://gitlab/Project/1', + securityMetrics: { + vulnerabilitiesOverTime: { + nodes: [ + { + date: '2022-06-01', + bySeverity: [ + { severity: 'CRITICAL', count: 3 }, + { severity: 'HIGH', count: 7 }, + ], + byReportType: [{ reportType: 'API_FUZZING', count: 5 }], + }, + ], + }, + }, + }, + }, + }); + + const createComponent = ({ props = {}, mockVulnerabilitiesOverTimeHandler = null } = {}) => { + const defaultMockData = createMockData(); + const vulnerabilitiesOverTimeHandler = + mockVulnerabilitiesOverTimeHandler || jest.fn().mockResolvedValue(defaultMockData); + + const apolloProvider = createMockApollo([ + [projectVulnerabilitiesOverTime, vulnerabilitiesOverTimeHandler], + ]); + wrapper = shallowMountExtended(ProjectVulnerabilitiesOverTimePanel, { + apolloProvider, propsData: { filters: mockFilters, ...props, @@ -19,70 +66,354 @@ describe('ProjectVulnerabilitiesOverTimePanel', () => { projectFullPath: mockProjectFullPath, }, }); + + return { vulnerabilitiesOverTimeHandler }; }; - const findVulnerabilitiesOverTimePanelBase = () => - wrapper.findComponent(VulnerabilitiesOverTimePanelBase); + const findExtendedDashboardPanel = () => wrapper.findComponent(ExtendedDashboardPanel); + const findVulnerabilitiesOverTimeChart = () => + wrapper.findComponent(VulnerabilitiesOverTimeChart); + const findOverTimeGroupBy = () => wrapper.findComponent(OverTimeGroupBy); + const findOverTimePeriodSelector = () => wrapper.findComponent(OverTimePeriodSelector); + const findSeverityFilter = () => wrapper.findComponent(OverTimeSeverityFilter); + const findEmptyState = () => wrapper.findByTestId('vulnerabilities-over-time-empty-state'); describe('component rendering', () => { beforeEach(() => { createComponent(); }); - it('renders the VulnerabilitiesOverTimePanelBase component', () => { - expect(findVulnerabilitiesOverTimePanelBase().exists()).toBe(true); - }); + it('renders the extended dashboard panel with correct props', () => { + const panel = findExtendedDashboardPanel(); - it('passes the correct props to the base component', () => { - expect(findVulnerabilitiesOverTimePanelBase().props()).toMatchObject({ - filters: mockFilters, - query: projectVulnerabilitiesOverTime, - namespacePath: mockProjectFullPath, - namespaceType: 'project', + expect(panel.props('title')).toBe('Vulnerabilities over time'); + expect(panel.props('tooltip')).toEqual({ + description: 'Vulnerability trends over time', }); }); + + it('renders all filter components', () => { + expect(findOverTimePeriodSelector().exists()).toBe(true); + expect(findSeverityFilter().exists()).toBe(true); + expect(findOverTimeGroupBy().exists()).toBe(true); + }); + + it('renders the vulnerabilities over time chart when data is available', async () => { + await waitForPromises(); + + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(true); + }); + + it('sets initial time period to 30 days', () => { + expect(findOverTimePeriodSelector().props('value')).toBe(30); + }); }); - describe('filters prop', () => { - it('passes custom filters to the base component', () => { - const customFilters = { - reportType: ['SAST', 'DAST'], - severity: ['HIGH', 'CRITICAL'], - }; + describe('data fetching', () => { + it('fetches data with correct variables for project namespace', async () => { + const { vulnerabilitiesOverTimeHandler } = createComponent(); + await waitForPromises(); - createComponent({ - props: { - filters: customFilters, - }, + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith({ + fullPath: mockProjectFullPath, + reportType: mockFilters.reportType, + severity: [], + includeBySeverity: true, + includeByReportType: false, + startDate: thirtyDaysAgoInIsoFormat, + endDate: todayInIsoFormat, }); + }); - expect(findVulnerabilitiesOverTimePanelBase().props('filters')).toEqual(customFilters); + it('does not include projectId in query variables for projects', async () => { + const { vulnerabilitiesOverTimeHandler } = createComponent(); + await waitForPromises(); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( + expect.not.objectContaining({ + projectId: expect.anything(), + }), + ); + }); + + it('refetches data when time period changes', async () => { + const { vulnerabilitiesOverTimeHandler } = createComponent(); + await waitForPromises(); + + const initialCallCount = vulnerabilitiesOverTimeHandler.mock.calls.length; + + await findOverTimePeriodSelector().vm.$emit('input', 60); + await waitForPromises(); + + expect(vulnerabilitiesOverTimeHandler.mock.calls.length).toBeGreaterThan(initialCallCount); }); }); - describe('provide values', () => { - it('passes the correct namespace path to the base component', () => { - createComponent(); + describe('time period selection', () => { + const sixtyDaysAgoInIsoFormat = '2022-05-07'; + const thirtyOneDaysAgoInIsoFormat = '2022-06-05'; + const ninetyDaysAgoInIsoFormat = '2022-04-07'; + const sixtyOneDaysAgoInIsoFormat = '2022-05-06'; + + it('fetches only 30-day chunk for default period', async () => { + const { vulnerabilitiesOverTimeHandler } = createComponent(); + await waitForPromises(); - expect(findVulnerabilitiesOverTimePanelBase().props('namespacePath')).toBe( - mockProjectFullPath, + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledTimes(1); + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith({ + fullPath: mockProjectFullPath, + reportType: mockFilters.reportType, + severity: [], + includeBySeverity: true, + includeByReportType: false, + startDate: thirtyDaysAgoInIsoFormat, + endDate: todayInIsoFormat, + }); + }); + + it('fetches 30-day and 60-day chunks when period is set to 60', async () => { + const { vulnerabilitiesOverTimeHandler } = createComponent(); + + await findOverTimePeriodSelector().vm.$emit('input', 60); + await waitForPromises(); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledTimes(2); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + startDate: thirtyDaysAgoInIsoFormat, + endDate: todayInIsoFormat, + }), + ); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + startDate: sixtyDaysAgoInIsoFormat, + endDate: thirtyOneDaysAgoInIsoFormat, + }), ); }); - it('passes the correct namespace type to the base component', () => { + it('fetches all three chunks when period is set to 90', async () => { + const { vulnerabilitiesOverTimeHandler } = createComponent(); + + await findOverTimePeriodSelector().vm.$emit('input', 90); + await waitForPromises(); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledTimes(3); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + startDate: thirtyDaysAgoInIsoFormat, + endDate: todayInIsoFormat, + }), + ); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + startDate: sixtyDaysAgoInIsoFormat, + endDate: thirtyOneDaysAgoInIsoFormat, + }), + ); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + startDate: ninetyDaysAgoInIsoFormat, + endDate: sixtyOneDaysAgoInIsoFormat, + }), + ); + }); + + it('shows loading state when switching time periods', async () => { createComponent(); + await waitForPromises(); + + expect(findExtendedDashboardPanel().props('loading')).toBe(false); + + await findOverTimePeriodSelector().vm.$emit('input', 60); + + expect(findExtendedDashboardPanel().props('loading')).toBe(true); + + await waitForPromises(); - expect(findVulnerabilitiesOverTimePanelBase().props('namespaceType')).toBe('project'); + expect(findExtendedDashboardPanel().props('loading')).toBe(false); + }); + }); + + describe('filters', () => { + it('updates GraphQL query when severity filter changes', async () => { + const { vulnerabilitiesOverTimeHandler } = createComponent(); + const appliedFilters = ['CRITICAL', 'HIGH']; + + await waitForPromises(); + const initialCallCount = vulnerabilitiesOverTimeHandler.mock.calls.length; + + await findSeverityFilter().vm.$emit('input', appliedFilters); + await waitForPromises(); + + expect(vulnerabilitiesOverTimeHandler.mock.calls.length).toBeGreaterThan(initialCallCount); + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + severity: appliedFilters, + }), + ); }); }); - describe('query prop', () => { - it('passes the correct GraphQL query to the base component', () => { + describe('loading state', () => { + it('shows loading state initially', () => { createComponent(); - expect(findVulnerabilitiesOverTimePanelBase().props('query')).toBe( - projectVulnerabilitiesOverTime, + expect(findExtendedDashboardPanel().props('loading')).toBe(true); + }); + + it('hides loading state after data is loaded', async () => { + createComponent(); + await waitForPromises(); + + expect(findExtendedDashboardPanel().props('loading')).toBe(false); + }); + }); + + describe('error handling', () => { + it('shows error state when GraphQL query fails', async () => { + createComponent({ + mockVulnerabilitiesOverTimeHandler: jest.fn().mockRejectedValue(new Error('GraphQL error')), + }); + + await waitForPromises(); + + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); + expect(findEmptyState().text()).toBe('Something went wrong. Please try again.'); + }); + + it('shows error state when server returns error response', async () => { + createComponent({ + mockVulnerabilitiesOverTimeHandler: jest.fn().mockResolvedValue({ + errors: [{ message: 'Internal server error' }], + }), + }); + + await waitForPromises(); + + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); + expect(findEmptyState().text()).toBe('Something went wrong. Please try again.'); + }); + + it('handles error when switching time periods', async () => { + const mockHandler = jest + .fn() + .mockResolvedValueOnce(createMockData()) // Initial load succeeds + .mockRejectedValueOnce(new Error('Network error')); // Time period change fails + + createComponent({ + mockVulnerabilitiesOverTimeHandler: mockHandler, + }); + + await waitForPromises(); + + // Initial load should succeed + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(false); + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(true); + + // Switch time period - this should fail + await findOverTimePeriodSelector().vm.$emit('input', 60); + await waitForPromises(); + + // Should show error state + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); + expect(findEmptyState().text()).toBe('Something went wrong. Please try again.'); + }); + + it('handles partial chunk failures gracefully', async () => { + const mockHandler = jest + .fn() + .mockResolvedValueOnce(createMockData()) // 30-day chunk succeeds + .mockRejectedValueOnce(new Error('Network timeout')); // 60-day chunk fails + + createComponent({ + mockVulnerabilitiesOverTimeHandler: mockHandler, + }); + + // Switch to 60-day period to trigger multiple chunks + await findOverTimePeriodSelector().vm.$emit('input', 60); + await waitForPromises(); + + // Should show error state when any chunk fails + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); + expect(findEmptyState().text()).toBe('Something went wrong. Please try again.'); + }); + + it('handles error during filter changes', async () => { + const mockHandler = jest + .fn() + .mockResolvedValueOnce(createMockData()) // Initial load succeeds + .mockRejectedValueOnce(new Error('Filter error')); // Filter change fails + + createComponent({ + mockVulnerabilitiesOverTimeHandler: mockHandler, + }); + + await waitForPromises(); + + // Initial load should succeed + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(false); + + // Change severity filter - this should fail + await findSeverityFilter().vm.$emit('input', ['CRITICAL']); + await waitForPromises(); + + // Should show error state + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); + }); + + it('resets error state when retrying after successful data fetch', async () => { + const mockHandler = jest + .fn() + .mockRejectedValueOnce(new Error('Initial error')) // First call fails + .mockResolvedValueOnce(createMockData()); // Retry succeeds + + createComponent({ + mockVulnerabilitiesOverTimeHandler: mockHandler, + }); + + await waitForPromises(); + + // Should show error state initially + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); + + // Trigger retry by changing time period + await findOverTimePeriodSelector().vm.$emit('input', 60); + await waitForPromises(); + + // Should show success state after retry + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(false); + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(true); + }); + + it('maintains project-specific behavior during error scenarios', async () => { + const mockHandler = jest.fn().mockRejectedValue(new Error('Project error')); + + createComponent({ + mockVulnerabilitiesOverTimeHandler: mockHandler, + }); + + await waitForPromises(); + + // Verify that even during error, no projectId is included in queries + expect(mockHandler).toHaveBeenCalledWith( + expect.not.objectContaining({ + projectId: expect.anything(), + }), ); + + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); }); }); }); diff --git a/ee/spec/frontend/security_dashboard/components/shared/vulnerabilities_over_time_panel_base_spec.js b/ee/spec/frontend/security_dashboard/components/shared/vulnerabilities_over_time_panel_base_spec.js deleted file mode 100644 index 1ff97dd08e3dfc..00000000000000 --- a/ee/spec/frontend/security_dashboard/components/shared/vulnerabilities_over_time_panel_base_spec.js +++ /dev/null @@ -1,579 +0,0 @@ -import Vue, { nextTick } from 'vue'; -import VueApollo from 'vue-apollo'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import ExtendedDashboardPanel from '~/vue_shared/components/customizable_dashboard/extended_dashboard_panel.vue'; -import VulnerabilitiesOverTimeChart from 'ee/security_dashboard/components/shared/charts/open_vulnerabilities_over_time.vue'; -import VulnerabilitiesOverTimePanelBase from 'ee/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue'; -import OverTimeGroupBy from 'ee/security_dashboard/components/shared/over_time_group_by.vue'; -import OverTimeSeverityFilter from 'ee/security_dashboard/components/shared/over_time_severity_filter.vue'; -import OverTimePeriodSelector from 'ee/security_dashboard/components/shared/over_time_period_selector.vue'; -import groupVulnerabilitiesOverTime from 'ee/security_dashboard/graphql/queries/group_vulnerabilities_over_time.query.graphql'; -import projectVulnerabilitiesOverTime from 'ee/security_dashboard/graphql/queries/project_vulnerabilities_over_time.query.graphql'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import { useFakeDate } from 'helpers/fake_date'; - -Vue.use(VueApollo); -jest.mock('~/alert'); - -describe('VulnerabilitiesOverTimePanelBase', () => { - const todayInIsoFormat = '2022-07-06'; - const thirtyDaysAgoInIsoFormat = '2022-06-06'; - const thirtyOneDaysAgoInIsoFormat = '2022-06-05'; - const sixtyDaysAgoInIsoFormat = '2022-05-07'; - const sixtyOneDaysAgoInIsoFormat = '2022-05-06'; - const ninetyDaysAgoInIsoFormat = '2022-04-07'; - useFakeDate(todayInIsoFormat); - - let wrapper; - - const mockNamespacePath = 'namespace/path'; - const mockFilters = { reportType: ['SAST'] }; - - const createMockData = (namespaceType) => ({ - data: { - [namespaceType]: { - id: `gid://gitlab/${namespaceType.charAt(0).toUpperCase() + namespaceType.slice(1)}/1`, - securityMetrics: { - vulnerabilitiesOverTime: { - nodes: [ - { - date: '2022-06-01', - bySeverity: [ - { severity: 'CRITICAL', count: 5 }, - { severity: 'HIGH', count: 10 }, - { severity: 'MEDIUM', count: 15 }, - { severity: 'LOW', count: 8 }, - ], - byReportType: [ - { reportType: 'SAST', count: 8 }, - { reportType: 'DEPENDENCY_SCANNING', count: 12 }, - ], - }, - { - date: '2022-06-02', - bySeverity: [ - { severity: 'CRITICAL', count: 6 }, - { severity: 'HIGH', count: 9 }, - { severity: 'MEDIUM', count: 14 }, - { severity: 'LOW', count: 7 }, - ], - byReportType: [ - { reportType: 'DAST', count: 5 }, - { reportType: 'API_FUZZING', count: 3 }, - ], - }, - ], - }, - }, - }, - }, - }); - - const createComponent = ({ - props = {}, - namespaceType = 'group', - mockVulnerabilitiesOverTimeHandler = null, - } = {}) => { - const query = - namespaceType === 'group' ? groupVulnerabilitiesOverTime : projectVulnerabilitiesOverTime; - const defaultMockData = createMockData(namespaceType); - - const vulnerabilitiesOverTimeHandler = - mockVulnerabilitiesOverTimeHandler || jest.fn().mockResolvedValue(defaultMockData); - - const apolloProvider = createMockApollo([[query, vulnerabilitiesOverTimeHandler]]); - - const defaultProps = { - filters: mockFilters, - query, - namespacePath: mockNamespacePath, - namespaceType, - ...props, - }; - - wrapper = shallowMountExtended(VulnerabilitiesOverTimePanelBase, { - apolloProvider, - propsData: defaultProps, - }); - - return { vulnerabilitiesOverTimeHandler }; - }; - - const findExtendedDashboardPanel = () => wrapper.findComponent(ExtendedDashboardPanel); - const findVulnerabilitiesOverTimeChart = () => - wrapper.findComponent(VulnerabilitiesOverTimeChart); - const findOverTimeGroupBy = () => wrapper.findComponent(OverTimeGroupBy); - const findOverTimePeriodSelector = () => wrapper.findComponent(OverTimePeriodSelector); - const findSeverityFilter = () => wrapper.findComponent(OverTimeSeverityFilter); - const findEmptyState = () => wrapper.findByTestId('vulnerabilities-over-time-empty-state'); - - describe('component rendering', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders the extended dashboard panel with correct props', () => { - const panel = findExtendedDashboardPanel(); - - expect(panel.props('title')).toBe('Vulnerabilities over time'); - expect(panel.props('tooltip')).toEqual({ - description: 'Vulnerability trends over time', - }); - }); - - it('renders all filter components', () => { - expect(findOverTimePeriodSelector().exists()).toBe(true); - expect(findSeverityFilter().exists()).toBe(true); - expect(findOverTimeGroupBy().exists()).toBe(true); - }); - - it('renders the vulnerabilities over time chart when data is available', async () => { - await waitForPromises(); - - expect(findVulnerabilitiesOverTimeChart().exists()).toBe(true); - }); - - it('passes severity value to OverTimeGroupBy by default', () => { - expect(findOverTimeGroupBy().props('value')).toBe('severity'); - }); - - it('sets initial time period to 30 days', () => { - expect(findOverTimePeriodSelector().props('value')).toBe(30); - }); - }); - - describe('chunked data fetching', () => { - describe('30-day period (default)', () => { - it('fetches only 30-day chunk for default period', async () => { - const { vulnerabilitiesOverTimeHandler } = createComponent(); - await waitForPromises(); - - expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledTimes(1); - expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith({ - fullPath: mockNamespacePath, - reportType: mockFilters.reportType, - severity: [], - includeBySeverity: true, - includeByReportType: false, - startDate: thirtyDaysAgoInIsoFormat, - endDate: todayInIsoFormat, - }); - }); - }); - - describe('60-day period', () => { - it('fetches 30-day and 60-day chunks when period is set to 60', async () => { - const { vulnerabilitiesOverTimeHandler } = createComponent(); - - await findOverTimePeriodSelector().vm.$emit('input', 60); - await waitForPromises(); - - expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledTimes(2); - - expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - startDate: thirtyDaysAgoInIsoFormat, - endDate: todayInIsoFormat, - }), - ); - - expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - startDate: sixtyDaysAgoInIsoFormat, - endDate: thirtyOneDaysAgoInIsoFormat, - }), - ); - }); - }); - - describe('90-day period', () => { - it('fetches all three chunks when period is set to 90', async () => { - const { vulnerabilitiesOverTimeHandler } = createComponent(); - - await findOverTimePeriodSelector().vm.$emit('input', 90); - await waitForPromises(); - - expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledTimes(3); - - expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - startDate: thirtyDaysAgoInIsoFormat, - endDate: todayInIsoFormat, - }), - ); - - expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - startDate: sixtyDaysAgoInIsoFormat, - endDate: thirtyOneDaysAgoInIsoFormat, - }), - ); - - expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - startDate: ninetyDaysAgoInIsoFormat, - endDate: sixtyOneDaysAgoInIsoFormat, - }), - ); - }); - }); - }); - - describe('namespace type support', () => { - describe('group namespace', () => { - it('includes projectId in query variables for groups', async () => { - const groupFilters = { projectId: 'gid://gitlab/Project/123', reportType: ['SAST'] }; - const { vulnerabilitiesOverTimeHandler } = createComponent({ - props: { filters: groupFilters }, - namespaceType: 'group', - }); - - await waitForPromises(); - - expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - projectId: 'gid://gitlab/Project/123', - }), - ); - }); - - it('extracts data from group field in GraphQL response', async () => { - createComponent({ namespaceType: 'group' }); - await waitForPromises(); - - expect(findVulnerabilitiesOverTimeChart().exists()).toBe(true); - }); - }); - - describe('project namespace', () => { - it('does not include projectId in query variables for projects', async () => { - const { vulnerabilitiesOverTimeHandler } = createComponent({ - namespaceType: 'project', - }); - - await waitForPromises(); - - expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( - expect.not.objectContaining({ - projectId: expect.anything(), - }), - ); - }); - - it('extracts data from project field in GraphQL response', async () => { - createComponent({ namespaceType: 'project' }); - await waitForPromises(); - - expect(findVulnerabilitiesOverTimeChart().exists()).toBe(true); - }); - }); - }); - - describe('filters prop', () => { - it('passes combined filters to VulnerabilitiesOverTimeChart component', async () => { - const customFilters = { - reportType: ['SAST', 'DAST'], - severity: ['HIGH', 'CRITICAL'], - }; - - createComponent({ - props: { filters: customFilters }, - }); - - await waitForPromises(); - - expect(findVulnerabilitiesOverTimeChart().props('filters')).toEqual({ - ...customFilters, - severity: [], - }); - }); - - it('combines props filters with panel level filters', async () => { - const customFilters = { - reportType: ['SAST', 'DAST'], - }; - - const panelLevelFilters = ['HIGH', 'MEDIUM']; - - createComponent({ - props: { filters: customFilters }, - }); - - await waitForPromises(); - - await findSeverityFilter().vm.$emit('input', panelLevelFilters); - await nextTick(); - - expect(findVulnerabilitiesOverTimeChart().props('filters')).toEqual({ - reportType: ['SAST', 'DAST'], - severity: panelLevelFilters, - }); - }); - }); - - describe('group by functionality', () => { - beforeEach(() => { - createComponent(); - }); - - it('switches to report type grouping when report type button is clicked', async () => { - await waitForPromises(); - const overTimeGroupBy = findOverTimeGroupBy(); - - await overTimeGroupBy.vm.$emit('input', 'reportType'); - await waitForPromises(); - - expect(overTimeGroupBy.props('value')).toBe('reportType'); - expect(findVulnerabilitiesOverTimeChart().props('groupedBy')).toBe('reportType'); - }); - - it('re-fetches data when grouping changes', async () => { - const { vulnerabilitiesOverTimeHandler } = createComponent(); - await waitForPromises(); - - const initialCallCount = vulnerabilitiesOverTimeHandler.mock.calls.length; - - await findOverTimeGroupBy().vm.$emit('input', 'reportType'); - await waitForPromises(); - - expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledTimes(initialCallCount + 1); - expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - includeBySeverity: false, - includeByReportType: true, - }), - ); - }); - }); - - describe('severity filter', () => { - beforeEach(() => { - createComponent(); - }); - - it('passes the correct value prop', () => { - expect(findSeverityFilter().props('value')).toEqual([]); - }); - - it('updates the GraphQL query variables when severity filter changes', async () => { - const { vulnerabilitiesOverTimeHandler } = createComponent(); - const appliedFilters = ['CRITICAL', 'HIGH']; - - await waitForPromises(); - const initialCallCount = vulnerabilitiesOverTimeHandler.mock.calls.length; - - await findSeverityFilter().vm.$emit('input', appliedFilters); - await waitForPromises(); - - expect(findSeverityFilter().props('value')).toBe(appliedFilters); - expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledTimes(initialCallCount + 1); - expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - severity: appliedFilters, - }), - ); - }); - }); - - describe('chart data formatting', () => { - beforeEach(() => { - createComponent(); - }); - - it('correctly formats chart data from the API response for severity grouping', async () => { - await waitForPromises(); - - const expectedChartData = [ - { - name: 'Critical', - id: 'CRITICAL', - data: [ - ['2022-06-01', 5], - ['2022-06-02', 6], - ], - }, - { - name: 'High', - id: 'HIGH', - data: [ - ['2022-06-01', 10], - ['2022-06-02', 9], - ], - }, - { - name: 'Medium', - id: 'MEDIUM', - data: [ - ['2022-06-01', 15], - ['2022-06-02', 14], - ], - }, - { - name: 'Low', - id: 'LOW', - data: [ - ['2022-06-01', 8], - ['2022-06-02', 7], - ], - }, - ]; - - expect(findVulnerabilitiesOverTimeChart().props('chartSeries')).toEqual(expectedChartData); - }); - - it('correctly formats chart data from the API response for report type grouping', async () => { - await findOverTimeGroupBy().vm.$emit('input', 'reportType'); - await waitForPromises(); - - const expectedChartData = [ - { - name: 'SAST', - id: 'SAST', - data: [['2022-06-01', 8]], - }, - { - name: 'Dependency Scanning', - id: 'DEPENDENCY_SCANNING', - data: [['2022-06-01', 12]], - }, - { - name: 'DAST', - id: 'DAST', - data: [['2022-06-02', 5]], - }, - { - name: 'API Fuzzing', - id: 'API_FUZZING', - data: [['2022-06-02', 3]], - }, - ]; - - expect(findVulnerabilitiesOverTimeChart().props('chartSeries')).toEqual(expectedChartData); - }); - - it('returns empty chart data when no vulnerabilities data is available', async () => { - const emptyResponse = { - data: { - group: { - id: 'gid://gitlab/Group/1', - securityMetrics: { - vulnerabilitiesOverTime: { - nodes: [], - }, - }, - }, - }, - }; - - createComponent({ - mockVulnerabilitiesOverTimeHandler: jest.fn().mockResolvedValue(emptyResponse), - }); - await waitForPromises(); - - expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); - expect(findEmptyState().text()).toBe('No results found'); - }); - - it('combines data from multiple chunks correctly', async () => { - const mockHandler = jest - .fn() - .mockResolvedValueOnce(createMockData('group')) // 30-day chunk - .mockResolvedValueOnce(createMockData('group')); // 60-day chunk - - createComponent({ - mockVulnerabilitiesOverTimeHandler: mockHandler, - }); - - await findOverTimePeriodSelector().vm.$emit('input', 60); - await waitForPromises(); - - expect(findVulnerabilitiesOverTimeChart().props('chartSeries')).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: 'Critical', - id: 'CRITICAL', - data: expect.arrayContaining([ - ['2022-06-01', 5], - ['2022-06-02', 6], - ]), - }), - ]), - ); - }); - }); - - describe('loading state', () => { - it('passes loading state to panels base', async () => { - createComponent(); - - expect(findExtendedDashboardPanel().props('loading')).toBe(true); - - await waitForPromises(); - - expect(findExtendedDashboardPanel().props('loading')).toBe(false); - }); - - it('shows loading state when switching time periods', async () => { - createComponent(); - await waitForPromises(); - - expect(findExtendedDashboardPanel().props('loading')).toBe(false); - - await findOverTimePeriodSelector().vm.$emit('input', 60); - - expect(findExtendedDashboardPanel().props('loading')).toBe(true); - - await waitForPromises(); - - expect(findExtendedDashboardPanel().props('loading')).toBe(false); - }); - }); - - describe('error handling', () => { - describe.each` - errorType | mockVulnerabilitiesOverTimeHandler - ${'GraphQL query failures'} | ${jest.fn().mockRejectedValue(new Error('GraphQL query failed'))} - ${'server error responses'} | ${jest.fn().mockResolvedValue({ errors: [{ message: 'Internal server error' }] })} - `('$errorType', ({ mockVulnerabilitiesOverTimeHandler }) => { - beforeEach(async () => { - createComponent({ - mockVulnerabilitiesOverTimeHandler, - }); - - await waitForPromises(); - }); - - it('sets the panel alert state', () => { - expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); - }); - - it('does not render the chart component', () => { - expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); - }); - - it('renders the correct error message', () => { - expect(findEmptyState().text()).toBe('Something went wrong. Please try again.'); - }); - }); - - it('handles partial chunk failures gracefully', async () => { - const mockHandler = jest - .fn() - .mockResolvedValueOnce(createMockData('group')) // 30-day chunk succeeds - .mockRejectedValueOnce(new Error('Network error')); // 60-day chunk fails - - createComponent({ - mockVulnerabilitiesOverTimeHandler: mockHandler, - }); - - // Switch to 60-day period to trigger multiple chunks - await findOverTimePeriodSelector().vm.$emit('input', 60); - await waitForPromises(); - - // Should show error state when any chunk fails - expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); - expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); - }); - }); -}); -- GitLab From 28fc704ef21fa5a369c02dca3215c1e555b37bcb Mon Sep 17 00:00:00 2001 From: Dave Pisek Date: Tue, 30 Sep 2025 18:54:59 +0200 Subject: [PATCH 19/22] WIP: Make requests sequential --- .../group_vulnerabilities_over_time_panel.vue | 9 ++++--- ...roject_vulnerabilities_over_time_panel.vue | 5 +++- ...up_vulnerabilities_over_time_panel_spec.js | 2 +- ...ct_vulnerabilities_over_time_panel_spec.js | 24 +------------------ 4 files changed, 12 insertions(+), 28 deletions(-) diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/group_vulnerabilities_over_time_panel.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/group_vulnerabilities_over_time_panel.vue index 4428faebee3641..5e3af46fd775a9 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/shared/group_vulnerabilities_over_time_panel.vue +++ b/ee/app/assets/javascripts/security_dashboard/components/shared/group_vulnerabilities_over_time_panel.vue @@ -4,7 +4,7 @@ import { s__ } from '~/locale'; import { formatDate, getDateInPast } from '~/lib/utils/datetime_utility'; import VulnerabilitiesOverTimeChart from 'ee/security_dashboard/components/shared/charts/open_vulnerabilities_over_time.vue'; import { formatVulnerabilitiesOverTimeData } from 'ee/security_dashboard/utils/chart_utils'; -import groupVulnerabilitiesOverTime from 'ee/security_dashboard/graphql/queries/group_vulnerabilities_over_time.query.graphql'; +import vulnerabilitiesOverTimeQuery from 'ee/security_dashboard/graphql/queries/group_vulnerabilities_over_time.query.graphql'; import OverTimeSeverityFilter from './over_time_severity_filter.vue'; import OverTimeGroupBy from './over_time_group_by.vue'; import OverTimePeriodSelector from './over_time_period_selector.vue'; @@ -98,7 +98,10 @@ export default { this.fetchError = false; try { - await Promise.all(this.lookBackDatesToLoad.map(this.loadLookBackWindow)); + // Note: we want to load each chunk sequentially for BE-performance reasons + for await (const lookBackDate of this.lookBackDatesToLoad) { + await this.loadLookBackWindow(lookBackDate); + } } catch (error) { this.fetchError = true; } finally { @@ -110,7 +113,7 @@ export default { const endDate = formatDate(getDateInPast(new Date(), endDays), 'isoDate'); const result = await this.$apollo.query({ - query: groupVulnerabilitiesOverTime, + query: vulnerabilitiesOverTimeQuery, variables: { ...this.baseQueryVariables, startDate, diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/project_vulnerabilities_over_time_panel.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/project_vulnerabilities_over_time_panel.vue index 212d0b57f09a9c..7fb97226d33415 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/shared/project_vulnerabilities_over_time_panel.vue +++ b/ee/app/assets/javascripts/security_dashboard/components/shared/project_vulnerabilities_over_time_panel.vue @@ -97,7 +97,10 @@ export default { this.fetchError = false; try { - await Promise.all(this.lookBackDatesToLoad.map(this.loadLookBackWindow)); + // Note: we want to load each chunk sequentially for BE-performance reasons + for await (const lookBackDate of this.lookBackDatesToLoad) { + await this.loadLookBackWindow(lookBackDate); + } } catch (error) { this.fetchError = true; } finally { diff --git a/ee/spec/frontend/security_dashboard/components/shared/group_vulnerabilities_over_time_panel_spec.js b/ee/spec/frontend/security_dashboard/components/shared/group_vulnerabilities_over_time_panel_spec.js index be6060834c549e..1ec40e98453f7f 100644 --- a/ee/spec/frontend/security_dashboard/components/shared/group_vulnerabilities_over_time_panel_spec.js +++ b/ee/spec/frontend/security_dashboard/components/shared/group_vulnerabilities_over_time_panel_spec.js @@ -367,7 +367,7 @@ describe('GroupVulnerabilitiesOverTimePanel', () => { const mockHandler = jest .fn() .mockRejectedValueOnce(new Error('Initial error')) // First call fails - .mockResolvedValueOnce(createMockData()); // Retry succeeds + .mockResolvedValue(createMockData()); // Retry succeeds createComponent({ mockVulnerabilitiesOverTimeHandler: mockHandler, diff --git a/ee/spec/frontend/security_dashboard/components/shared/project_vulnerabilities_over_time_panel_spec.js b/ee/spec/frontend/security_dashboard/components/shared/project_vulnerabilities_over_time_panel_spec.js index 9830660961af66..0106b6cc849325 100644 --- a/ee/spec/frontend/security_dashboard/components/shared/project_vulnerabilities_over_time_panel_spec.js +++ b/ee/spec/frontend/security_dashboard/components/shared/project_vulnerabilities_over_time_panel_spec.js @@ -376,7 +376,7 @@ describe('ProjectVulnerabilitiesOverTimePanel', () => { const mockHandler = jest .fn() .mockRejectedValueOnce(new Error('Initial error')) // First call fails - .mockResolvedValueOnce(createMockData()); // Retry succeeds + .mockResolvedValue(createMockData()); // Retry succeeds createComponent({ mockVulnerabilitiesOverTimeHandler: mockHandler, @@ -384,36 +384,14 @@ describe('ProjectVulnerabilitiesOverTimePanel', () => { await waitForPromises(); - // Should show error state initially expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); - // Trigger retry by changing time period await findOverTimePeriodSelector().vm.$emit('input', 60); await waitForPromises(); - // Should show success state after retry expect(findExtendedDashboardPanel().props('showAlertState')).toBe(false); expect(findVulnerabilitiesOverTimeChart().exists()).toBe(true); }); - - it('maintains project-specific behavior during error scenarios', async () => { - const mockHandler = jest.fn().mockRejectedValue(new Error('Project error')); - - createComponent({ - mockVulnerabilitiesOverTimeHandler: mockHandler, - }); - - await waitForPromises(); - - // Verify that even during error, no projectId is included in queries - expect(mockHandler).toHaveBeenCalledWith( - expect.not.objectContaining({ - projectId: expect.anything(), - }), - ); - - expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); - }); }); }); -- GitLab From a8624ba30c6285f9b4ab9f14f535842d7eb31492 Mon Sep 17 00:00:00 2001 From: Dave Pisek Date: Tue, 30 Sep 2025 18:59:46 +0200 Subject: [PATCH 20/22] WIP: Minor improvements --- .../components/shared/over_time_period_selector.vue | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/over_time_period_selector.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/over_time_period_selector.vue index a7ef4ce999e99b..e8913fabffcb7d 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/shared/over_time_period_selector.vue +++ b/ee/app/assets/javascripts/security_dashboard/components/shared/over_time_period_selector.vue @@ -42,25 +42,19 @@ export default { return this.selectedOption?.text || TIME_PERIOD_OPTIONS[0].text; }, }, - watch: { - selected(newValue) { - this.$emit('input', newValue); - }, - }, - created() { - this.timePeriodOptions = TIME_PERIOD_OPTIONS; - }, + timePeriodOptions: TIME_PERIOD_OPTIONS, }; -- GitLab From bdd4c9dd4a02b8184e7ce4d4572d47ab3bc2848c Mon Sep 17 00:00:00 2001 From: Dave Pisek Date: Tue, 30 Sep 2025 19:07:47 +0200 Subject: [PATCH 21/22] WIP: Clean up small issues --- .../shared/group_vulnerabilities_over_time_panel.vue | 8 +++----- .../shared/project_vulnerabilities_over_time_panel.vue | 6 ++---- .../shared/group_vulnerabilities_over_time_panel_spec.js | 4 ++-- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/group_vulnerabilities_over_time_panel.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/group_vulnerabilities_over_time_panel.vue index 5e3af46fd775a9..d879186cb5cb91 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/shared/group_vulnerabilities_over_time_panel.vue +++ b/ee/app/assets/javascripts/security_dashboard/components/shared/group_vulnerabilities_over_time_panel.vue @@ -3,8 +3,8 @@ import ExtendedDashboardPanel from '~/vue_shared/components/customizable_dashboa import { s__ } from '~/locale'; import { formatDate, getDateInPast } from '~/lib/utils/datetime_utility'; import VulnerabilitiesOverTimeChart from 'ee/security_dashboard/components/shared/charts/open_vulnerabilities_over_time.vue'; +import groupVulnerabilitiesOverTime from 'ee/security_dashboard/graphql/queries/group_vulnerabilities_over_time.query.graphql'; import { formatVulnerabilitiesOverTimeData } from 'ee/security_dashboard/utils/chart_utils'; -import vulnerabilitiesOverTimeQuery from 'ee/security_dashboard/graphql/queries/group_vulnerabilities_over_time.query.graphql'; import OverTimeSeverityFilter from './over_time_severity_filter.vue'; import OverTimeGroupBy from './over_time_group_by.vue'; import OverTimePeriodSelector from './over_time_period_selector.vue'; @@ -61,7 +61,7 @@ export default { return formatVulnerabilitiesOverTimeData(selectedChartData, this.groupedBy); }, baseQueryVariables() { - const variables = { + return { reportType: this.filters.reportType, severity: this.panelLevelFilters.severity, includeBySeverity: this.groupedBy === 'severity', @@ -69,8 +69,6 @@ export default { fullPath: this.groupFullPath, projectId: this.filters.projectId, }; - - return variables; }, lookBackDatesToLoad() { return [ @@ -113,7 +111,7 @@ export default { const endDate = formatDate(getDateInPast(new Date(), endDays), 'isoDate'); const result = await this.$apollo.query({ - query: vulnerabilitiesOverTimeQuery, + query: groupVulnerabilitiesOverTime, variables: { ...this.baseQueryVariables, startDate, diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/project_vulnerabilities_over_time_panel.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/project_vulnerabilities_over_time_panel.vue index 7fb97226d33415..22e6edfeea5fe7 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/shared/project_vulnerabilities_over_time_panel.vue +++ b/ee/app/assets/javascripts/security_dashboard/components/shared/project_vulnerabilities_over_time_panel.vue @@ -3,8 +3,8 @@ import ExtendedDashboardPanel from '~/vue_shared/components/customizable_dashboa import { s__ } from '~/locale'; import { formatDate, getDateInPast } from '~/lib/utils/datetime_utility'; import VulnerabilitiesOverTimeChart from 'ee/security_dashboard/components/shared/charts/open_vulnerabilities_over_time.vue'; -import { formatVulnerabilitiesOverTimeData } from 'ee/security_dashboard/utils/chart_utils'; import projectVulnerabilitiesOverTime from 'ee/security_dashboard/graphql/queries/project_vulnerabilities_over_time.query.graphql'; +import { formatVulnerabilitiesOverTimeData } from 'ee/security_dashboard/utils/chart_utils'; import OverTimeSeverityFilter from './over_time_severity_filter.vue'; import OverTimeGroupBy from './over_time_group_by.vue'; import OverTimePeriodSelector from './over_time_period_selector.vue'; @@ -61,15 +61,13 @@ export default { return formatVulnerabilitiesOverTimeData(selectedChartData, this.groupedBy); }, baseQueryVariables() { - const variables = { + return { reportType: this.filters.reportType, severity: this.panelLevelFilters.severity, includeBySeverity: this.groupedBy === 'severity', includeByReportType: this.groupedBy === 'reportType', fullPath: this.projectFullPath, }; - - return variables; }, lookBackDatesToLoad() { return [ diff --git a/ee/spec/frontend/security_dashboard/components/shared/group_vulnerabilities_over_time_panel_spec.js b/ee/spec/frontend/security_dashboard/components/shared/group_vulnerabilities_over_time_panel_spec.js index 1ec40e98453f7f..3c32d96198c7a6 100644 --- a/ee/spec/frontend/security_dashboard/components/shared/group_vulnerabilities_over_time_panel_spec.js +++ b/ee/spec/frontend/security_dashboard/components/shared/group_vulnerabilities_over_time_panel_spec.js @@ -74,9 +74,9 @@ describe('GroupVulnerabilitiesOverTimePanel', () => { const findVulnerabilitiesOverTimeChart = () => wrapper.findComponent(VulnerabilitiesOverTimeChart); const findOverTimeGroupBy = () => wrapper.findComponent(OverTimeGroupBy); - const findOverTimePeriodSelector = () => wrapper.findComponent(OverTimePeriodSelector); - const findSeverityFilter = () => wrapper.findComponent(OverTimeSeverityFilter); const findEmptyState = () => wrapper.findByTestId('vulnerabilities-over-time-empty-state'); + const findSeverityFilter = () => wrapper.findComponent(OverTimeSeverityFilter); + const findOverTimePeriodSelector = () => wrapper.findComponent(OverTimePeriodSelector); describe('component rendering', () => { beforeEach(() => { -- GitLab From a8947931fa10abeb88fd0b94e9ffee5c5fab569b Mon Sep 17 00:00:00 2001 From: Dave Pisek Date: Tue, 30 Sep 2025 19:13:33 +0200 Subject: [PATCH 22/22] WIP: clean up specs --- ...up_vulnerabilities_over_time_panel_spec.js | 85 +++++++++---------- ...ct_vulnerabilities_over_time_panel_spec.js | 82 +++++++++--------- 2 files changed, 78 insertions(+), 89 deletions(-) diff --git a/ee/spec/frontend/security_dashboard/components/shared/group_vulnerabilities_over_time_panel_spec.js b/ee/spec/frontend/security_dashboard/components/shared/group_vulnerabilities_over_time_panel_spec.js index 3c32d96198c7a6..66727ad25ab60c 100644 --- a/ee/spec/frontend/security_dashboard/components/shared/group_vulnerabilities_over_time_panel_spec.js +++ b/ee/spec/frontend/security_dashboard/components/shared/group_vulnerabilities_over_time_panel_spec.js @@ -83,28 +83,32 @@ describe('GroupVulnerabilitiesOverTimePanel', () => { createComponent(); }); - it('renders the extended dashboard panel with correct props', () => { - const panel = findExtendedDashboardPanel(); + it('passes the correct title to the panels base', () => { + expect(findExtendedDashboardPanel().props('title')).toBe('Vulnerabilities over time'); + }); - expect(panel.props('title')).toBe('Vulnerabilities over time'); - expect(panel.props('tooltip')).toEqual({ + it('passes the correct tooltip to the panels base', () => { + expect(findExtendedDashboardPanel().props('tooltip')).toEqual({ description: 'Vulnerability trends over time', }); }); + it('renders the vulnerabilities over time chart when data is available', async () => { + await waitForPromises(); + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(true); + }); + + it('passes severity value to OverTimeGroupBy by default', () => { + expect(findOverTimeGroupBy().props('value')).toBe('severity'); + }); + it('renders all filter components', () => { expect(findOverTimePeriodSelector().exists()).toBe(true); expect(findSeverityFilter().exists()).toBe(true); expect(findOverTimeGroupBy().exists()).toBe(true); }); - it('renders the vulnerabilities over time chart when data is available', async () => { - await waitForPromises(); - - expect(findVulnerabilitiesOverTimeChart().exists()).toBe(true); - }); - - it('sets initial time period to 30 days', () => { + it('sets initial time period for the chart data to 30 days', () => { expect(findOverTimePeriodSelector().props('value')).toBe(30); }); }); @@ -126,7 +130,7 @@ describe('GroupVulnerabilitiesOverTimePanel', () => { }); }); - it('refetches data when time period changes', async () => { + it('re-fetches data when time period changes', async () => { const { vulnerabilitiesOverTimeHandler } = createComponent(); await waitForPromises(); @@ -214,21 +218,6 @@ describe('GroupVulnerabilitiesOverTimePanel', () => { }), ); }); - - it('shows loading state when switching time periods', async () => { - createComponent(); - await waitForPromises(); - - expect(findExtendedDashboardPanel().props('loading')).toBe(false); - - await findOverTimePeriodSelector().vm.$emit('input', 60); - - expect(findExtendedDashboardPanel().props('loading')).toBe(true); - - await waitForPromises(); - - expect(findExtendedDashboardPanel().props('loading')).toBe(false); - }); }); describe('filters', () => { @@ -264,6 +253,21 @@ describe('GroupVulnerabilitiesOverTimePanel', () => { expect(findExtendedDashboardPanel().props('loading')).toBe(false); }); + + it('shows loading state when switching time periods', async () => { + createComponent(); + await waitForPromises(); + + expect(findExtendedDashboardPanel().props('loading')).toBe(false); + + await findOverTimePeriodSelector().vm.$emit('input', 60); + + expect(findExtendedDashboardPanel().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findExtendedDashboardPanel().props('loading')).toBe(false); + }); }); describe('error handling', () => { @@ -296,8 +300,8 @@ describe('GroupVulnerabilitiesOverTimePanel', () => { it('handles error when switching time periods', async () => { const mockHandler = jest .fn() - .mockResolvedValueOnce(createMockData()) // Initial load succeeds - .mockRejectedValueOnce(new Error('Network error')); // Time period change fails + .mockResolvedValueOnce(createMockData()) + .mockRejectedValueOnce(new Error('Network error')); createComponent({ mockVulnerabilitiesOverTimeHandler: mockHandler, @@ -305,15 +309,12 @@ describe('GroupVulnerabilitiesOverTimePanel', () => { await waitForPromises(); - // Initial load should succeed expect(findExtendedDashboardPanel().props('showAlertState')).toBe(false); expect(findVulnerabilitiesOverTimeChart().exists()).toBe(true); - // Switch time period - this should fail await findOverTimePeriodSelector().vm.$emit('input', 60); await waitForPromises(); - // Should show error state expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); expect(findEmptyState().text()).toBe('Something went wrong. Please try again.'); @@ -322,18 +323,16 @@ describe('GroupVulnerabilitiesOverTimePanel', () => { it('handles partial chunk failures gracefully', async () => { const mockHandler = jest .fn() - .mockResolvedValueOnce(createMockData()) // 30-day chunk succeeds - .mockRejectedValueOnce(new Error('Network timeout')); // 60-day chunk fails + .mockResolvedValueOnce(createMockData()) + .mockRejectedValueOnce(new Error('Network timeout')); createComponent({ mockVulnerabilitiesOverTimeHandler: mockHandler, }); - // Switch to 60-day period to trigger multiple chunks await findOverTimePeriodSelector().vm.$emit('input', 60); await waitForPromises(); - // Should show error state when any chunk fails expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); expect(findEmptyState().text()).toBe('Something went wrong. Please try again.'); @@ -342,8 +341,8 @@ describe('GroupVulnerabilitiesOverTimePanel', () => { it('handles error during filter changes', async () => { const mockHandler = jest .fn() - .mockResolvedValueOnce(createMockData()) // Initial load succeeds - .mockRejectedValueOnce(new Error('Filter error')); // Filter change fails + .mockResolvedValueOnce(createMockData()) + .mockRejectedValueOnce(new Error('Filter error')); createComponent({ mockVulnerabilitiesOverTimeHandler: mockHandler, @@ -351,14 +350,11 @@ describe('GroupVulnerabilitiesOverTimePanel', () => { await waitForPromises(); - // Initial load should succeed expect(findExtendedDashboardPanel().props('showAlertState')).toBe(false); - // Change severity filter - this should fail await findSeverityFilter().vm.$emit('input', ['CRITICAL']); await waitForPromises(); - // Should show error state expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); }); @@ -366,8 +362,8 @@ describe('GroupVulnerabilitiesOverTimePanel', () => { it('resets error state when retrying after successful data fetch', async () => { const mockHandler = jest .fn() - .mockRejectedValueOnce(new Error('Initial error')) // First call fails - .mockResolvedValue(createMockData()); // Retry succeeds + .mockRejectedValueOnce(new Error('Initial error')) + .mockResolvedValue(createMockData()); createComponent({ mockVulnerabilitiesOverTimeHandler: mockHandler, @@ -375,15 +371,12 @@ describe('GroupVulnerabilitiesOverTimePanel', () => { await waitForPromises(); - // Should show error state initially expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); - // Trigger retry by changing time period await findOverTimePeriodSelector().vm.$emit('input', 60); await waitForPromises(); - // Should show success state after retry expect(findExtendedDashboardPanel().props('showAlertState')).toBe(false); expect(findVulnerabilitiesOverTimeChart().exists()).toBe(true); }); diff --git a/ee/spec/frontend/security_dashboard/components/shared/project_vulnerabilities_over_time_panel_spec.js b/ee/spec/frontend/security_dashboard/components/shared/project_vulnerabilities_over_time_panel_spec.js index 0106b6cc849325..e9e42fa8bc0576 100644 --- a/ee/spec/frontend/security_dashboard/components/shared/project_vulnerabilities_over_time_panel_spec.js +++ b/ee/spec/frontend/security_dashboard/components/shared/project_vulnerabilities_over_time_panel_spec.js @@ -83,28 +83,32 @@ describe('ProjectVulnerabilitiesOverTimePanel', () => { createComponent(); }); - it('renders the extended dashboard panel with correct props', () => { - const panel = findExtendedDashboardPanel(); + it('passes the correct title to the panels base', () => { + expect(findExtendedDashboardPanel().props('title')).toBe('Vulnerabilities over time'); + }); - expect(panel.props('title')).toBe('Vulnerabilities over time'); - expect(panel.props('tooltip')).toEqual({ + it('passes the correct tooltip to the panels base', () => { + expect(findExtendedDashboardPanel().props('tooltip')).toEqual({ description: 'Vulnerability trends over time', }); }); + it('renders the vulnerabilities over time chart when data is available', async () => { + await waitForPromises(); + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(true); + }); + + it('passes severity value to OverTimeGroupBy by default', () => { + expect(findOverTimeGroupBy().props('value')).toBe('severity'); + }); + it('renders all filter components', () => { expect(findOverTimePeriodSelector().exists()).toBe(true); expect(findSeverityFilter().exists()).toBe(true); expect(findOverTimeGroupBy().exists()).toBe(true); }); - it('renders the vulnerabilities over time chart when data is available', async () => { - await waitForPromises(); - - expect(findVulnerabilitiesOverTimeChart().exists()).toBe(true); - }); - - it('sets initial time period to 30 days', () => { + it('sets initial time period for the chart data to 30 days', () => { expect(findOverTimePeriodSelector().props('value')).toBe(30); }); }); @@ -136,7 +140,7 @@ describe('ProjectVulnerabilitiesOverTimePanel', () => { ); }); - it('refetches data when time period changes', async () => { + it('re-fetches data when time period changes', async () => { const { vulnerabilitiesOverTimeHandler } = createComponent(); await waitForPromises(); @@ -223,21 +227,6 @@ describe('ProjectVulnerabilitiesOverTimePanel', () => { }), ); }); - - it('shows loading state when switching time periods', async () => { - createComponent(); - await waitForPromises(); - - expect(findExtendedDashboardPanel().props('loading')).toBe(false); - - await findOverTimePeriodSelector().vm.$emit('input', 60); - - expect(findExtendedDashboardPanel().props('loading')).toBe(true); - - await waitForPromises(); - - expect(findExtendedDashboardPanel().props('loading')).toBe(false); - }); }); describe('filters', () => { @@ -273,6 +262,21 @@ describe('ProjectVulnerabilitiesOverTimePanel', () => { expect(findExtendedDashboardPanel().props('loading')).toBe(false); }); + + it('shows loading state when switching time periods', async () => { + createComponent(); + await waitForPromises(); + + expect(findExtendedDashboardPanel().props('loading')).toBe(false); + + await findOverTimePeriodSelector().vm.$emit('input', 60); + + expect(findExtendedDashboardPanel().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findExtendedDashboardPanel().props('loading')).toBe(false); + }); }); describe('error handling', () => { @@ -305,8 +309,8 @@ describe('ProjectVulnerabilitiesOverTimePanel', () => { it('handles error when switching time periods', async () => { const mockHandler = jest .fn() - .mockResolvedValueOnce(createMockData()) // Initial load succeeds - .mockRejectedValueOnce(new Error('Network error')); // Time period change fails + .mockResolvedValueOnce(createMockData()) + .mockRejectedValueOnce(new Error('Network error')); createComponent({ mockVulnerabilitiesOverTimeHandler: mockHandler, @@ -314,15 +318,12 @@ describe('ProjectVulnerabilitiesOverTimePanel', () => { await waitForPromises(); - // Initial load should succeed expect(findExtendedDashboardPanel().props('showAlertState')).toBe(false); expect(findVulnerabilitiesOverTimeChart().exists()).toBe(true); - // Switch time period - this should fail await findOverTimePeriodSelector().vm.$emit('input', 60); await waitForPromises(); - // Should show error state expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); expect(findEmptyState().text()).toBe('Something went wrong. Please try again.'); @@ -331,18 +332,16 @@ describe('ProjectVulnerabilitiesOverTimePanel', () => { it('handles partial chunk failures gracefully', async () => { const mockHandler = jest .fn() - .mockResolvedValueOnce(createMockData()) // 30-day chunk succeeds - .mockRejectedValueOnce(new Error('Network timeout')); // 60-day chunk fails + .mockResolvedValueOnce(createMockData()) + .mockRejectedValueOnce(new Error('Network timeout')); createComponent({ mockVulnerabilitiesOverTimeHandler: mockHandler, }); - // Switch to 60-day period to trigger multiple chunks await findOverTimePeriodSelector().vm.$emit('input', 60); await waitForPromises(); - // Should show error state when any chunk fails expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); expect(findEmptyState().text()).toBe('Something went wrong. Please try again.'); @@ -351,8 +350,8 @@ describe('ProjectVulnerabilitiesOverTimePanel', () => { it('handles error during filter changes', async () => { const mockHandler = jest .fn() - .mockResolvedValueOnce(createMockData()) // Initial load succeeds - .mockRejectedValueOnce(new Error('Filter error')); // Filter change fails + .mockResolvedValueOnce(createMockData()) + .mockRejectedValueOnce(new Error('Filter error')); createComponent({ mockVulnerabilitiesOverTimeHandler: mockHandler, @@ -360,14 +359,11 @@ describe('ProjectVulnerabilitiesOverTimePanel', () => { await waitForPromises(); - // Initial load should succeed expect(findExtendedDashboardPanel().props('showAlertState')).toBe(false); - // Change severity filter - this should fail await findSeverityFilter().vm.$emit('input', ['CRITICAL']); await waitForPromises(); - // Should show error state expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); }); @@ -375,8 +371,8 @@ describe('ProjectVulnerabilitiesOverTimePanel', () => { it('resets error state when retrying after successful data fetch', async () => { const mockHandler = jest .fn() - .mockRejectedValueOnce(new Error('Initial error')) // First call fails - .mockResolvedValue(createMockData()); // Retry succeeds + .mockRejectedValueOnce(new Error('Initial error')) + .mockResolvedValue(createMockData()); createComponent({ mockVulnerabilitiesOverTimeHandler: mockHandler, -- GitLab