diff --git a/ee/app/assets/javascripts/api.js b/ee/app/assets/javascripts/api.js index 4615b51bda413d8ab5049f14d102ee09de2b3cec..7863b15072243ccc8410f95a4051f63e94f3fc01 100644 --- a/ee/app/assets/javascripts/api.js +++ b/ee/app/assets/javascripts/api.js @@ -303,11 +303,6 @@ export default { return axios.post(url, params); }, - fetchVulnerability(id, params) { - const url = Api.buildUrl(this.vulnerabilityPath).replace(':id', id); - return axios.get(url, params); - }, - changeVulnerabilityState(id, state) { const url = Api.buildUrl(this.vulnerabilityActionPath) .replace(':id', id) diff --git a/ee/app/assets/javascripts/security_dashboard/graphql/header_vulnerability.graphql b/ee/app/assets/javascripts/security_dashboard/graphql/header_vulnerability.graphql new file mode 100644 index 0000000000000000000000000000000000000000..f7b602ee1ab50939894275bef152daee4c6db32d --- /dev/null +++ b/ee/app/assets/javascripts/security_dashboard/graphql/header_vulnerability.graphql @@ -0,0 +1,18 @@ +query vulnerability($id: VulnerabilityID!) { + vulnerability(id: $id) { + state + confirmedAt + detectedAt + dismissedAt + resolvedAt + dismissedBy { + id + } + confirmedBy { + id + } + resolvedBy { + id + } + } +} diff --git a/ee/app/assets/javascripts/vulnerabilities/components/header.vue b/ee/app/assets/javascripts/vulnerabilities/components/header.vue index 8c813103946f9f849d786d126190c8d54802d5c4..091a0043a986a152f49c64f675ab48250872077d 100644 --- a/ee/app/assets/javascripts/vulnerabilities/components/header.vue +++ b/ee/app/assets/javascripts/vulnerabilities/components/header.vue @@ -1,8 +1,7 @@ <script> import { GlLoadingIcon, GlButton, GlBadge } from '@gitlab/ui'; -import Api from 'ee/api'; -import { CancelToken } from 'axios'; import SplitButton from 'ee/vue_shared/security_reports/components/split_button.vue'; +import fetchHeaderVulnerabilityQuery from 'ee/security_dashboard/graphql/header_vulnerability.graphql'; import vulnerabilityStateMutations from 'ee/security_dashboard/graphql/mutate_vulnerability_state'; import axios from '~/lib/utils/axios_utils'; import download from '~/lib/utils/downloader'; @@ -14,9 +13,13 @@ import UsersCache from '~/lib/utils/users_cache'; import ResolutionAlert from './resolution_alert.vue'; import VulnerabilityStateDropdown from './vulnerability_state_dropdown.vue'; import StatusDescription from './status_description.vue'; -import { VULNERABILITY_STATE_OBJECTS, FEEDBACK_TYPES, HEADER_ACTION_BUTTONS } from '../constants'; - -const gidPrefix = 'gid://gitlab/Vulnerability/'; +import { normalizeGraphQLVulnerability } from '../helpers'; +import { + VULNERABILITY_STATE_OBJECTS, + FEEDBACK_TYPES, + HEADER_ACTION_BUTTONS, + gidPrefix, +} from '../constants'; export default { name: 'VulnerabilityHeader', @@ -47,7 +50,7 @@ export default { // prop leading to an error in the footer component. vulnerability: { ...this.initialVulnerability }, user: undefined, - refreshVulnerabilitySource: undefined, + shouldRefreshVulnerability: false, }; }, @@ -57,6 +60,38 @@ export default { detected: 'warning', }, + apollo: { + vulnerability: { + query: fetchHeaderVulnerabilityQuery, + manual: true, + fetchPolicy: 'no-cache', + variables() { + return { + id: `${gidPrefix}${this.vulnerability.id}`, + }; + }, + result({ data: { vulnerability } }) { + this.shouldRefreshVulnerability = false; + this.isLoadingVulnerability = false; + + this.vulnerability = { + ...this.vulnerability, + ...normalizeGraphQLVulnerability(vulnerability), + }; + }, + error() { + createFlash( + s__( + 'VulnerabilityManagement|Something went wrong while trying to refresh the vulnerability. Please try again later.', + ), + ); + }, + skip() { + return !this.shouldRefreshVulnerability; + }, + }, + }, + computed: { stateVariant() { return this.$options.badgeVariants[this.vulnerability.state] || 'neutral'; @@ -144,13 +179,10 @@ export default { variables: { id: `${gidPrefix}${this.vulnerability.id}`, ...payload }, }); const [queryName] = Object.keys(data); - const { vulnerability } = data[queryName]; - vulnerability.id = vulnerability.id.replace(gidPrefix, ''); - vulnerability.state = vulnerability.state.toLowerCase(); this.vulnerability = { ...this.vulnerability, - ...vulnerability, + ...normalizeGraphQLVulnerability(data[queryName].vulnerability), }; this.$emit('vulnerability-state-change'); @@ -207,34 +239,7 @@ export default { }, refreshVulnerability() { this.isLoadingVulnerability = true; - - // Cancel any pending API requests. - if (this.refreshVulnerabilitySource) { - this.refreshVulnerabilitySource.cancel(); - } - - this.refreshVulnerabilitySource = CancelToken.source(); - - Api.fetchVulnerability(this.vulnerability.id, { - cancelToken: this.refreshVulnerabilitySource.token, - }) - .then(({ data }) => { - Object.assign(this.vulnerability, data); - }) - .catch((e) => { - // Don't show an error message if the request was cancelled through the cancel token. - if (!axios.isCancel(e)) { - createFlash( - s__( - 'VulnerabilityManagement|Something went wrong while trying to refresh the vulnerability. Please try again later.', - ), - ); - } - }) - .finally(() => { - this.isLoadingVulnerability = false; - this.refreshVulnerabilitySource = undefined; - }); + this.shouldRefreshVulnerability = true; }, }, }; diff --git a/ee/app/assets/javascripts/vulnerabilities/constants.js b/ee/app/assets/javascripts/vulnerabilities/constants.js index b19b6abb08b3c73d456ff2b661e41a0ee24aa41f..2689d82fefbb0cd96eda96618922bcd2b5c4e184 100644 --- a/ee/app/assets/javascripts/vulnerabilities/constants.js +++ b/ee/app/assets/javascripts/vulnerabilities/constants.js @@ -6,6 +6,9 @@ import { const falsePositiveMessage = s__('VulnerabilityManagement|Will not fix or a false-positive'); +export const gidPrefix = 'gid://gitlab/Vulnerability/'; +export const uidPrefix = 'gid://gitlab/User/'; + export const VULNERABILITY_STATE_OBJECTS = { detected: { action: 'revert', diff --git a/ee/app/assets/javascripts/vulnerabilities/helpers.js b/ee/app/assets/javascripts/vulnerabilities/helpers.js index 8e08d0bcbf8935c869746de1701f3e432efc75d1..4e6e8bc3f31931e30956be707d766dbbcfda1554 100644 --- a/ee/app/assets/javascripts/vulnerabilities/helpers.js +++ b/ee/app/assets/javascripts/vulnerabilities/helpers.js @@ -1,5 +1,5 @@ import { isAbsolute, isSafeURL } from '~/lib/utils/url_utility'; -import { REGEXES } from './constants'; +import { REGEXES, gidPrefix, uidPrefix } from './constants'; // Get the issue in the format expected by the descendant components of related_issues_block.vue. export const getFormattedIssue = (issue) => ({ @@ -27,3 +27,28 @@ export const getAddRelatedIssueRequestParams = (reference, defaultProjectId) => return { target_issue_iid: issueId, target_project_id: projectId }; }; + +export const normalizeGraphQLVulnerability = (vulnerability) => { + if (!vulnerability) { + return null; + } + + const newVulnerability = { ...vulnerability }; + + if (vulnerability.id) { + newVulnerability.id = vulnerability.id.replace(gidPrefix, ''); + } + + if (vulnerability.state) { + newVulnerability.state = vulnerability.state.toLowerCase(); + } + + ['confirmed', 'resolved', 'dismissed'].forEach((state) => { + if (vulnerability[`${state}By`]?.id) { + newVulnerability[`${state}ById`] = vulnerability[`${state}By`].id.replace(uidPrefix, ''); + delete newVulnerability[`${state}By`]; + } + }); + + return newVulnerability; +}; diff --git a/ee/spec/frontend/vulnerabilities/header_spec.js b/ee/spec/frontend/vulnerabilities/header_spec.js index 932ba1e014f2c53c8964214a4837caa6a9e0c8e2..e3ad95269e70b86a716bb35b05970417f71d3240 100644 --- a/ee/spec/frontend/vulnerabilities/header_spec.js +++ b/ee/spec/frontend/vulnerabilities/header_spec.js @@ -9,6 +9,7 @@ import ResolutionAlert from 'ee/vulnerabilities/components/resolution_alert.vue' import StatusDescription from 'ee/vulnerabilities/components/status_description.vue'; import VulnerabilityStateDropdown from 'ee/vulnerabilities/components/vulnerability_state_dropdown.vue'; import vulnerabilityStateMutations from 'ee/security_dashboard/graphql/mutate_vulnerability_state'; +import fetchHeaderVulnerabilityQuery from 'ee/security_dashboard/graphql/header_vulnerability.graphql'; import { FEEDBACK_TYPES, VULNERABILITY_STATE_OBJECTS } from 'ee/vulnerabilities/constants'; import UsersMockHelper from 'helpers/user_mock_data_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -418,4 +419,63 @@ describe('Vulnerability Header', () => { }); }); }); + + describe('refresh vulnerability', () => { + describe('on success', () => { + beforeEach(() => { + const apolloProvider = createApolloProvider([ + fetchHeaderVulnerabilityQuery, + jest.fn().mockResolvedValue({ + data: { + errors: [], + vulnerability: { + id: 'gid://gitlab/Vulnerability/54', + [`resolvedAt`]: '2020-09-16T11:13:26Z', + state: 'RESOLVED', + }, + }, + }), + ]); + + createWrapper({ + apolloProvider, + vulnerability: getVulnerability({}), + }); + }); + + it('fetches the vulnerability when refreshVulnerability method is called', async () => { + expect(findBadge().text()).toBe('detected'); + wrapper.vm.refreshVulnerability(); + await waitForPromises(); + expect(findBadge().text()).toBe('resolved'); + }); + }); + + describe('on failure', () => { + beforeEach(() => { + const apolloProvider = createApolloProvider([ + fetchHeaderVulnerabilityQuery, + jest.fn().mockRejectedValue({ + data: { + errors: [{ message: 'something went wrong while fetching the vulnerability' }], + vulnerability: null, + }, + }), + ]); + + createWrapper({ + apolloProvider, + vulnerability: getVulnerability({}), + }); + }); + + it('calls createFlash', async () => { + expect(findBadge().text()).toBe('detected'); + wrapper.vm.refreshVulnerability(); + await waitForPromises(); + expect(findBadge().text()).toBe('detected'); + expect(createFlash).toHaveBeenCalledTimes(1); + }); + }); + }); }); diff --git a/ee/spec/frontend/vulnerabilities/helpers_spec.js b/ee/spec/frontend/vulnerabilities/helpers_spec.js index f81e666d9355de2d0b2c879f904a94ce5abc0b8d..9d89a65351ce741bcf243d48473f9e4bbaca7483 100644 --- a/ee/spec/frontend/vulnerabilities/helpers_spec.js +++ b/ee/spec/frontend/vulnerabilities/helpers_spec.js @@ -1,4 +1,8 @@ -import { getFormattedIssue, getAddRelatedIssueRequestParams } from 'ee/vulnerabilities/helpers'; +import { + getFormattedIssue, + getAddRelatedIssueRequestParams, + normalizeGraphQLVulnerability, +} from 'ee/vulnerabilities/helpers'; describe('Vulnerabilities helpers', () => { describe('getFormattedIssue', () => { @@ -37,4 +41,28 @@ describe('Vulnerabilities helpers', () => { }, ); }); + + describe('normalizeGraphQLVulnerability', () => { + it('returns null when vulnerability is null', () => { + expect(normalizeGraphQLVulnerability(null)).toBe(null); + }); + + it('normalizes the GraphQL response when the vulnerability is not null', () => { + expect( + normalizeGraphQLVulnerability({ + confirmedBy: { id: 'gid://gitlab/User/16' }, + resolvedBy: { id: 'gid://gitlab/User/16' }, + dismissedBy: { id: 'gid://gitlab/User/16' }, + state: 'DISMISSED', + id: 'gid://gitlab/Vulnerability/54', + }), + ).toEqual({ + confirmedById: '16', + resolvedById: '16', + dismissedById: '16', + state: 'dismissed', + id: '54', + }); + }); + }); });