summaryrefslogtreecommitdiff
path: root/spec/frontend/security_configuration
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/security_configuration')
-rw-r--r--spec/frontend/security_configuration/components/app_spec.js24
-rw-r--r--spec/frontend/security_configuration/components/feature_card_spec.js1
-rw-r--r--spec/frontend/security_configuration/components/training_provider_list_spec.js200
-rw-r--r--spec/frontend/security_configuration/components/upgrade_banner_spec.js85
-rw-r--r--spec/frontend/security_configuration/mock_data.js57
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,
+ },
+ },
+};