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 {
-
+
+
@@ -98,7 +165,7 @@ export default {
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
new file mode 100644
index 00000000000000..0913a0ded39d34
--- /dev/null
+++ b/ee/app/assets/javascripts/security_dashboard/components/shared/over_time_period_selector.vue
@@ -0,0 +1,69 @@
+
+
+
+
+
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 004905178f9ade..a47ebcc977433a 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"
--
GitLab
From d39c4f56a569d0fbca48d08ada8e790114865312 Mon Sep 17 00:00:00 2001
From: Dave Pisek
Date: Wed, 24 Sep 2025 20:21:25 +0200
Subject: [PATCH 02/22] WIP: update translations
---
.../shared/group_vulnerabilities_over_time_panel.vue | 2 +-
locale/gitlab.pot | 12 ++++++++++++
2 files changed, 13 insertions(+), 1 deletion(-)
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 fe84ef790316db..b2bfdb51215e45 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
@@ -175,7 +175,7 @@ export default {
data-testid="vulnerabilities-over-time-empty-state"
>
{{ __('Something went wrong. Please try again.') }}
- {{ __('No data available.') }}
+ {{ __('No results found') }}
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index c776d1b824c9f2..b654fd07752c42 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -60187,6 +60187,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 +60726,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 ""
--
GitLab
From 4589ce847ab911d6fa683c2472035f2fb8067d24 Mon Sep 17 00:00:00 2001
From: Dave Pisek
Date: Wed, 24 Sep 2025 20:31:38 +0200
Subject: [PATCH 03/22] WIP: Fix linting error
---
.../group_vulnerabilities_over_time_panel.vue | 1 -
.../vulnerabilities_over_time_wrapper.vue | 175 ++++++++++++++++++
2 files changed, 175 insertions(+), 1 deletion(-)
create 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/group_vulnerabilities_over_time_panel.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/group_vulnerabilities_over_time_panel.vue
index b2bfdb51215e45..89ebbab7bb605e 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,7 +1,6 @@
+
+
+
+
+
+
--
GitLab
From 97b9e0bcf5db95ca9cf76f9f09b1cb229a7e7420 Mon Sep 17 00:00:00 2001
From: Dave Pisek
Date: Fri, 26 Sep 2025 13:35:02 +0200
Subject: [PATCH 04/22] Simplify watcher
---
.../group_vulnerabilities_over_time_panel.vue | 32 ++++++++-----------
1 file changed, 14 insertions(+), 18 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 89ebbab7bb605e..7752122271cf81 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
@@ -58,25 +58,26 @@ export default {
...(this.selectedTimePeriod > 60 ? this.chartData.ninetyDays : []),
];
},
+ queryVariables() {
+ return {
+ projectId: this.filters.projectId,
+ reportType: this.filters.reportType,
+ fullPath: this.groupFullPath,
+ includeBySeverity: this.groupedBy === 'severity',
+ includeByReportType: this.groupedBy === 'reportType',
+ severity: this.panelLevelFilters.severity,
+ };
+ },
},
watch: {
- selectedTimePeriod() {
- this.fetchChartData();
- },
- groupedBy() {
- this.fetchChartData();
- },
- filters: {
+ queryVariables: {
handler() {
this.fetchChartData();
},
deep: true,
},
- panelLevelFilters: {
- handler() {
- this.fetchChartData();
- },
- deep: true,
+ selectedTimePeriod() {
+ this.fetchChartData();
},
},
mounted() {
@@ -126,12 +127,7 @@ export default {
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,
+ ...this.queryVariables,
startDate: formatDate(getDateInPast(new Date(), startDays), 'isoDate'),
endDate: formatDate(getDateInPast(new Date(), endDays), 'isoDate'),
},
--
GitLab
From fbc4aa1c8ed65e2978c76d857ef55834c10ba167 Mon Sep 17 00:00:00 2001
From: Dave Pisek
Date: Fri, 26 Sep 2025 13:37:24 +0200
Subject: [PATCH 05/22] Set watcher to immediate
---
.../shared/group_vulnerabilities_over_time_panel.vue | 4 +---
1 file changed, 1 insertion(+), 3 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 7752122271cf81..62f0f8965be6eb 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
@@ -75,14 +75,12 @@ export default {
this.fetchChartData();
},
deep: true,
+ immediate: true,
},
selectedTimePeriod() {
this.fetchChartData();
},
},
- mounted() {
- this.fetchChartData();
- },
methods: {
async fetchChartData() {
this.isLoading = true;
--
GitLab
From aab664c497daf24fdad93ec034f8f2a1b44a4242 Mon Sep 17 00:00:00 2001
From: Dave Pisek
Date: Fri, 26 Sep 2025 16:54:37 +0200
Subject: [PATCH 06/22] Use base component
---
.../group_vulnerabilities_over_time_panel.vue | 165 +--------------
...roject_vulnerabilities_over_time_panel.vue | 95 +--------
.../vulnerabilities_over_time_panel_base.vue | 193 ++++++++++++++++++
3 files changed, 211 insertions(+), 242 deletions(-)
create mode 100644 ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.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 62f0f8965be6eb..92d8c045c4ed00 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,22 +1,11 @@
-
-
-
-
-
-
-
-
-
-
- {{ __('Something went wrong. Please try again.') }}
- {{ __('No results found') }}
-
-
-
+
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 5a9d5bebd7574b..d5df3309f66c49 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,22 +1,11 @@
-
-
-
-
-
-
-
-
- {{ __('Something went wrong. Please try again.') }}
- {{ __('No data available.') }}
-
-
-
+
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
new file mode 100644
index 00000000000000..beb6b9be3a9bc2
--- /dev/null
+++ b/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue
@@ -0,0 +1,193 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ __('Something went wrong. Please try again.') }}
+ {{ __('No results found') }}
+
+
+
+
+
--
GitLab
From 88a6937393d10201b68e4db7d682af5d7a01d6fd Mon Sep 17 00:00:00 2001
From: Dave Pisek
Date: Fri, 26 Sep 2025 17:15:14 +0200
Subject: [PATCH 07/22] Update specs
---
.../vulnerabilities_over_time_panel_base.vue | 2 -
...lnerabilities_over_time_panel_base_spec.js | 577 ++++++++++++++++++
2 files changed, 577 insertions(+), 2 deletions(-)
create 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/vulnerabilities_over_time_panel_base.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerabilities_over_time_panel_base.vue
index beb6b9be3a9bc2..8f19763f736390 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
@@ -168,7 +168,6 @@ export default {
-
@@ -187,7 +186,6 @@ export default {
{{ __('Something went wrong. Please try again.') }}
{{ __('No results found') }}
-
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 @@
-
+
+
+
+
+
+
+
+
+
+
+ {{ __('Something went wrong. Please try again.') }}
+ {{ __('No results found') }}
+
+
+
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 @@
-
+
+
+
+
+
+
+
+
+
+
+ {{ __('Something went wrong. Please try again.') }}
+ {{ __('No results found') }}
+
+
+
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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ __('Something went wrong. Please try again.') }}
- {{ __('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 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