diff options
Diffstat (limited to 'spec/frontend/security_configuration')
5 files changed, 298 insertions, 69 deletions
diff --git a/spec/frontend/security_configuration/components/app_spec.js b/spec/frontend/security_configuration/components/app_spec.js index cbdf7f53913..963577fa763 100644 --- a/spec/frontend/security_configuration/components/app_spec.js +++ b/spec/frontend/security_configuration/components/app_spec.js @@ -32,7 +32,7 @@ const upgradePath = '/upgrade'; const autoDevopsHelpPagePath = '/autoDevopsHelpPagePath'; const autoDevopsPath = '/autoDevopsPath'; const gitlabCiHistoryPath = 'test/historyPath'; -const projectPath = 'namespace/project'; +const projectFullPath = 'namespace/project'; useLocalStorageSpy(); @@ -54,7 +54,7 @@ describe('App component', () => { upgradePath, autoDevopsHelpPagePath, autoDevopsPath, - projectPath, + projectFullPath, glFeatures: { secureVulnerabilityTraining, }, @@ -274,11 +274,11 @@ describe('App component', () => { describe('Auto DevOps enabled alert', () => { describe.each` - context | autoDevopsEnabled | localStorageValue | shouldRender - ${'enabled'} | ${true} | ${null} | ${true} - ${'enabled, alert dismissed on other project'} | ${true} | ${['foo/bar']} | ${true} - ${'enabled, alert dismissed on this project'} | ${true} | ${[projectPath]} | ${false} - ${'not enabled'} | ${false} | ${null} | ${false} + context | autoDevopsEnabled | localStorageValue | shouldRender + ${'enabled'} | ${true} | ${null} | ${true} + ${'enabled, alert dismissed on other project'} | ${true} | ${['foo/bar']} | ${true} + ${'enabled, alert dismissed on this project'} | ${true} | ${[projectFullPath]} | ${false} + ${'not enabled'} | ${false} | ${null} | ${false} `('given Auto DevOps is $context', ({ autoDevopsEnabled, localStorageValue, shouldRender }) => { beforeEach(() => { if (localStorageValue !== null) { @@ -302,11 +302,11 @@ describe('App component', () => { describe('dismissing', () => { describe.each` - dismissedProjects | expectedWrittenValue - ${null} | ${[projectPath]} - ${[]} | ${[projectPath]} - ${['foo/bar']} | ${['foo/bar', projectPath]} - ${[projectPath]} | ${[projectPath]} + dismissedProjects | expectedWrittenValue + ${null} | ${[projectFullPath]} + ${[]} | ${[projectFullPath]} + ${['foo/bar']} | ${['foo/bar', projectFullPath]} + ${[projectFullPath]} | ${[projectFullPath]} `( 'given dismissed projects $dismissedProjects', ({ dismissedProjects, expectedWrittenValue }) => { diff --git a/spec/frontend/security_configuration/components/feature_card_spec.js b/spec/frontend/security_configuration/components/feature_card_spec.js index 0eca2c27075..2b74be19480 100644 --- a/spec/frontend/security_configuration/components/feature_card_spec.js +++ b/spec/frontend/security_configuration/components/feature_card_spec.js @@ -113,7 +113,6 @@ describe('FeatureCard component', () => { context | available | configured | expectedStatus ${'a configured feature'} | ${true} | ${true} | ${'Enabled'} ${'an unconfigured feature'} | ${true} | ${false} | ${'Not enabled'} - ${'an available feature with unknown status'} | ${true} | ${undefined} | ${''} ${'an unavailable feature'} | ${false} | ${false} | ${'Available with Ultimate'} ${'an unavailable feature with unknown status'} | ${false} | ${undefined} | ${'Available with Ultimate'} `('given $context', ({ available, configured, expectedStatus }) => { diff --git a/spec/frontend/security_configuration/components/training_provider_list_spec.js b/spec/frontend/security_configuration/components/training_provider_list_spec.js index 578248e696f..18c9ada6bde 100644 --- a/spec/frontend/security_configuration/components/training_provider_list_spec.js +++ b/spec/frontend/security_configuration/components/training_provider_list_spec.js @@ -1,14 +1,26 @@ +import * as Sentry from '@sentry/browser'; import { GlAlert, GlLink, GlToggle, GlCard, GlSkeletonLoader } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { + TRACK_TOGGLE_TRAINING_PROVIDER_ACTION, + TRACK_TOGGLE_TRAINING_PROVIDER_LABEL, +} from '~/security_configuration/constants'; import TrainingProviderList from '~/security_configuration/components/training_provider_list.vue'; +import securityTrainingProvidersQuery from '~/security_configuration/graphql/security_training_providers.query.graphql'; import configureSecurityTrainingProvidersMutation from '~/security_configuration/graphql/configure_security_training_providers.mutation.graphql'; +import dismissUserCalloutMutation from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql'; import waitForPromises from 'helpers/wait_for_promises'; import { + dismissUserCalloutResponse, + dismissUserCalloutErrorResponse, securityTrainingProviders, - createMockResolvers, + securityTrainingProvidersResponse, + updateSecurityTrainingProvidersResponse, + updateSecurityTrainingProvidersErrorResponse, testProjectPath, textProviderIds, } from '../mock_data'; @@ -19,14 +31,28 @@ describe('TrainingProviderList component', () => { let wrapper; let apolloProvider; - const createApolloProvider = ({ resolvers } = {}) => { - apolloProvider = createMockApollo([], createMockResolvers({ resolvers })); + const createApolloProvider = ({ handlers = [] } = {}) => { + const defaultHandlers = [ + [ + securityTrainingProvidersQuery, + jest.fn().mockResolvedValue(securityTrainingProvidersResponse), + ], + [ + configureSecurityTrainingProvidersMutation, + jest.fn().mockResolvedValue(updateSecurityTrainingProvidersResponse), + ], + ]; + + // make sure we don't have any duplicate handlers to avoid 'Request handler already defined for query` errors + const mergedHandlers = [...new Map([...defaultHandlers, ...handlers])]; + + apolloProvider = createMockApollo(mergedHandlers); }; const createComponent = () => { wrapper = shallowMount(TrainingProviderList, { provide: { - projectPath: testProjectPath, + projectFullPath: testProjectPath, }, apolloProvider, }); @@ -42,27 +68,49 @@ describe('TrainingProviderList component', () => { const findLoader = () => wrapper.findComponent(GlSkeletonLoader); const findErrorAlert = () => wrapper.findComponent(GlAlert); - const toggleFirstProvider = () => findFirstToggle().vm.$emit('change'); + const toggleFirstProvider = () => findFirstToggle().vm.$emit('change', textProviderIds[0]); afterEach(() => { wrapper.destroy(); apolloProvider = null; }); - describe('with a successful response', () => { + describe('when loading', () => { beforeEach(() => { - createApolloProvider(); + const pendingHandler = () => new Promise(() => {}); + + createApolloProvider({ + handlers: [[securityTrainingProvidersQuery, pendingHandler]], + }); createComponent(); }); - describe('when loading', () => { - it('shows the loader', () => { - expect(findLoader().exists()).toBe(true); - }); + it('shows the loader', () => { + expect(findLoader().exists()).toBe(true); + }); - it('does not show the cards', () => { - expect(findCards().exists()).toBe(false); + it('does not show the cards', () => { + expect(findCards().exists()).toBe(false); + }); + }); + + describe('with a successful response', () => { + beforeEach(() => { + createApolloProvider({ + handlers: [ + [dismissUserCalloutMutation, jest.fn().mockResolvedValue(dismissUserCalloutResponse)], + ], + resolvers: { + Mutation: { + configureSecurityTrainingProviders: () => ({ + errors: [], + securityTrainingProviders: [], + }), + }, + }, }); + + createComponent(); }); describe('basic structure', () => { @@ -104,9 +152,9 @@ describe('TrainingProviderList component', () => { beforeEach(async () => { jest.spyOn(apolloProvider.defaultClient, 'mutate'); - await waitForMutationToBeLoaded(); + await waitForQueryToBeLoaded(); - toggleFirstProvider(); + await toggleFirstProvider(); }); it.each` @@ -124,10 +172,78 @@ describe('TrainingProviderList component', () => { expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith( expect.objectContaining({ mutation: configureSecurityTrainingProvidersMutation, - variables: { input: { enabledProviders: textProviderIds, fullPath: testProjectPath } }, + variables: { + input: { + providerId: textProviderIds[0], + isEnabled: true, + isPrimary: false, + projectPath: testProjectPath, + }, + }, }), ); }); + + it('dismisses the callout when the feature gets first enabled', async () => { + // wait for configuration update mutation to complete + await waitForMutationToBeLoaded(); + + // both the config and dismiss mutations have been called + expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledTimes(2); + expect(apolloProvider.defaultClient.mutate).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + mutation: dismissUserCalloutMutation, + variables: { + input: { + featureName: 'security_training_feature_promotion', + }, + }, + }), + ); + + toggleFirstProvider(); + await waitForMutationToBeLoaded(); + + // the config mutation has been called again but not the dismiss mutation + expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledTimes(3); + expect(apolloProvider.defaultClient.mutate).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + mutation: configureSecurityTrainingProvidersMutation, + }), + ); + }); + }); + + describe('metrics', () => { + let trackingSpy; + + beforeEach(() => { + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('tracks when a provider gets toggled', () => { + expect(trackingSpy).not.toHaveBeenCalled(); + + toggleFirstProvider(); + + // Note: Ideally we also want to test that the tracking event is called correctly when a + // provider gets disabled, but that's a bit tricky to do with the current implementation + // Once https://gitlab.com/gitlab-org/gitlab/-/issues/348985 and https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79492 + // are merged this will be much easer to do and should be tackled then. + expect(trackingSpy).toHaveBeenCalledWith(undefined, TRACK_TOGGLE_TRAINING_PROVIDER_ACTION, { + property: securityTrainingProviders[0].id, + label: TRACK_TOGGLE_TRAINING_PROVIDER_LABEL, + extra: { + providerIsEnabled: true, + }, + }); + }); }); }); @@ -142,11 +258,7 @@ describe('TrainingProviderList component', () => { describe('when fetching training providers', () => { beforeEach(async () => { createApolloProvider({ - resolvers: { - Query: { - securityTrainingProviders: jest.fn().mockReturnValue(new Error()), - }, - }, + handlers: [[securityTrainingProvidersQuery, jest.fn().mockRejectedValue()]], }); createComponent(); @@ -165,10 +277,43 @@ describe('TrainingProviderList component', () => { describe('when storing training provider configurations', () => { beforeEach(async () => { createApolloProvider({ + handlers: [ + [ + configureSecurityTrainingProvidersMutation, + jest.fn().mockReturnValue(updateSecurityTrainingProvidersErrorResponse), + ], + ], + }); + createComponent(); + + await waitForQueryToBeLoaded(); + toggleFirstProvider(); + await waitForMutationToBeLoaded(); + }); + + it('shows an non-dismissible error alert', () => { + expectErrorAlertToExist(); + }); + + it('shows an error description', () => { + expect(findErrorAlert().text()).toBe(TrainingProviderList.i18n.configMutationErrorMessage); + }); + }); + + describe.each` + errorType | mutationHandler + ${'backend error'} | ${jest.fn().mockReturnValue(dismissUserCalloutErrorResponse)} + ${'network error'} | ${jest.fn().mockRejectedValue()} + `('when dismissing the callout and a "$errorType" happens', ({ mutationHandler }) => { + beforeEach(async () => { + jest.spyOn(Sentry, 'captureException').mockImplementation(); + + createApolloProvider({ + handlers: [[dismissUserCalloutMutation, mutationHandler]], resolvers: { Mutation: { configureSecurityTrainingProviders: () => ({ - errors: ['something went wrong!'], + errors: [], securityTrainingProviders: [], }), }, @@ -178,15 +323,14 @@ describe('TrainingProviderList component', () => { await waitForQueryToBeLoaded(); toggleFirstProvider(); - await waitForMutationToBeLoaded(); }); - it('shows an non-dismissible error alert', () => { - expectErrorAlertToExist(); - }); + it('logs the error to sentry', async () => { + expect(Sentry.captureException).not.toHaveBeenCalled(); - it('shows an error description', () => { - expect(findErrorAlert().text()).toBe(TrainingProviderList.i18n.configMutationErrorMessage); + await waitForMutationToBeLoaded(); + + expect(Sentry.captureException).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/security_configuration/components/upgrade_banner_spec.js b/spec/frontend/security_configuration/components/upgrade_banner_spec.js index a35fded72fb..ff44acfc4f9 100644 --- a/spec/frontend/security_configuration/components/upgrade_banner_spec.js +++ b/spec/frontend/security_configuration/components/upgrade_banner_spec.js @@ -1,15 +1,22 @@ import { GlBanner } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import UpgradeBanner from '~/security_configuration/components/upgrade_banner.vue'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import UpgradeBanner, { + SECURITY_UPGRADE_BANNER, + UPGRADE_OR_FREE_TRIAL, +} from '~/security_configuration/components/upgrade_banner.vue'; const upgradePath = '/upgrade'; describe('UpgradeBanner component', () => { let wrapper; let closeSpy; + let primarySpy; + let trackingSpy; const createComponent = (propsData) => { closeSpy = jest.fn(); + primarySpy = jest.fn(); wrapper = shallowMountExtended(UpgradeBanner, { provide: { @@ -18,43 +25,83 @@ describe('UpgradeBanner component', () => { propsData, listeners: { close: closeSpy, + primary: primarySpy, }, }); }; const findGlBanner = () => wrapper.findComponent(GlBanner); + const expectTracking = (action, label) => { + return expect(trackingSpy).toHaveBeenCalledWith(undefined, action, { + label, + property: SECURITY_UPGRADE_BANNER, + }); + }; + beforeEach(() => { - createComponent(); + trackingSpy = mockTracking(undefined, undefined, jest.spyOn); }); afterEach(() => { wrapper.destroy(); + unmockTracking(); }); - it('passes the expected props to GlBanner', () => { - expect(findGlBanner().props()).toMatchObject({ - title: UpgradeBanner.i18n.title, - buttonText: UpgradeBanner.i18n.buttonText, - buttonLink: upgradePath, + describe('when the component renders', () => { + it('tracks an event', () => { + expect(trackingSpy).not.toHaveBeenCalled(); + + createComponent(); + + expectTracking('render', SECURITY_UPGRADE_BANNER); }); }); - it('renders the list of benefits', () => { - const wrapperText = wrapper.text(); + describe('when ready', () => { + beforeEach(() => { + createComponent(); + trackingSpy.mockClear(); + }); - expect(wrapperText).toContain('Immediately begin risk analysis and remediation'); - expect(wrapperText).toContain('statistics in the merge request'); - expect(wrapperText).toContain('statistics across projects'); - expect(wrapperText).toContain('Runtime security metrics'); - expect(wrapperText).toContain('More scan types, including Container Scanning,'); - }); + it('passes the expected props to GlBanner', () => { + expect(findGlBanner().props()).toMatchObject({ + title: UpgradeBanner.i18n.title, + buttonText: UpgradeBanner.i18n.buttonText, + buttonLink: upgradePath, + }); + }); - it(`re-emits GlBanner's close event`, () => { - expect(closeSpy).not.toHaveBeenCalled(); + it('renders the list of benefits', () => { + const wrapperText = wrapper.text(); - wrapper.findComponent(GlBanner).vm.$emit('close'); + expect(wrapperText).toContain('Immediately begin risk analysis and remediation'); + expect(wrapperText).toContain('statistics in the merge request'); + expect(wrapperText).toContain('statistics across projects'); + expect(wrapperText).toContain('Runtime security metrics'); + expect(wrapperText).toContain('More scan types, including Container Scanning,'); + }); + + describe('when user interacts', () => { + it(`re-emits GlBanner's close event & tracks an event`, () => { + expect(closeSpy).not.toHaveBeenCalled(); + expect(trackingSpy).not.toHaveBeenCalled(); + + wrapper.findComponent(GlBanner).vm.$emit('close'); + + expect(closeSpy).toHaveBeenCalledTimes(1); + expectTracking('dismiss_banner', SECURITY_UPGRADE_BANNER); + }); - expect(closeSpy).toHaveBeenCalledTimes(1); + it(`re-emits GlBanner's primary event & tracks an event`, () => { + expect(primarySpy).not.toHaveBeenCalled(); + expect(trackingSpy).not.toHaveBeenCalled(); + + wrapper.findComponent(GlBanner).vm.$emit('primary'); + + expect(primarySpy).toHaveBeenCalledTimes(1); + expectTracking('click_button', UPGRADE_OR_FREE_TRIAL); + }); + }); }); }); diff --git a/spec/frontend/security_configuration/mock_data.js b/spec/frontend/security_configuration/mock_data.js index 37ecce3886d..b042e870467 100644 --- a/spec/frontend/security_configuration/mock_data.js +++ b/spec/frontend/security_configuration/mock_data.js @@ -9,6 +9,7 @@ export const securityTrainingProviders = [ description: 'Interactive developer security education', url: 'https://www.example.org/security/training', isEnabled: false, + isPrimary: false, }, { id: textProviderIds[1], @@ -16,24 +17,62 @@ export const securityTrainingProviders = [ description: 'Security training with guide and learning pathways.', url: 'https://www.vendornametwo.com/', isEnabled: true, + isPrimary: false, }, ]; export const securityTrainingProvidersResponse = { data: { - securityTrainingProviders, + project: { + id: 1, + securityTrainingProviders, + }, + }, +}; + +export const dismissUserCalloutResponse = { + data: { + userCalloutCreate: { + errors: [], + userCallout: { + dismissedAt: '2022-02-02T04:36:57Z', + featureName: 'SECURITY_TRAINING_FEATURE_PROMOTION', + }, + }, + }, +}; + +export const dismissUserCalloutErrorResponse = { + data: { + userCalloutCreate: { + errors: ['Something went wrong'], + userCallout: { + dismissedAt: '', + featureName: 'SECURITY_TRAINING_FEATURE_PROMOTION', + }, + }, }, }; -const defaultMockResolvers = { - Query: { - securityTrainingProviders() { - return securityTrainingProviders; +export const updateSecurityTrainingProvidersResponse = { + data: { + securityTrainingUpdate: { + errors: [], + training: { + id: 101, + name: 'Acme', + isEnabled: true, + isPrimary: false, + }, }, }, }; -export const createMockResolvers = ({ resolvers: customMockResolvers = {} } = {}) => ({ - ...defaultMockResolvers, - ...customMockResolvers, -}); +export const updateSecurityTrainingProvidersErrorResponse = { + data: { + securityTrainingUpdate: { + errors: ['something went wrong!'], + training: null, + }, + }, +}; |