diff options
Diffstat (limited to 'spec/frontend/vue_shared/security_reports')
5 files changed, 1111 insertions, 97 deletions
diff --git a/spec/frontend/vue_shared/security_reports/components/security_report_download_dropdown_spec.js b/spec/frontend/vue_shared/security_reports/components/security_report_download_dropdown_spec.js new file mode 100644 index 00000000000..7e70407655a --- /dev/null +++ b/spec/frontend/vue_shared/security_reports/components/security_report_download_dropdown_spec.js @@ -0,0 +1,64 @@ +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue'; + +describe('SecurityReportDownloadDropdown component', () => { + let wrapper; + let artifacts; + + const createComponent = props => { + wrapper = shallowMount(SecurityReportDownloadDropdown, { + propsData: { ...props }, + }); + }; + + const findDropdown = () => wrapper.find(GlDropdown); + const findDropdownItems = () => wrapper.findAll(GlDropdownItem); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('given report artifacts', () => { + beforeEach(() => { + artifacts = [ + { + name: 'foo', + path: '/foo.json', + }, + { + name: 'bar', + path: '/bar.json', + }, + ]; + + createComponent({ artifacts }); + }); + + it('renders a dropdown', () => { + expect(findDropdown().props('loading')).toBe(false); + }); + + it('renders a dropdown items for each artifact', () => { + artifacts.forEach((artifact, i) => { + const item = findDropdownItems().at(i); + expect(item.text()).toContain(artifact.name); + expect(item.attributes()).toMatchObject({ + href: artifact.path, + download: expect.any(String), + }); + }); + }); + }); + + describe('given it is loading', () => { + beforeEach(() => { + createComponent({ artifacts: [], loading: true }); + }); + + it('renders a loading dropdown', () => { + expect(findDropdown().props('loading')).toBe(true); + }); + }); +}); diff --git a/spec/frontend/vue_shared/security_reports/mock_data.js b/spec/frontend/vue_shared/security_reports/mock_data.js new file mode 100644 index 00000000000..e93ca8329e7 --- /dev/null +++ b/spec/frontend/vue_shared/security_reports/mock_data.js @@ -0,0 +1,437 @@ +import { + REPORT_TYPE_SAST, + REPORT_TYPE_SECRET_DETECTION, +} from '~/vue_shared/security_reports/constants'; + +export const mockFindings = [ + { + id: null, + report_type: 'dependency_scanning', + name: 'Cross-site Scripting in serialize-javascript', + severity: 'critical', + scanner: { + external_id: 'gemnasium', + name: 'Gemnasium', + version: '1.1.1', + url: 'https://gitlab.com/gitlab-org/security-products/gemnasium', + }, + identifiers: [ + { + external_type: 'gemnasium', + external_id: '58caa017-9a9a-46d6-bab2-ec930f46833c', + name: 'Gemnasium-58caa017-9a9a-46d6-bab2-ec930f46833c', + url: + 'https://deps.sec.gitlab.com/packages/npm/serialize-javascript/versions/1.7.0/advisories', + }, + { + external_type: 'cve', + external_id: 'CVE-2019-16769', + name: 'CVE-2019-16769', + url: 'https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-16769', + }, + ], + project_fingerprint: '09df9f4d11c8deb93d81bdcc39f7667b44143298', + create_vulnerability_feedback_issue_path: '/gitlab-org/gitlab-ui/vulnerability_feedback', + create_vulnerability_feedback_merge_request_path: + '/gitlab-org/gitlab-ui/vulnerability_feedback', + create_vulnerability_feedback_dismissal_path: '/gitlab-org/gitlab-ui/vulnerability_feedback', + project: { + id: 7071551, + name: 'gitlab-ui', + full_path: '/gitlab-org/gitlab-ui', + full_name: 'GitLab.org / gitlab-ui', + }, + dismissal_feedback: null, + issue_feedback: null, + merge_request_feedback: null, + description: + 'The serialize-javascript npm package is vulnerable to Cross-site Scripting (XSS). It does not properly mitigate against unsafe characters in serialized regular expressions. If serialized data of regular expression objects are used in an environment other than Node.js, it is affected by this vulnerability.', + links: [{ url: 'https://nvd.nist.gov/vuln/detail/CVE-2019-16769' }], + location: { + file: 'yarn.lock', + dependency: { package: { name: 'serialize-javascript' }, version: '1.7.0' }, + }, + remediations: [null], + solution: 'Upgrade to version 2.1.1 or above.', + state: 'opened', + blob_path: '/gitlab-org/gitlab-ui/blob/ad137f0a8ac59af961afe47d04e5cc062c6864a9/yarn.lock', + evidence: 'Credit Card Detected: Diners Card', + }, + { + id: null, + report_type: 'dependency_scanning', + name: '3rd party CORS request may execute in jquery', + severity: 'high', + scanner: { external_id: 'retire.js', name: 'Retire.js' }, + identifiers: [ + { + external_type: 'cve', + external_id: 'CVE-2015-9251', + name: 'CVE-2015-9251', + url: 'https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2015-9251', + }, + ], + project_fingerprint: '1ecd3b214cf39c0b9ad23a0a9679778d7cf55876', + create_vulnerability_feedback_issue_path: '/gitlab-org/gitlab-ui/vulnerability_feedback', + create_vulnerability_feedback_merge_request_path: + '/gitlab-org/gitlab-ui/vulnerability_feedback', + create_vulnerability_feedback_dismissal_path: '/gitlab-org/gitlab-ui/vulnerability_feedback', + project: { + id: 7071551, + name: 'gitlab-ui', + full_path: '/gitlab-org/gitlab-ui', + full_name: 'GitLab.org / gitlab-ui', + }, + dismissal_feedback: { + id: 2528, + created_at: '2019-08-26T12:30:32.349Z', + project_id: 7071551, + author: { + id: 181229, + name: "Lukas 'Eipi' Eipert", + username: 'leipert', + state: 'active', + avatar_url: + 'https://secure.gravatar.com/avatar/19a1f1260fa70323f35bc508927921a2?s=80\u0026d=identicon', + web_url: 'https://gitlab.com/leipert', + status_tooltip_html: null, + path: '/leipert', + }, + comment_details: { + comment: 'This particular jQuery version appears in a test path of tinycolor2.\n', + comment_timestamp: '2019-08-26T12:30:37.610Z', + comment_author: { + id: 181229, + name: "Lukas 'Eipi' Eipert", + username: 'leipert', + state: 'active', + avatar_url: + 'https://secure.gravatar.com/avatar/19a1f1260fa70323f35bc508927921a2?s=80\u0026d=identicon', + web_url: 'https://gitlab.com/leipert', + status_tooltip_html: null, + path: '/leipert', + }, + }, + pipeline: { id: 78375355, path: '/gitlab-org/gitlab-ui/pipelines/78375355' }, + destroy_vulnerability_feedback_dismissal_path: + '/gitlab-org/gitlab-ui/vulnerability_feedback/2528', + category: 'dependency_scanning', + feedback_type: 'dismissal', + branch: 'leipert-dogfood-secure', + project_fingerprint: '1ecd3b214cf39c0b9ad23a0a9679778d7cf55876', + }, + issue_feedback: null, + merge_request_feedback: null, + description: null, + links: [ + { url: 'https://github.com/jquery/jquery/issues/2432' }, + { url: 'http://blog.jquery.com/2016/01/08/jquery-2-2-and-1-12-released/' }, + { url: 'https://nvd.nist.gov/vuln/detail/CVE-2015-9251' }, + { url: 'http://research.insecurelabs.org/jquery/test/' }, + ], + location: { + file: 'node_modules/tinycolor2/demo/jquery-1.9.1.js', + dependency: { package: { name: 'jquery' }, version: '1.9.1' }, + }, + remediations: [null], + solution: null, + state: 'dismissed', + blob_path: + '/gitlab-org/gitlab-ui/blob/ad137f0a8ac59af961afe47d04e5cc062c6864a9/node_modules/tinycolor2/demo/jquery-1.9.1.js', + }, + { + id: null, + report_type: 'dependency_scanning', + name: + 'jQuery before 3.4.0, as used in Drupal, Backdrop CMS, and other products, mishandles jQuery.extend(true, {}, ...) because of Object.prototype pollution in jquery', + severity: 'low', + scanner: { external_id: 'retire.js', name: 'Retire.js' }, + identifiers: [ + { + external_type: 'cve', + external_id: 'CVE-2019-11358', + name: 'CVE-2019-11358', + url: 'https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-11358', + }, + ], + project_fingerprint: 'aeb4b2442d92d0ccf7023f0c220bda8b4ba910e3', + create_vulnerability_feedback_issue_path: '/gitlab-org/gitlab-ui/vulnerability_feedback', + create_vulnerability_feedback_merge_request_path: + '/gitlab-org/gitlab-ui/vulnerability_feedback', + create_vulnerability_feedback_dismissal_path: '/gitlab-org/gitlab-ui/vulnerability_feedback', + project: { + id: 7071551, + name: 'gitlab-ui', + full_path: '/gitlab-org/gitlab-ui', + full_name: 'GitLab.org / gitlab-ui', + }, + dismissal_feedback: { + id: 4197, + created_at: '2019-11-14T11:03:18.472Z', + project_id: 7071551, + author: { + id: 181229, + name: "Lukas 'Eipi' Eipert", + username: 'leipert', + state: 'active', + avatar_url: + 'https://secure.gravatar.com/avatar/19a1f1260fa70323f35bc508927921a2?s=80\u0026d=identicon', + web_url: 'https://gitlab.com/leipert', + status_tooltip_html: null, + path: '/leipert', + }, + comment_details: { + comment: + 'This is a false positive, as it just part of some documentation assets of sass-true.', + comment_timestamp: '2019-11-14T11:03:18.464Z', + comment_author: { + id: 181229, + name: "Lukas 'Eipi' Eipert", + username: 'leipert', + state: 'active', + avatar_url: + 'https://secure.gravatar.com/avatar/19a1f1260fa70323f35bc508927921a2?s=80\u0026d=identicon', + web_url: 'https://gitlab.com/leipert', + status_tooltip_html: null, + path: '/leipert', + }, + }, + destroy_vulnerability_feedback_dismissal_path: + '/gitlab-org/gitlab-ui/vulnerability_feedback/4197', + category: 'dependency_scanning', + feedback_type: 'dismissal', + branch: null, + project_fingerprint: 'aeb4b2442d92d0ccf7023f0c220bda8b4ba910e3', + }, + issue_feedback: null, + merge_request_feedback: null, + description: null, + links: [ + { url: 'https://blog.jquery.com/2019/04/10/jquery-3-4-0-released/' }, + { url: 'https://nvd.nist.gov/vuln/detail/CVE-2019-11358' }, + { url: 'https://github.com/jquery/jquery/commit/753d591aea698e57d6db58c9f722cd0808619b1b' }, + ], + location: { + file: 'node_modules/sass-true/docs/assets/webpack/common.min.js', + dependency: { package: { name: 'jquery' }, version: '3.3.1' }, + }, + remediations: [null], + solution: null, + state: 'dismissed', + blob_path: + '/gitlab-org/gitlab-ui/blob/ad137f0a8ac59af961afe47d04e5cc062c6864a9/node_modules/sass-true/docs/assets/webpack/common.min.js', + }, + { + id: null, + report_type: 'dependency_scanning', + name: + 'jQuery before 3.4.0, as used in Drupal, Backdrop CMS, and other products, mishandles jQuery.extend(true, {}, ...) because of Object.prototype pollution in jquery', + severity: 'low', + scanner: { external_id: 'retire.js', name: 'Retire.js' }, + identifiers: [ + { + external_type: 'cve', + external_id: 'CVE-2019-11358', + name: 'CVE-2019-11358', + url: 'https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-11358', + }, + ], + project_fingerprint: 'eb86aa13eb9d897a083ead6e134aa78aa9cadd52', + create_vulnerability_feedback_issue_path: '/gitlab-org/gitlab-ui/vulnerability_feedback', + create_vulnerability_feedback_merge_request_path: + '/gitlab-org/gitlab-ui/vulnerability_feedback', + create_vulnerability_feedback_dismissal_path: '/gitlab-org/gitlab-ui/vulnerability_feedback', + project: { + id: 7071551, + name: 'gitlab-ui', + full_path: '/gitlab-org/gitlab-ui', + full_name: 'GitLab.org / gitlab-ui', + }, + dismissal_feedback: { + id: 2527, + created_at: '2019-08-26T12:29:43.624Z', + project_id: 7071551, + author: { + id: 181229, + name: "Lukas 'Eipi' Eipert", + username: 'leipert', + state: 'active', + avatar_url: + 'https://secure.gravatar.com/avatar/19a1f1260fa70323f35bc508927921a2?s=80\u0026d=identicon', + web_url: 'https://gitlab.com/leipert', + status_tooltip_html: null, + path: '/leipert', + }, + comment_details: { + comment: 'This particular jQuery version appears in a test path of tinycolor2.', + comment_timestamp: '2019-08-26T12:30:14.840Z', + comment_author: { + id: 181229, + name: "Lukas 'Eipi' Eipert", + username: 'leipert', + state: 'active', + avatar_url: + 'https://secure.gravatar.com/avatar/19a1f1260fa70323f35bc508927921a2?s=80\u0026d=identicon', + web_url: 'https://gitlab.com/leipert', + status_tooltip_html: null, + path: '/leipert', + }, + }, + pipeline: { id: 78375355, path: '/gitlab-org/gitlab-ui/pipelines/78375355' }, + destroy_vulnerability_feedback_dismissal_path: + '/gitlab-org/gitlab-ui/vulnerability_feedback/2527', + category: 'dependency_scanning', + feedback_type: 'dismissal', + branch: 'leipert-dogfood-secure', + project_fingerprint: 'eb86aa13eb9d897a083ead6e134aa78aa9cadd52', + }, + issue_feedback: null, + merge_request_feedback: null, + description: null, + links: [ + { url: 'https://blog.jquery.com/2019/04/10/jquery-3-4-0-released/' }, + { url: 'https://nvd.nist.gov/vuln/detail/CVE-2019-11358' }, + { url: 'https://github.com/jquery/jquery/commit/753d591aea698e57d6db58c9f722cd0808619b1b' }, + ], + location: { + file: 'node_modules/tinycolor2/demo/jquery-1.9.1.js', + dependency: { package: { name: 'jquery' }, version: '1.9.1' }, + }, + remediations: [null], + solution: null, + state: 'dismissed', + blob_path: + '/gitlab-org/gitlab-ui/blob/ad137f0a8ac59af961afe47d04e5cc062c6864a9/node_modules/tinycolor2/demo/jquery-1.9.1.js', + }, +]; + +export const sastDiffSuccessMock = { + added: [mockFindings[0]], + fixed: [mockFindings[1], mockFindings[2]], + existing: [mockFindings[3]], + base_report_created_at: '2020-01-01T10:00:00.000Z', + base_report_out_of_date: false, + head_report_created_at: '2020-01-10T10:00:00.000Z', +}; + +export const secretScanningDiffSuccessMock = { + added: [mockFindings[0], mockFindings[1]], + fixed: [mockFindings[2]], + base_report_created_at: '2020-01-01T10:00:00.000Z', + base_report_out_of_date: false, + head_report_created_at: '2020-01-10T10:00:00.000Z', +}; + +export const securityReportDownloadPathsQueryResponse = { + project: { + mergeRequest: { + headPipeline: { + id: 'gid://gitlab/Ci::Pipeline/176', + jobs: { + nodes: [ + { + name: 'secret_detection', + artifacts: { + nodes: [ + { + downloadPath: + '/gitlab-org/secrets-detection-test/-/jobs/1399/artifacts/download?file_type=trace', + fileType: 'TRACE', + __typename: 'CiJobArtifact', + }, + { + downloadPath: + '/gitlab-org/secrets-detection-test/-/jobs/1399/artifacts/download?file_type=secret_detection', + fileType: 'SECRET_DETECTION', + __typename: 'CiJobArtifact', + }, + ], + __typename: 'CiJobArtifactConnection', + }, + __typename: 'CiJob', + }, + { + name: 'bandit-sast', + artifacts: { + nodes: [ + { + downloadPath: + '/gitlab-org/secrets-detection-test/-/jobs/1400/artifacts/download?file_type=trace', + fileType: 'TRACE', + __typename: 'CiJobArtifact', + }, + { + downloadPath: + '/gitlab-org/secrets-detection-test/-/jobs/1400/artifacts/download?file_type=sast', + fileType: 'SAST', + __typename: 'CiJobArtifact', + }, + ], + __typename: 'CiJobArtifactConnection', + }, + __typename: 'CiJob', + }, + { + name: 'eslint-sast', + artifacts: { + nodes: [ + { + downloadPath: + '/gitlab-org/secrets-detection-test/-/jobs/1401/artifacts/download?file_type=trace', + fileType: 'TRACE', + __typename: 'CiJobArtifact', + }, + { + downloadPath: + '/gitlab-org/secrets-detection-test/-/jobs/1401/artifacts/download?file_type=sast', + fileType: 'SAST', + __typename: 'CiJobArtifact', + }, + ], + __typename: 'CiJobArtifactConnection', + }, + __typename: 'CiJob', + }, + ], + __typename: 'CiJobConnection', + }, + __typename: 'Pipeline', + }, + __typename: 'MergeRequest', + }, + __typename: 'Project', + }, +}; + +/** + * These correspond to SAST jobs in the securityReportDownloadPathsQueryResponse above. + */ +export const sastArtifacts = [ + { + name: 'bandit-sast', + reportType: REPORT_TYPE_SAST, + path: '/gitlab-org/secrets-detection-test/-/jobs/1400/artifacts/download?file_type=sast', + }, + { + name: 'eslint-sast', + reportType: REPORT_TYPE_SAST, + path: '/gitlab-org/secrets-detection-test/-/jobs/1401/artifacts/download?file_type=sast', + }, +]; + +/** + * These correspond to Secret Detection jobs in the securityReportDownloadPathsQueryResponse above. + */ +export const secretDetectionArtifacts = [ + { + name: 'secret_detection', + reportType: REPORT_TYPE_SECRET_DETECTION, + path: + '/gitlab-org/secrets-detection-test/-/jobs/1399/artifacts/download?file_type=secret_detection', + }, +]; + +export const expectedDownloadDropdownProps = { + loading: false, + artifacts: [...secretDetectionArtifacts, ...sastArtifacts], +}; diff --git a/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js index ab87d80b291..c440081a0c4 100644 --- a/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js +++ b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js @@ -1,162 +1,465 @@ -import { mount } from '@vue/test-utils'; +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 'jest/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 Flash from '~/flash'; +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; - let mrTabsMock; const props = { pipelineId: 123, projectId: 456, securityReportsDocsPath: '/docs', + discoverProjectSecurityPath: '/discoverProjectSecurityPath', }; - const createComponent = () => { - wrapper = mount(SecurityReportsApp, { - propsData: { ...props }, - }); + 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 findHelpLink = () => wrapper.find('[data-testid="help"]'); - const setupMrTabsMock = () => { - mrTabsMock = { tabShown: jest.fn() }; - window.mrTabs = mrTabsMock; - }; + 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(SecurityReportsApp.reportTypes)('given a report type %p', reportType => { - beforeEach(() => { - window.mrTabs = { tabShown: jest.fn() }; - setupMockJobArtifact(reportType); - createComponent(); - return wrapper.vm.$nextTick(); - }); + describe.each([false, true])( + 'given the coreSecurityMrWidgetCounts feature flag is %p', + coreSecurityMrWidgetCounts => { + const createComponentWithFlag = options => + createComponent( + merge( + { + provide: { + glFeatures: { + coreSecurityMrWidgetCounts, + }, + }, + }, + options, + ), + ); - it('calls the pipelineJobs API correctly', () => { - expect(Api.pipelineJobs).toHaveBeenCalledTimes(1); - expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId, anyParams); - }); + describe.each(SecurityReportsApp.reportTypes)('given a report type %p', reportType => { + beforeEach(() => { + window.mrTabs = { tabShown: jest.fn() }; + setupMockJobArtifact(reportType); + createComponentWithFlag(); + return wrapper.vm.$nextTick(); + }); - it('renders the expected message', () => { - expect(wrapper.text()).toMatchInterpolatedText(SecurityReportsApp.i18n.scansHaveRun); - }); + it('calls the pipelineJobs API correctly', () => { + expect(Api.pipelineJobs).toHaveBeenCalledTimes(1); + expect(Api.pipelineJobs).toHaveBeenCalledWith( + props.projectId, + props.pipelineId, + anyParams, + ); + }); - describe('clicking the anchor to the pipelines tab', () => { - beforeEach(() => { - setupMrTabsMock(); - findPipelinesTabAnchor().trigger('click'); + 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(''); + }); }); - it('calls the mrTabs.tabShown global', () => { - expect(mrTabsMock.tabShown.mock.calls).toEqual([['pipelines']]); + 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, + ); + }); }); - }); - it('renders a help link', () => { - expect(findHelpLink().attributes()).toMatchObject({ - href: props.securityReportsDocsPath, + 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, + }, + }, + }), + ); - describe('given a report type "foo"', () => { beforeEach(() => { - setupMockJobArtifact('foo'); - createComponent(); - return wrapper.vm.$nextTick(); + mock = new MockAdapter(axios); }); - it('calls the pipelineJobs API correctly', () => { - expect(Api.pipelineJobs).toHaveBeenCalledTimes(1); - expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId, anyParams); + afterEach(() => { + mock.restore(); }); - it('renders nothing', () => { - expect(wrapper.html()).toBe(''); - }); + 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('security artifacts on last page of multi-page response', () => { - const numPages = 3; + describe('given coreSecurityMrWidgetDownloads feature flag is enabled', () => { + const createComponentWithFlagEnabled = options => + createComponent( + merge(options, { + provide: { + glFeatures: { + coreSecurityMrWidgetDownloads: true, + }, + }, + }), + ); - 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'); - }); - - createComponent(); - return wrapper.vm.$nextTick(); + 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.isEmpty()).toBe(true); + }); }); - it('fetches all pages', () => { - expect(Api.pipelineJobs).toHaveBeenCalledTimes(numPages); + 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); + }); }); - it('renders the expected message', () => { - expect(wrapper.text()).toMatchInterpolatedText(SecurityReportsApp.i18n.scansHaveRun); + 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.isEmpty()).toBe(true); + }); }); }); - describe('given an error from the API', () => { - let error; + describe('given coreSecurityMrWidgetCounts and coreSecurityMrWidgetDownloads feature flags are enabled', () => { + let mock; beforeEach(() => { - error = new Error('an error'); - jest.spyOn(Api, 'pipelineJobs').mockRejectedValue(error); - createComponent(); - return wrapper.vm.$nextTick(); + 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(); }); - it('calls the pipelineJobs API correctly', () => { - expect(Api.pipelineJobs).toHaveBeenCalledTimes(1); - expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId, anyParams); + afterEach(() => { + mock.restore(); }); - it('renders nothing', () => { - expect(wrapper.html()).toBe(''); + it('renders the download dropdown', () => { + expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownProps); }); - it('calls Flash correctly', () => { - expect(Flash.mock.calls).toEqual([ - [ - { - message: SecurityReportsApp.i18n.apiError, - captureError: true, - error, - }, - ], - ]); + 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); }); }); }); diff --git a/spec/frontend/vue_shared/security_reports/store/getters_spec.js b/spec/frontend/vue_shared/security_reports/store/getters_spec.js new file mode 100644 index 00000000000..8de704be455 --- /dev/null +++ b/spec/frontend/vue_shared/security_reports/store/getters_spec.js @@ -0,0 +1,182 @@ +import createState from '~/vue_shared/security_reports/store/state'; +import createSastState from '~/vue_shared/security_reports/store/modules/sast/state'; +import createSecretScanningState from '~/vue_shared/security_reports/store/modules/secret_detection/state'; +import { groupedTextBuilder } from '~/vue_shared/security_reports/store/utils'; +import { + groupedSummaryText, + allReportsHaveError, + areReportsLoading, + anyReportHasError, + areAllReportsLoading, + anyReportHasIssues, + summaryCounts, +} from '~/vue_shared/security_reports/store/getters'; +import { CRITICAL, HIGH, LOW } from '~/vulnerabilities/constants'; + +const generateVuln = severity => ({ severity }); + +describe('Security reports getters', () => { + let state; + + beforeEach(() => { + state = createState(); + state.sast = createSastState(); + state.secretDetection = createSecretScanningState(); + }); + + describe('summaryCounts', () => { + it('returns 0 count for empty state', () => { + expect(summaryCounts(state)).toEqual({ + critical: 0, + high: 0, + other: 0, + }); + }); + + describe('combines all reports', () => { + it('of the same severity', () => { + state.sast.newIssues = [generateVuln(CRITICAL)]; + state.secretDetection.newIssues = [generateVuln(CRITICAL)]; + + expect(summaryCounts(state)).toEqual({ + critical: 2, + high: 0, + other: 0, + }); + }); + + it('of different severities', () => { + state.sast.newIssues = [generateVuln(CRITICAL)]; + state.secretDetection.newIssues = [generateVuln(HIGH), generateVuln(LOW)]; + + expect(summaryCounts(state)).toEqual({ + critical: 1, + high: 1, + other: 1, + }); + }); + }); + }); + + describe('groupedSummaryText', () => { + it('returns failed text', () => { + expect( + groupedSummaryText(state, { + allReportsHaveError: true, + areReportsLoading: false, + summaryCounts: {}, + }), + ).toEqual({ message: 'Security scanning failed loading any results' }); + }); + + it('returns `is loading` as status text', () => { + expect( + groupedSummaryText(state, { + allReportsHaveError: false, + areReportsLoading: true, + summaryCounts: {}, + }), + ).toEqual( + groupedTextBuilder({ + reportType: 'Security scanning', + critical: 0, + high: 0, + other: 0, + status: 'is loading', + }), + ); + }); + + it('returns no new status text if there are existing ones', () => { + expect( + groupedSummaryText(state, { + allReportsHaveError: false, + areReportsLoading: false, + summaryCounts: {}, + }), + ).toEqual( + groupedTextBuilder({ + reportType: 'Security scanning', + critical: 0, + high: 0, + other: 0, + status: '', + }), + ); + }); + }); + + describe('areReportsLoading', () => { + it('returns true when any report is loading', () => { + state.sast.isLoading = true; + + expect(areReportsLoading(state)).toEqual(true); + }); + + it('returns false when none of the reports are loading', () => { + expect(areReportsLoading(state)).toEqual(false); + }); + }); + + describe('areAllReportsLoading', () => { + it('returns true when all reports are loading', () => { + state.sast.isLoading = true; + state.secretDetection.isLoading = true; + + expect(areAllReportsLoading(state)).toEqual(true); + }); + + it('returns false when some of the reports are loading', () => { + state.sast.isLoading = true; + + expect(areAllReportsLoading(state)).toEqual(false); + }); + + it('returns false when none of the reports are loading', () => { + expect(areAllReportsLoading(state)).toEqual(false); + }); + }); + + describe('allReportsHaveError', () => { + it('returns true when all reports have error', () => { + state.sast.hasError = true; + state.secretDetection.hasError = true; + + expect(allReportsHaveError(state)).toEqual(true); + }); + + it('returns false when none of the reports have error', () => { + expect(allReportsHaveError(state)).toEqual(false); + }); + + it('returns false when one of the reports does not have error', () => { + state.secretDetection.hasError = true; + + expect(allReportsHaveError(state)).toEqual(false); + }); + }); + + describe('anyReportHasError', () => { + it('returns true when any of the reports has error', () => { + state.sast.hasError = true; + + expect(anyReportHasError(state)).toEqual(true); + }); + + it('returns false when none of the reports has error', () => { + expect(anyReportHasError(state)).toEqual(false); + }); + }); + + describe('anyReportHasIssues', () => { + it('returns true when any of the reports has new issues', () => { + state.sast.newIssues.push(generateVuln(LOW)); + + expect(anyReportHasIssues(state)).toEqual(true); + }); + + it('returns false when none of the reports has error', () => { + expect(anyReportHasIssues(state)).toEqual(false); + }); + }); +}); diff --git a/spec/frontend/vue_shared/security_reports/utils_spec.js b/spec/frontend/vue_shared/security_reports/utils_spec.js new file mode 100644 index 00000000000..ea54644796a --- /dev/null +++ b/spec/frontend/vue_shared/security_reports/utils_spec.js @@ -0,0 +1,28 @@ +import { extractSecurityReportArtifacts } from '~/vue_shared/security_reports/utils'; +import { + REPORT_TYPE_SAST, + REPORT_TYPE_SECRET_DETECTION, +} from '~/vue_shared/security_reports/constants'; +import { + securityReportDownloadPathsQueryResponse, + sastArtifacts, + secretDetectionArtifacts, +} from './mock_data'; + +describe('extractSecurityReportArtifacts', () => { + it.each` + reportTypes | expectedArtifacts + ${[]} | ${[]} + ${['foo']} | ${[]} + ${[REPORT_TYPE_SAST]} | ${sastArtifacts} + ${[REPORT_TYPE_SECRET_DETECTION]} | ${secretDetectionArtifacts} + ${[REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION]} | ${[...secretDetectionArtifacts, ...sastArtifacts]} + `( + 'returns the expected artifacts given report types $reportTypes', + ({ reportTypes, expectedArtifacts }) => { + expect( + extractSecurityReportArtifacts(reportTypes, securityReportDownloadPathsQueryResponse), + ).toEqual(expectedArtifacts); + }, + ); +}); |