import { mount, createLocalVue } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { merge } from 'lodash'; import VueApollo from 'vue-apollo'; import Vuex from 'vuex'; import createMockApollo from 'helpers/mock_apollo_helper'; import { trimText } from 'helpers/text_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { expectedDownloadDropdownProps, securityReportDownloadPathsQueryResponse, sastDiffSuccessMock, secretScanningDiffSuccessMock, } from 'jest/vue_shared/security_reports/mock_data'; import Api from '~/api'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION, } from '~/vue_shared/security_reports/constants'; import HelpIcon from '~/vue_shared/security_reports/components/help_icon.vue'; import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue'; import SecurityReportsApp from '~/vue_shared/security_reports/security_reports_app.vue'; import securityReportDownloadPathsQuery from '~/vue_shared/security_reports/queries/security_report_download_paths.query.graphql'; jest.mock('~/flash'); const localVue = createLocalVue(); localVue.use(Vuex); const SAST_COMPARISON_PATH = '/sast.json'; const SECRET_SCANNING_COMPARISON_PATH = '/secret_detection.json'; describe('Security reports app', () => { let wrapper; const props = { pipelineId: 123, projectId: 456, securityReportsDocsPath: '/docs', discoverProjectSecurityPath: '/discoverProjectSecurityPath', }; const createComponent = (options) => { wrapper = mount( SecurityReportsApp, merge( { localVue, propsData: { ...props }, stubs: { HelpIcon: true, }, }, options, ), ); }; const pendingHandler = () => new Promise(() => {}); const successHandler = () => Promise.resolve({ data: securityReportDownloadPathsQueryResponse }); const failureHandler = () => Promise.resolve({ errors: [{ message: 'some error' }] }); const createMockApolloProvider = (handler) => { localVue.use(VueApollo); const requestHandlers = [[securityReportDownloadPathsQuery, handler]]; return createMockApollo(requestHandlers); }; const anyParams = expect.any(Object); const findDownloadDropdown = () => wrapper.find(SecurityReportDownloadDropdown); const findPipelinesTabAnchor = () => wrapper.find('[data-testid="show-pipelines"]'); const findHelpIconComponent = () => wrapper.find(HelpIcon); const setupMockJobArtifact = (reportType) => { jest .spyOn(Api, 'pipelineJobs') .mockResolvedValue({ data: [{ artifacts: [{ file_type: reportType }] }] }); }; const expectPipelinesTabAnchor = () => { const mrTabsMock = { tabShown: jest.fn() }; window.mrTabs = mrTabsMock; findPipelinesTabAnchor().trigger('click'); expect(mrTabsMock.tabShown.mock.calls).toEqual([['pipelines']]); }; afterEach(() => { wrapper.destroy(); delete window.mrTabs; }); describe.each([false, true])( 'given the coreSecurityMrWidgetCounts feature flag is %p', (coreSecurityMrWidgetCounts) => { const createComponentWithFlag = (options) => createComponent( merge( { provide: { glFeatures: { coreSecurityMrWidgetCounts, }, }, }, options, ), ); describe.each(SecurityReportsApp.reportTypes)('given a report type %p', (reportType) => { beforeEach(() => { window.mrTabs = { tabShown: jest.fn() }; setupMockJobArtifact(reportType); createComponentWithFlag(); return wrapper.vm.$nextTick(); }); it('calls the pipelineJobs API correctly', () => { expect(Api.pipelineJobs).toHaveBeenCalledTimes(1); expect(Api.pipelineJobs).toHaveBeenCalledWith( props.projectId, props.pipelineId, anyParams, ); }); it('renders the expected message', () => { expect(wrapper.text()).toMatchInterpolatedText( SecurityReportsApp.i18n.scansHaveRunWithDownloadGuidance, ); }); describe('clicking the anchor to the pipelines tab', () => { it('calls the mrTabs.tabShown global', () => { expectPipelinesTabAnchor(); }); }); it('renders a help link', () => { expect(findHelpIconComponent().props()).toEqual({ helpPath: props.securityReportsDocsPath, discoverProjectSecurityPath: props.discoverProjectSecurityPath, }); }); }); describe('given a report type "foo"', () => { beforeEach(() => { setupMockJobArtifact('foo'); createComponentWithFlag(); return wrapper.vm.$nextTick(); }); it('calls the pipelineJobs API correctly', () => { expect(Api.pipelineJobs).toHaveBeenCalledTimes(1); expect(Api.pipelineJobs).toHaveBeenCalledWith( props.projectId, props.pipelineId, anyParams, ); }); it('renders nothing', () => { expect(wrapper.html()).toBe(''); }); }); describe('security artifacts on last page of multi-page response', () => { const numPages = 3; beforeEach(() => { jest .spyOn(Api, 'pipelineJobs') .mockImplementation(async (projectId, pipelineId, { page }) => { const requestedPage = parseInt(page, 10); if (requestedPage < numPages) { return { // Some jobs with no relevant artifacts data: [{}, {}], headers: { 'x-next-page': String(requestedPage + 1) }, }; } else if (requestedPage === numPages) { return { data: [{ artifacts: [{ file_type: SecurityReportsApp.reportTypes[0] }] }], }; } throw new Error('Test failed due to request of non-existent jobs page'); }); createComponentWithFlag(); return wrapper.vm.$nextTick(); }); it('fetches all pages', () => { expect(Api.pipelineJobs).toHaveBeenCalledTimes(numPages); }); it('renders the expected message', () => { expect(wrapper.text()).toMatchInterpolatedText( SecurityReportsApp.i18n.scansHaveRunWithDownloadGuidance, ); }); }); describe('given an error from the API', () => { let error; beforeEach(() => { error = new Error('an error'); jest.spyOn(Api, 'pipelineJobs').mockRejectedValue(error); createComponentWithFlag(); return wrapper.vm.$nextTick(); }); it('calls the pipelineJobs API correctly', () => { expect(Api.pipelineJobs).toHaveBeenCalledTimes(1); expect(Api.pipelineJobs).toHaveBeenCalledWith( props.projectId, props.pipelineId, anyParams, ); }); it('renders nothing', () => { expect(wrapper.html()).toBe(''); }); it('calls createFlash correctly', () => { expect(createFlash.mock.calls).toEqual([ [ { message: SecurityReportsApp.i18n.apiError, captureError: true, error, }, ], ]); }); }); }, ); describe('given the coreSecurityMrWidgetCounts feature flag is enabled', () => { let mock; const createComponentWithFlagEnabled = (options) => createComponent( merge(options, { provide: { glFeatures: { coreSecurityMrWidgetCounts: true, }, }, }), ); beforeEach(() => { mock = new MockAdapter(axios); }); afterEach(() => { mock.restore(); }); const SAST_SUCCESS_MESSAGE = 'Security scanning detected 1 potential vulnerability 1 Critical 0 High and 0 Others'; const SECRET_SCANNING_SUCCESS_MESSAGE = 'Security scanning detected 2 potential vulnerabilities 1 Critical 1 High and 0 Others'; describe.each` reportType | pathProp | path | successResponse | successMessage ${REPORT_TYPE_SAST} | ${'sastComparisonPath'} | ${SAST_COMPARISON_PATH} | ${sastDiffSuccessMock} | ${SAST_SUCCESS_MESSAGE} ${REPORT_TYPE_SECRET_DETECTION} | ${'secretScanningComparisonPath'} | ${SECRET_SCANNING_COMPARISON_PATH} | ${secretScanningDiffSuccessMock} | ${SECRET_SCANNING_SUCCESS_MESSAGE} `( 'given a $pathProp and $reportType artifact', ({ reportType, pathProp, path, successResponse, successMessage }) => { beforeEach(() => { setupMockJobArtifact(reportType); }); describe('when loading', () => { beforeEach(() => { mock = new MockAdapter(axios, { delayResponse: 1 }); mock.onGet(path).replyOnce(200, successResponse); createComponentWithFlagEnabled({ propsData: { [pathProp]: path, }, }); return waitForPromises(); }); it('should have loading message', () => { expect(wrapper.text()).toBe('Security scanning is loading'); }); it('should not render the pipeline tab anchor', () => { expect(findPipelinesTabAnchor().exists()).toBe(false); }); }); describe('when successfully loaded', () => { beforeEach(() => { mock.onGet(path).replyOnce(200, successResponse); createComponentWithFlagEnabled({ propsData: { [pathProp]: path, }, }); return waitForPromises(); }); it('should show counts', () => { expect(trimText(wrapper.text())).toContain(successMessage); }); it('should render the pipeline tab anchor', () => { expectPipelinesTabAnchor(); }); }); describe('when an error occurs', () => { beforeEach(() => { mock.onGet(path).replyOnce(500); createComponentWithFlagEnabled({ propsData: { [pathProp]: path, }, }); return waitForPromises(); }); it('should show error message', () => { expect(trimText(wrapper.text())).toContain('Loading resulted in an error'); }); it('should render the pipeline tab anchor', () => { expectPipelinesTabAnchor(); }); }); }, ); }); describe('given coreSecurityMrWidgetDownloads feature flag is enabled', () => { const createComponentWithFlagEnabled = (options) => createComponent( merge(options, { provide: { glFeatures: { coreSecurityMrWidgetDownloads: true, }, }, }), ); describe('given the query is loading', () => { beforeEach(() => { createComponentWithFlagEnabled({ apolloProvider: createMockApolloProvider(pendingHandler), }); }); // TODO: Remove this assertion as part of // https://gitlab.com/gitlab-org/gitlab/-/issues/273431 it('initially renders nothing', () => { expect(wrapper.html()).toBe(''); }); }); describe('given the query loads successfully', () => { beforeEach(() => { createComponentWithFlagEnabled({ apolloProvider: createMockApolloProvider(successHandler), }); }); it('renders the download dropdown', () => { expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownProps); }); it('renders the expected message', () => { const text = wrapper.text(); expect(text).not.toContain(SecurityReportsApp.i18n.scansHaveRunWithDownloadGuidance); expect(text).toContain(SecurityReportsApp.i18n.scansHaveRun); }); it('should not render the pipeline tab anchor', () => { expect(findPipelinesTabAnchor().exists()).toBe(false); }); }); describe('given the query fails', () => { beforeEach(() => { createComponentWithFlagEnabled({ apolloProvider: createMockApolloProvider(failureHandler), }); }); it('calls createFlash correctly', () => { expect(createFlash).toHaveBeenCalledWith({ message: SecurityReportsApp.i18n.apiError, captureError: true, error: expect.any(Error), }); }); // TODO: Remove this assertion as part of // https://gitlab.com/gitlab-org/gitlab/-/issues/273431 it('renders nothing', () => { expect(wrapper.html()).toBe(''); }); }); }); describe('given coreSecurityMrWidgetCounts and coreSecurityMrWidgetDownloads feature flags are enabled', () => { let mock; beforeEach(() => { mock = new MockAdapter(axios); mock.onGet(SAST_COMPARISON_PATH).replyOnce(200, sastDiffSuccessMock); mock.onGet(SECRET_SCANNING_COMPARISON_PATH).replyOnce(200, secretScanningDiffSuccessMock); createComponent({ propsData: { sastComparisonPath: SAST_COMPARISON_PATH, secretScanningComparisonPath: SECRET_SCANNING_COMPARISON_PATH, }, provide: { glFeatures: { coreSecurityMrWidgetCounts: true, coreSecurityMrWidgetDownloads: true, }, }, apolloProvider: createMockApolloProvider(successHandler), }); return waitForPromises(); }); afterEach(() => { mock.restore(); }); it('renders the download dropdown', () => { expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownProps); }); it('renders the expected counts message', () => { expect(trimText(wrapper.text())).toContain( 'Security scanning detected 3 potential vulnerabilities 2 Critical 1 High and 0 Others', ); }); it('should not render the pipeline tab anchor', () => { expect(findPipelinesTabAnchor().exists()).toBe(false); }); }); });