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 ab7f614b15a39d456117c51d0d1f56cb509d65f9..d879186cb5cb915d00a95334e8d045e4c8176f11 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,14 +1,13 @@ + + + + diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/over_time_severity_filter.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/over_time_severity_filter.vue index 004905178f9ade430dfc9fed4524fc5170a87bec..a47ebcc977433a9faa6b9996b46069b6f80c2c01 100644 --- a/ee/app/assets/javascripts/security_dashboard/components/shared/over_time_severity_filter.vue +++ b/ee/app/assets/javascripts/security_dashboard/components/shared/over_time_severity_filter.vue @@ -58,7 +58,7 @@ export default { :selected="selectedSeverities" block multiple - class="gl-mr-2 gl-w-15" + class="gl-w-15 gl-flex-shrink-0" size="small" :toggle-text="severityFilterToggleText" @select="updateSelected" 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 5a9d5bebd7574bcf3cf4786d9b9467ddacc40da8..22e6edfeea5fe78f5bb229cb8692517abdb837e0 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 @@ -2,13 +2,12 @@ import ExtendedDashboardPanel from '~/vue_shared/components/customizable_dashboard/extended_dashboard_panel.vue'; import { s__ } from '~/locale'; import { formatDate, getDateInPast } from '~/lib/utils/datetime_utility'; -import { fetchPolicies } from '~/lib/graphql'; import VulnerabilitiesOverTimeChart from 'ee/security_dashboard/components/shared/charts/open_vulnerabilities_over_time.vue'; import projectVulnerabilitiesOverTime from 'ee/security_dashboard/graphql/queries/project_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: 'ProjectVulnerabilitiesOverTimePanel', @@ -17,6 +16,7 @@ export default { VulnerabilitiesOverTimeChart, OverTimeGroupBy, OverTimeSeverityFilter, + OverTimePeriodSelector, }, inject: ['projectFullPath'], props: { @@ -25,47 +25,101 @@ export default { required: true, }, }, - apollo: { - vulnerabilitiesOverTime: { - fetchPolicy: fetchPolicies.NETWORK_ONLY, - query: projectVulnerabilitiesOverTime, - variables() { - const lookbackDate = getDateInPast(new Date(), DASHBOARD_LOOKBACK_DAYS); - const startDate = formatDate(lookbackDate, 'isoDate'); - const endDate = formatDate(new Date(), 'isoDate'); - - return { - fullPath: this.projectFullPath, - startDate, - endDate, - reportType: this.filters.reportType, - severity: this.panelLevelFilters.severity, - includeBySeverity: this.groupedBy === 'severity', - includeByReportType: this.groupedBy === 'reportType', - }; - }, - update(data) { - const rawData = data.project?.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: [], }, }; }, computed: { + combinedFilters() { + return { + ...this.filters, + ...this.panelLevelFilters, + }; + }, hasChartData() { - return this.vulnerabilitiesOverTime.length > 0; + return this.selectedChartData.length > 0; + }, + selectedChartData() { + const selectedChartData = [ + ...(this.selectedTimePeriod >= 90 ? this.chartData.ninetyDays : []), + ...(this.selectedTimePeriod >= 60 ? this.chartData.sixtyDays : []), + ...this.chartData.thirtyDays, + ]; + + return formatVulnerabilitiesOverTimeData(selectedChartData, this.groupedBy); + }, + baseQueryVariables() { + return { + reportType: this.filters.reportType, + severity: this.panelLevelFilters.severity, + includeBySeverity: this.groupedBy === 'severity', + includeByReportType: this.groupedBy === 'reportType', + fullPath: this.projectFullPath, + }; + }, + lookBackDatesToLoad() { + return [ + { key: 'thirtyDays', startDays: 30, endDays: 0 }, + { key: 'sixtyDays', startDays: 60, endDays: 31 }, + { key: 'ninetyDays', startDays: 90, endDays: 61 }, + ].filter(({ startDays }) => startDays <= this.selectedTimePeriod); + }, + }, + watch: { + baseQueryVariables: { + handler() { + this.fetchChartData(); + }, + deep: true, + immediate: true, + }, + selectedTimePeriod() { + this.fetchChartData(); + }, + }, + methods: { + async fetchChartData() { + this.isLoading = true; + this.fetchError = false; + + try { + // 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 { + this.isLoading = false; + } + }, + 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: projectVulnerabilitiesOverTime, + variables: { + ...this.baseQueryVariables, + startDate, + endDate, + }, + }); + + this.chartData[key] = + result.data.project?.securityMetrics?.vulnerabilitiesOverTime?.nodes || []; }, }, tooltip: { @@ -77,20 +131,23 @@ export default { - + + + {{ __('Something went wrong. Please try again.') }} - {{ __('No data available.') }} + {{ __('No results found') }} 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 82dd88dba05c9e39e8a556259500c622ac375ff5..66727ad25ab60cb5f96c53514f8f3be19f42b7e6 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,4 +1,4 @@ -import Vue, { nextTick } from 'vue'; +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'; @@ -6,7 +6,8 @@ import VulnerabilitiesOverTimeChart from 'ee/security_dashboard/components/share 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 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'; @@ -22,9 +23,9 @@ describe('GroupVulnerabilitiesOverTimePanel', () => { let wrapper; const mockGroupFullPath = 'group/subgroup'; - const mockFilters = { projectId: 'gid://gitlab/Project/123' }; + const mockFilters = { projectId: 'gid://gitlab/Project/123', reportType: ['SAST'] }; - const defaultMockVulnerabilitiesOverTimeData = { + const createMockData = () => ({ data: { group: { id: 'gid://gitlab/Group/1', @@ -32,47 +33,27 @@ describe('GroupVulnerabilitiesOverTimePanel', () => { vulnerabilitiesOverTime: { nodes: [ { - date: '2025-06-01', + 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 }, - { 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 }, ], + byReportType: [{ reportType: 'SAST', count: 8 }], }, ], }, }, }, }, - }; + }); const createComponent = ({ props = {}, mockVulnerabilitiesOverTimeHandler = null } = {}) => { + const defaultMockData = createMockData(); const vulnerabilitiesOverTimeHandler = - mockVulnerabilitiesOverTimeHandler || - jest.fn().mockResolvedValue(defaultMockVulnerabilitiesOverTimeData); + mockVulnerabilitiesOverTimeHandler || jest.fn().mockResolvedValue(defaultMockData); const apolloProvider = createMockApollo([ - [vulnerabilitiesOverTime, vulnerabilitiesOverTimeHandler], + [groupVulnerabilitiesOverTime, vulnerabilitiesOverTimeHandler], ]); wrapper = shallowMountExtended(GroupVulnerabilitiesOverTimePanel, { @@ -83,7 +64,6 @@ describe('GroupVulnerabilitiesOverTimePanel', () => { }, provide: { groupFullPath: mockGroupFullPath, - securityVulnerabilitiesPath: '/group/security/vulnerabilities', }, }); @@ -96,6 +76,7 @@ describe('GroupVulnerabilitiesOverTimePanel', () => { const findOverTimeGroupBy = () => wrapper.findComponent(OverTimeGroupBy); const findEmptyState = () => wrapper.findByTestId('vulnerabilities-over-time-empty-state'); const findSeverityFilter = () => wrapper.findComponent(OverTimeSeverityFilter); + const findOverTimePeriodSelector = () => wrapper.findComponent(OverTimePeriodSelector); describe('component rendering', () => { beforeEach(() => { @@ -120,200 +101,137 @@ describe('GroupVulnerabilitiesOverTimePanel', () => { it('passes severity value to OverTimeGroupBy by default', () => { expect(findOverTimeGroupBy().props('value')).toBe('severity'); }); - }); - - describe('filters prop', () => { - it('passes filters to VulnerabilitiesOverTimeChart component', async () => { - 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('renders all filter components', () => { + expect(findOverTimePeriodSelector().exists()).toBe(true); + expect(findSeverityFilter().exists()).toBe(true); + expect(findOverTimeGroupBy().exists()).toBe(true); }); - 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('sets initial time period for the chart data to 30 days', () => { + expect(findOverTimePeriodSelector().props('value')).toBe(30); }); + }); - 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, - }, - }); - + describe('data fetching', () => { + it('fetches data with correct variables for group namespace', async () => { + const { vulnerabilitiesOverTimeHandler } = createComponent(); 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(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith({ + fullPath: mockGroupFullPath, + projectId: mockFilters.projectId, + reportType: mockFilters.reportType, + severity: [], + includeBySeverity: true, + includeByReportType: false, + startDate: thirtyDaysAgoInIsoFormat, + endDate: todayInIsoFormat, }); }); - }); - describe('group by functionality', () => { - beforeEach(() => { - createComponent(); - }); - - it('switches to report type grouping when report type button is clicked', async () => { + it('re-fetches data when time period changes', async () => { + const { vulnerabilitiesOverTimeHandler } = createComponent(); await waitForPromises(); - const overTimeGroupBy = findOverTimeGroupBy(); - await overTimeGroupBy.vm.$emit('input', 'reportType'); - await nextTick(); + const initialCallCount = vulnerabilitiesOverTimeHandler.mock.calls.length; - expect(overTimeGroupBy.props('value')).toBe('reportType'); - }); - - it('switches back to severity grouping when severity button is clicked', async () => { + await findOverTimePeriodSelector().vm.$emit('input', 60); 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'); + expect(vulnerabilitiesOverTimeHandler.mock.calls.length).toBeGreaterThan(initialCallCount); }); }); - describe('Apollo query', () => { - beforeEach(() => { - 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 vulnerabilities over time data when component is created', () => { + 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, - startDate: thirtyDaysAgoInIsoFormat, - endDate: todayInIsoFormat, + reportType: mockFilters.reportType, + severity: [], includeBySeverity: true, includeByReportType: false, - severity: [], - reportType: undefined, + startDate: thirtyDaysAgoInIsoFormat, + endDate: todayInIsoFormat, }); }); - 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('fetches 30-day and 60-day chunks when period is set to 60', async () => { + const { vulnerabilitiesOverTimeHandler } = createComponent(); - it('does not add unsupported filters that are passed', () => { - const unsupportedFilter = ['filterValue']; - const { vulnerabilitiesOverTimeHandler } = createComponent({ - props: { - filters: { unsupportedFilter }, - }, - }); + await findOverTimePeriodSelector().vm.$emit('input', 60); + await waitForPromises(); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledTimes(2); - expect(vulnerabilitiesOverTimeHandler).not.toHaveBeenCalledWith( + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( expect.objectContaining({ - unsupportedFilter, + startDate: thirtyDaysAgoInIsoFormat, + endDate: todayInIsoFormat, + }), + ); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + startDate: sixtyDaysAgoInIsoFormat, + endDate: thirtyOneDaysAgoInIsoFormat, }), ); }); - it('updates query variables when switching to report type grouping', async () => { + it('fetches all three chunks when period is set to 90', async () => { const { vulnerabilitiesOverTimeHandler } = createComponent(); - await findOverTimeGroupBy().vm.$emit('input', 'reportType'); + await findOverTimePeriodSelector().vm.$emit('input', 90); await waitForPromises(); + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledTimes(3); + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( expect.objectContaining({ - includeBySeverity: false, - includeByReportType: true, + startDate: thirtyDaysAgoInIsoFormat, + endDate: todayInIsoFormat, }), ); - }); - }); - describe('severity filter', () => { - beforeEach(() => { - createComponent(); - }); + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + startDate: sixtyDaysAgoInIsoFormat, + endDate: thirtyOneDaysAgoInIsoFormat, + }), + ); - it('passes the correct value prop', () => { - expect(findSeverityFilter().props('value')).toEqual([]); + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + startDate: ninetyDaysAgoInIsoFormat, + endDate: sixtyOneDaysAgoInIsoFormat, + }), + ); }); + }); - it('updates the GraphQL query variables when severity filter changes', async () => { + describe('filters', () => { + it('updates GraphQL query when severity filter changes', async () => { const { vulnerabilitiesOverTimeHandler } = createComponent(); const appliedFilters = ['CRITICAL', 'HIGH']; - findSeverityFilter().vm.$emit('input', appliedFilters); await waitForPromises(); + const initialCallCount = vulnerabilitiesOverTimeHandler.mock.calls.length; - expect(findSeverityFilter().props('value')).toBe(appliedFilters); + await findSeverityFilter().vm.$emit('input', appliedFilters); + await waitForPromises(); + + expect(vulnerabilitiesOverTimeHandler.mock.calls.length).toBeGreaterThan(initialCallCount); expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( expect.objectContaining({ severity: appliedFilters, @@ -322,166 +240,145 @@ describe('GroupVulnerabilitiesOverTimePanel', () => { }); }); - describe('chart data formatting', () => { - beforeEach(() => { + describe('loading state', () => { + it('shows loading state initially', () => { createComponent(); + + expect(findExtendedDashboardPanel().props('loading')).toBe(true); }); - it('correctly formats chart data from the API response for severity grouping', async () => { + it('hides loading state after data is loaded', async () => { + createComponent(); 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(findExtendedDashboardPanel().props('loading')).toBe(false); + }); - expect(findVulnerabilitiesOverTimeChart().props('chartSeries')).toEqual(expectedChartData); + 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', () => { + it('shows error state when GraphQL query fails', async () => { + createComponent({ + mockVulnerabilitiesOverTimeHandler: jest.fn().mockRejectedValue(new Error('GraphQL error')), + }); - it('passes the correct grouped-by prop for severity grouping', async () => { await waitForPromises(); - expect(findVulnerabilitiesOverTimeChart().props('groupedBy')).toBe('severity'); + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); + expect(findEmptyState().text()).toBe('Something went wrong. Please try again.'); }); - it('passes the correct grouped-by prop for report type grouping', async () => { - await findOverTimeGroupBy().vm.$emit('input', 'reportType'); + it('shows error state when server returns error response', async () => { + createComponent({ + mockVulnerabilitiesOverTimeHandler: jest.fn().mockResolvedValue({ + errors: [{ message: 'Internal server error' }], + }), + }); + await waitForPromises(); - expect(findVulnerabilitiesOverTimeChart().props('groupedBy')).toBe('reportType'); + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); + expect(findEmptyState().text()).toBe('Something went wrong. Please try again.'); }); - it('correctly formats chart data from the API response for report type grouping', async () => { - await findOverTimeGroupBy().vm.$emit('input', 'reportType'); + it('handles error when switching time periods', async () => { + const mockHandler = jest + .fn() + .mockResolvedValueOnce(createMockData()) + .mockRejectedValueOnce(new Error('Network error')); + + createComponent({ + mockVulnerabilitiesOverTimeHandler: mockHandler, + }); + 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(findExtendedDashboardPanel().props('showAlertState')).toBe(false); + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(true); - expect(findVulnerabilitiesOverTimeChart().props('chartSeries')).toEqual(expectedChartData); + await findOverTimePeriodSelector().vm.$emit('input', 60); + await waitForPromises(); + + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); + expect(findEmptyState().text()).toBe('Something went wrong. Please try again.'); }); - it('returns empty chart data when no vulnerabilities data is available', async () => { - const emptyResponse = { - data: { - group: { - id: 'gid://gitlab/Group/1', - securityMetrics: { - vulnerabilitiesOverTime: { - nodes: [], - }, - }, - }, - }, - }; + it('handles partial chunk failures gracefully', async () => { + const mockHandler = jest + .fn() + .mockResolvedValueOnce(createMockData()) + .mockRejectedValueOnce(new Error('Network timeout')); createComponent({ - mockVulnerabilitiesOverTimeHandler: jest.fn().mockResolvedValue(emptyResponse), + mockVulnerabilitiesOverTimeHandler: mockHandler, }); + + await findOverTimePeriodSelector().vm.$emit('input', 60); await waitForPromises(); + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); - expect(findEmptyState().text()).toBe('No data available.'); + expect(findEmptyState().text()).toBe('Something went wrong. Please try again.'); }); - }); - describe('loading state', () => { - beforeEach(() => { - createComponent(); - }); + it('handles error during filter changes', async () => { + const mockHandler = jest + .fn() + .mockResolvedValueOnce(createMockData()) + .mockRejectedValueOnce(new Error('Filter error')); - it('passes loading state to panels base', async () => { - expect(findExtendedDashboardPanel().props('loading')).toBe(true); + createComponent({ + mockVulnerabilitiesOverTimeHandler: mockHandler, + }); await waitForPromises(); - expect(findExtendedDashboardPanel().props('loading')).toBe(false); + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(false); + + await findSeverityFilter().vm.$emit('input', ['CRITICAL']); + await waitForPromises(); + + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); + expect(findVulnerabilitiesOverTimeChart().exists()).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('resets error state when retrying after successful data fetch', async () => { + const mockHandler = jest + .fn() + .mockRejectedValueOnce(new Error('Initial error')) + .mockResolvedValue(createMockData()); - it('sets the panel alert state', () => { - expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); + createComponent({ + mockVulnerabilitiesOverTimeHandler: mockHandler, }); - it('does not render the chart component', () => { - expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); - }); + await waitForPromises(); - it('renders the correct error message', () => { - expect(findEmptyState().text()).toBe('Something went wrong. Please try again.'); - }); + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); + + await findOverTimePeriodSelector().vm.$emit('input', 60); + await waitForPromises(); + + 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 65f4237a456e1558427aa27f62a5791510fbd186..e9e42fa8bc0576ed11937e7b839cf66efa677573 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,4 +1,4 @@ -import Vue, { nextTick } from 'vue'; +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'; @@ -6,7 +6,8 @@ import VulnerabilitiesOverTimeChart from 'ee/security_dashboard/components/share 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 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'; @@ -24,7 +25,7 @@ describe('ProjectVulnerabilitiesOverTimePanel', () => { const mockProjectFullPath = 'project-1'; const mockFilters = { reportType: ['API_FUZZING'] }; - const defaultMockVulnerabilitiesOverTimeData = { + const createMockData = () => ({ data: { project: { id: 'gid://gitlab/Project/1', @@ -32,47 +33,27 @@ describe('ProjectVulnerabilitiesOverTimePanel', () => { vulnerabilitiesOverTime: { nodes: [ { - date: '2025-06-01', + 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 }, - { 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 }, + { 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(defaultMockVulnerabilitiesOverTimeData); + mockVulnerabilitiesOverTimeHandler || jest.fn().mockResolvedValue(defaultMockData); const apolloProvider = createMockApollo([ - [vulnerabilitiesOverTime, vulnerabilitiesOverTimeHandler], + [projectVulnerabilitiesOverTime, vulnerabilitiesOverTimeHandler], ]); wrapper = shallowMountExtended(ProjectVulnerabilitiesOverTimePanel, { @@ -83,7 +64,6 @@ describe('ProjectVulnerabilitiesOverTimePanel', () => { }, provide: { projectFullPath: mockProjectFullPath, - securityVulnerabilitiesPath: '/group/project/security/vulnerabilities', }, }); @@ -94,14 +74,15 @@ describe('ProjectVulnerabilitiesOverTimePanel', () => { const findVulnerabilitiesOverTimeChart = () => wrapper.findComponent(VulnerabilitiesOverTimeChart); const findOverTimeGroupBy = () => wrapper.findComponent(OverTimeGroupBy); - const findEmptyState = () => wrapper.findByTestId('vulnerabilities-over-time-empty-state'); + const findOverTimePeriodSelector = () => wrapper.findComponent(OverTimePeriodSelector); const findSeverityFilter = () => wrapper.findComponent(OverTimeSeverityFilter); - - beforeEach(() => { - createComponent(); - }); + const findEmptyState = () => wrapper.findByTestId('vulnerabilities-over-time-empty-state'); describe('component rendering', () => { + beforeEach(() => { + createComponent(); + }); + it('passes the correct title to the panels base', () => { expect(findExtendedDashboardPanel().props('title')).toBe('Vulnerabilities over time'); }); @@ -112,7 +93,7 @@ describe('ProjectVulnerabilitiesOverTimePanel', () => { }); }); - it('renders the vulnerabilities over time chart', async () => { + it('renders the vulnerabilities over time chart when data is available', async () => { await waitForPromises(); expect(findVulnerabilitiesOverTimeChart().exists()).toBe(true); }); @@ -120,105 +101,146 @@ describe('ProjectVulnerabilitiesOverTimePanel', () => { 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('sets initial time period for the chart data to 30 days', () => { + expect(findOverTimePeriodSelector().props('value')).toBe(30); + }); }); - describe('group by functionality', () => { - it('switches to report type grouping when report type button is clicked', async () => { + describe('data fetching', () => { + it('fetches data with correct variables for project namespace', async () => { + const { vulnerabilitiesOverTimeHandler } = createComponent(); await waitForPromises(); - const overTimeGroupBy = findOverTimeGroupBy(); - await overTimeGroupBy.vm.$emit('input', 'reportType'); - await nextTick(); + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith({ + fullPath: mockProjectFullPath, + reportType: mockFilters.reportType, + severity: [], + includeBySeverity: true, + includeByReportType: false, + startDate: thirtyDaysAgoInIsoFormat, + endDate: todayInIsoFormat, + }); + }); + + it('does not include projectId in query variables for projects', async () => { + const { vulnerabilitiesOverTimeHandler } = createComponent(); + await waitForPromises(); - expect(overTimeGroupBy.props('value')).toBe('reportType'); + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( + expect.not.objectContaining({ + projectId: expect.anything(), + }), + ); }); - it('switches back to severity grouping when severity button is clicked', async () => { + it('re-fetches data when time period changes', async () => { + const { vulnerabilitiesOverTimeHandler } = createComponent(); await waitForPromises(); - const overTimeGroupBy = findOverTimeGroupBy(); - await overTimeGroupBy.vm.$emit('input', 'reportType'); - await nextTick(); + const initialCallCount = vulnerabilitiesOverTimeHandler.mock.calls.length; - await overTimeGroupBy.vm.$emit('input', 'severity'); - await nextTick(); + await findOverTimePeriodSelector().vm.$emit('input', 60); + await waitForPromises(); - expect(overTimeGroupBy.props('value')).toBe('severity'); + expect(vulnerabilitiesOverTimeHandler.mock.calls.length).toBeGreaterThan(initialCallCount); }); }); - describe('Apollo query', () => { - it('fetches vulnerabilities over time data when component is created', () => { + 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(vulnerabilitiesOverTimeHandler).toHaveBeenCalledTimes(1); expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith({ fullPath: mockProjectFullPath, reportType: mockFilters.reportType, - startDate: thirtyDaysAgoInIsoFormat, - endDate: todayInIsoFormat, severity: [], includeBySeverity: true, includeByReportType: false, + startDate: thirtyDaysAgoInIsoFormat, + endDate: todayInIsoFormat, }); }); - it('passes filters to the GraphQL query', () => { - const { vulnerabilitiesOverTimeHandler } = createComponent({ - props: { - filters: { reportType: ['API_FUZZING', 'SAST'] }, - }, - }); + 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({ - reportType: ['API_FUZZING', 'SAST'], + startDate: thirtyDaysAgoInIsoFormat, + endDate: todayInIsoFormat, }), ); - }); - it('does not add unsupported filters that are passed', () => { - const unsupportedFilter = ['filterValue']; - const { vulnerabilitiesOverTimeHandler } = createComponent({ - props: { - filters: { unsupportedFilter }, - }, - }); - - expect(vulnerabilitiesOverTimeHandler).not.toHaveBeenCalledWith( + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( expect.objectContaining({ - unsupportedFilter, + startDate: sixtyDaysAgoInIsoFormat, + endDate: thirtyOneDaysAgoInIsoFormat, }), ); }); - it('updates query variables when switching to report type grouping', async () => { + it('fetches all three chunks when period is set to 90', async () => { const { vulnerabilitiesOverTimeHandler } = createComponent(); - await findOverTimeGroupBy().vm.$emit('input', 'reportType'); + await findOverTimePeriodSelector().vm.$emit('input', 90); await waitForPromises(); + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledTimes(3); + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( expect.objectContaining({ - includeBySeverity: false, - includeByReportType: true, + startDate: thirtyDaysAgoInIsoFormat, + endDate: todayInIsoFormat, }), ); - }); - }); - describe('severity filter', () => { - it('passes the correct value prop', () => { - expect(findSeverityFilter().props('value')).toEqual([]); + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + startDate: sixtyDaysAgoInIsoFormat, + endDate: thirtyOneDaysAgoInIsoFormat, + }), + ); + + expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + startDate: ninetyDaysAgoInIsoFormat, + endDate: sixtyOneDaysAgoInIsoFormat, + }), + ); }); + }); - it('updates the GraphQL query variables when severity filter changes', async () => { + describe('filters', () => { + it('updates GraphQL query when severity filter changes', async () => { const { vulnerabilitiesOverTimeHandler } = createComponent(); const appliedFilters = ['CRITICAL', 'HIGH']; - findSeverityFilter().vm.$emit('input', appliedFilters); await waitForPromises(); + const initialCallCount = vulnerabilitiesOverTimeHandler.mock.calls.length; - expect(findSeverityFilter().props('value')).toBe(appliedFilters); + await findSeverityFilter().vm.$emit('input', appliedFilters); + await waitForPromises(); + + expect(vulnerabilitiesOverTimeHandler.mock.calls.length).toBeGreaterThan(initialCallCount); expect(vulnerabilitiesOverTimeHandler).toHaveBeenCalledWith( expect.objectContaining({ severity: appliedFilters, @@ -227,162 +249,145 @@ describe('ProjectVulnerabilitiesOverTimePanel', () => { }); }); - describe('chart data formatting', () => { - it('correctly formats chart data from the API response', async () => { + describe('loading state', () => { + it('shows loading state initially', () => { 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); + expect(findExtendedDashboardPanel().props('loading')).toBe(true); }); - it('passes the correct grouped-by prop for severity grouping', async () => { + it('hides loading state after data is loaded', async () => { + createComponent(); await waitForPromises(); - expect(findVulnerabilitiesOverTimeChart().props('groupedBy')).toBe('severity'); + expect(findExtendedDashboardPanel().props('loading')).toBe(false); }); - it('correctly formats chart data from the API response for report type grouping', async () => { - await findOverTimeGroupBy().vm.$emit('input', 'reportType'); + it('shows loading state when switching time periods', async () => { + createComponent(); 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(findExtendedDashboardPanel().props('loading')).toBe(false); - expect(findVulnerabilitiesOverTimeChart().props('chartSeries')).toEqual(expectedChartData); - }); + await findOverTimePeriodSelector().vm.$emit('input', 60); + + expect(findExtendedDashboardPanel().props('loading')).toBe(true); - 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'); + expect(findExtendedDashboardPanel().props('loading')).toBe(false); }); + }); - it('returns empty chart data when no vulnerabilities data is available', async () => { - const emptyResponse = { - data: { - project: { - id: 'gid://gitlab/Project/1', - securityMetrics: { - vulnerabilitiesOverTime: { - nodes: [], - }, - }, - }, - }, - }; + 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(emptyResponse), + 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('No data available.'); + expect(findEmptyState().text()).toBe('Something went wrong. Please try again.'); }); - }); - describe('loading state', () => { - it('passes loading state to panels base', async () => { - createComponent(); + it('handles error when switching time periods', async () => { + const mockHandler = jest + .fn() + .mockResolvedValueOnce(createMockData()) + .mockRejectedValueOnce(new Error('Network error')); - expect(findExtendedDashboardPanel().props('loading')).toBe(true); + createComponent({ + mockVulnerabilitiesOverTimeHandler: mockHandler, + }); await waitForPromises(); - expect(findExtendedDashboardPanel().props('loading')).toBe(false); + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(false); + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(true); + + await findOverTimePeriodSelector().vm.$emit('input', 60); + await waitForPromises(); + + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); + expect(findEmptyState().text()).toBe('Something went wrong. Please try again.'); }); - }); - 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('handles partial chunk failures gracefully', async () => { + const mockHandler = jest + .fn() + .mockResolvedValueOnce(createMockData()) + .mockRejectedValueOnce(new Error('Network timeout')); - it('sets the panel alert state', () => { - expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); + createComponent({ + mockVulnerabilitiesOverTimeHandler: mockHandler, }); - it('does not render the chart component', () => { - expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); + await findOverTimePeriodSelector().vm.$emit('input', 60); + 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 during filter changes', async () => { + const mockHandler = jest + .fn() + .mockResolvedValueOnce(createMockData()) + .mockRejectedValueOnce(new Error('Filter error')); + + createComponent({ + mockVulnerabilitiesOverTimeHandler: mockHandler, }); - it('renders the correct error message', () => { - expect(wrapper.text()).toBe('Something went wrong. Please try again.'); + await waitForPromises(); + + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(false); + + await findSeverityFilter().vm.$emit('input', ['CRITICAL']); + await waitForPromises(); + + 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')) + .mockResolvedValue(createMockData()); + + createComponent({ + mockVulnerabilitiesOverTimeHandler: mockHandler, }); + + await waitForPromises(); + + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(true); + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(false); + + await findOverTimePeriodSelector().vm.$emit('input', 60); + await waitForPromises(); + + expect(findExtendedDashboardPanel().props('showAlertState')).toBe(false); + expect(findVulnerabilitiesOverTimeChart().exists()).toBe(true); }); }); }); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index c776d1b824c9f2922f5d46d36a4fa46f86400e14..50929b776dd3f3d54f06e349ba67e3c4f323d75c 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 "" @@ -60187,6 +60184,15 @@ msgstr "" msgid "SecurityReports|1 vulnerability attached to 1 issue" msgstr "" +msgid "SecurityReports|30 days" +msgstr "" + +msgid "SecurityReports|60 days" +msgstr "" + +msgid "SecurityReports|90 days" +msgstr "" + msgid "SecurityReports|A comment is required when changing the severity." msgstr "" @@ -60717,6 +60723,9 @@ msgstr "" msgid "SecurityReports|This selection is required." msgstr "" +msgid "SecurityReports|Time period" +msgstr "" + msgid "SecurityReports|To widen your search, change or remove filters above" msgstr ""
{{ __('Something went wrong. Please try again.') }} - {{ __('No data available.') }} + {{ __('No results found') }}