import * as Sentry from '@sentry/browser';
import {
GlAlert,
GlLink,
GlFormRadio,
GlToggle,
GlCard,
GlSkeletonLoader,
GlIcon,
} from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import {
TRACK_TOGGLE_TRAINING_PROVIDER_ACTION,
TRACK_TOGGLE_TRAINING_PROVIDER_LABEL,
TRACK_PROVIDER_LEARN_MORE_CLICK_ACTION,
TRACK_PROVIDER_LEARN_MORE_CLICK_LABEL,
} from '~/security_configuration/constants';
import { TEMP_PROVIDER_URLS } from '~/security_configuration/components/constants';
import TrainingProviderList from '~/security_configuration/components/training_provider_list.vue';
import { updateSecurityTrainingOptimisticResponse } from '~/security_configuration/graphql/cache_utils';
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,
getSecurityTrainingProvidersData,
updateSecurityTrainingProvidersResponse,
updateSecurityTrainingProvidersErrorResponse,
testProjectPath,
testProviderIds,
testProviderName,
} from '../mock_data';
Vue.use(VueApollo);
const TEST_TRAINING_PROVIDERS_ALL_DISABLED = getSecurityTrainingProvidersData();
const TEST_TRAINING_PROVIDERS_FIRST_ENABLED = getSecurityTrainingProvidersData({
providerOverrides: { first: { isEnabled: true, isPrimary: true } },
});
const TEST_TRAINING_PROVIDERS_ALL_ENABLED = getSecurityTrainingProvidersData({
providerOverrides: {
first: { isEnabled: true, isPrimary: true },
second: { isEnabled: true, isPrimary: false },
third: { isEnabled: true, isPrimary: false },
},
});
const TEST_TRAINING_PROVIDERS_DEFAULT = TEST_TRAINING_PROVIDERS_ALL_DISABLED;
const TEMP_PROVIDER_LOGOS = {
Kontra: {
svg: '',
},
'Secure Code Warrior': {
svg: '',
},
};
jest.mock('~/security_configuration/components/constants', () => {
return {
TEMP_PROVIDER_URLS: jest.requireActual('~/security_configuration/components/constants')
.TEMP_PROVIDER_URLS,
// NOTE: Jest hoists all mocks to the top so we can't use TEMP_PROVIDER_LOGOS
// here directly.
TEMP_PROVIDER_LOGOS: {
Kontra: {
svg: '',
},
'Secure Code Warrior': {
svg: '',
},
},
};
});
describe('TrainingProviderList component', () => {
let wrapper;
let apolloProvider;
const createApolloProvider = ({ handlers = [] } = {}) => {
const defaultHandlers = [
[
securityTrainingProvidersQuery,
jest.fn().mockResolvedValue(TEST_TRAINING_PROVIDERS_DEFAULT.response),
],
[
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 = (props = {}) => {
wrapper = shallowMountExtended(TrainingProviderList, {
provide: {
projectFullPath: testProjectPath,
},
directives: {
GlTooltip: createMockDirective('gl-tooltip'),
},
propsData: {
securityTrainingEnabled: true,
...props,
},
apolloProvider,
});
};
const waitForQueryToBeLoaded = () => waitForPromises();
const waitForMutationToBeLoaded = waitForQueryToBeLoaded;
const findCards = () => wrapper.findAllComponents(GlCard);
const findLinks = () => wrapper.findAllComponents(GlLink);
const findToggles = () => wrapper.findAllComponents(GlToggle);
const findFirstToggle = () => findToggles().at(0);
const findPrimaryProviderRadios = () => wrapper.findAllComponents(GlFormRadio);
const findLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findErrorAlert = () => wrapper.findComponent(GlAlert);
const findLogos = () => wrapper.findAllByTestId('provider-logo');
const findUnavailableTexts = () => wrapper.findAllByTestId('unavailable-text');
const toggleFirstProvider = () => findFirstToggle().vm.$emit('change', testProviderIds[0]);
afterEach(() => {
apolloProvider = null;
});
describe('when loading', () => {
beforeEach(() => {
const pendingHandler = () => new Promise(() => {});
createApolloProvider({
handlers: [[securityTrainingProvidersQuery, pendingHandler]],
});
createComponent();
});
it('shows the loader', () => {
expect(findLoader().exists()).toBe(true);
});
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: [],
TEST_TRAINING_PROVIDERS_DEFAULT: [],
}),
},
},
});
createComponent();
});
describe('basic structure', () => {
beforeEach(async () => {
await waitForQueryToBeLoaded();
});
it('renders correct amount of cards', () => {
expect(findCards()).toHaveLength(TEST_TRAINING_PROVIDERS_DEFAULT.data.length);
});
TEST_TRAINING_PROVIDERS_DEFAULT.data.forEach(({ name, description, isEnabled }, index) => {
it(`shows the name for card ${index}`, () => {
expect(findCards().at(index).text()).toContain(name);
});
it(`shows the description for card ${index}`, () => {
expect(findCards().at(index).text()).toContain(description);
});
it(`shows the learn more link for enabled card ${index}`, () => {
const learnMoreLink = findCards().at(index).findComponent(GlLink);
const tempLogo = TEMP_PROVIDER_URLS[name];
if (tempLogo) {
expect(learnMoreLink.attributes()).toEqual({
target: '_blank',
href: TEMP_PROVIDER_URLS[name],
});
} else {
expect(learnMoreLink.exists()).toBe(false);
}
});
it(`shows the toggle with the correct value for card ${index}`, () => {
expect(findToggles().at(index).props('value')).toEqual(isEnabled);
});
it(`shows a radio button to select the provider as primary within card ${index}`, () => {
const primaryProviderRadioForCurrentCard = findPrimaryProviderRadios().at(index);
// if the given provider is not enabled it should not be possible select it as primary
expect(primaryProviderRadioForCurrentCard.attributes('disabled')).toBe(
isEnabled ? undefined : 'true',
);
expect(primaryProviderRadioForCurrentCard.text()).toBe(
TrainingProviderList.i18n.primaryTraining,
);
});
it('shows a info-tooltip that describes the purpose of a primary provider', () => {
const infoIcon = findPrimaryProviderRadios().at(index).findComponent(GlIcon);
const tooltip = getBinding(infoIcon.element, 'gl-tooltip');
expect(infoIcon.props()).toMatchObject({
name: 'information-o',
});
expect(tooltip.value).toBe(TrainingProviderList.i18n.primaryTrainingDescription);
});
it('does not show loader when query is populated', () => {
expect(findLoader().exists()).toBe(false);
});
});
});
describe('provider logo', () => {
beforeEach(async () => {
await waitForQueryToBeLoaded();
});
const providerIndexArray = [0, 1];
it.each(providerIndexArray)('displays the correct width for provider %s', (provider) => {
expect(findLogos().at(provider).attributes('style')).toBe('width: 18px;');
});
it.each(providerIndexArray)('has a11y decorative attribute for provider %s', (provider) => {
expect(findLogos().at(provider).attributes('role')).toBe('presentation');
});
it.each(providerIndexArray)('renders the svg content for provider %s', async (provider) => {
expect(findLogos().at(provider).html()).toContain(
TEMP_PROVIDER_LOGOS[testProviderName[provider]].svg,
);
});
});
describe('storing training provider settings', () => {
beforeEach(async () => {
jest.spyOn(apolloProvider.defaultClient, 'mutate');
await waitForQueryToBeLoaded();
await toggleFirstProvider();
});
it('calls mutation when toggle is changed', () => {
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith(
expect.objectContaining({
mutation: configureSecurityTrainingProvidersMutation,
variables: {
input: {
providerId: testProviderIds[0],
isEnabled: true,
isPrimary: true,
projectPath: testProjectPath,
},
},
}),
);
});
it('returns an optimistic response when calling the mutation', () => {
const optimisticResponse = updateSecurityTrainingOptimisticResponse({
id: TEST_TRAINING_PROVIDERS_DEFAULT.data[0].id,
isEnabled: true,
isPrimary: true,
});
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith(
expect.objectContaining({
optimisticResponse,
}),
);
});
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: TEST_TRAINING_PROVIDERS_DEFAULT.data[0].id,
label: TRACK_TOGGLE_TRAINING_PROVIDER_LABEL,
extra: {
providerIsEnabled: true,
},
});
});
it(`tracks when a provider's "Learn more" link is clicked`, () => {
const firstProviderLink = findLinks().at(0);
const [{ id: firstProviderId }] = TEST_TRAINING_PROVIDERS_DEFAULT.data;
expect(trackingSpy).not.toHaveBeenCalled();
firstProviderLink.vm.$emit('click');
expect(trackingSpy).toHaveBeenCalledWith(
undefined,
TRACK_PROVIDER_LEARN_MORE_CLICK_ACTION,
{
label: TRACK_PROVIDER_LEARN_MORE_CLICK_LABEL,
property: firstProviderId,
},
);
});
});
describe('non ultimate users', () => {
beforeEach(async () => {
createComponent({
securityTrainingEnabled: false,
});
await waitForQueryToBeLoaded();
});
it('displays unavailable text', () => {
findUnavailableTexts().wrappers.forEach((unavailableText) => {
expect(unavailableText.text()).toBe(TrainingProviderList.i18n.unavailableText);
});
});
it('has disabled state for toggle', () => {
findToggles().wrappers.forEach((toggle) => {
expect(toggle.props('disabled')).toBe(true);
});
});
it('has disabled state for radio', () => {
findPrimaryProviderRadios().wrappers.forEach((radio) => {
expect(radio.attributes('disabled')).toBe('true');
});
});
it('adds backgrounds color', () => {
findCards().wrappers.forEach((card) => {
expect(card.props('bodyClass')).toMatchObject({
'gl-bg-gray-10': true,
});
});
});
});
});
describe('primary provider settings', () => {
it.each`
description | initialProviderData | expectedMutationInput
${'sets the provider to be non-primary when it gets disabled'} | ${TEST_TRAINING_PROVIDERS_FIRST_ENABLED.response} | ${{ providerId: TEST_TRAINING_PROVIDERS_FIRST_ENABLED.data[0].id, isEnabled: false, isPrimary: false }}
${'sets a provider to be primary when it is the only one enabled'} | ${TEST_TRAINING_PROVIDERS_ALL_DISABLED.response} | ${{ providerId: TEST_TRAINING_PROVIDERS_ALL_DISABLED.data[0].id, isEnabled: true, isPrimary: true }}
${'sets the first other enabled provider to be primary when the primary one gets disabled'} | ${TEST_TRAINING_PROVIDERS_ALL_ENABLED.response} | ${{ providerId: TEST_TRAINING_PROVIDERS_ALL_ENABLED.data[1].id, isEnabled: true, isPrimary: true }}
`('$description', async ({ initialProviderData, expectedMutationInput }) => {
createApolloProvider({
handlers: [
[securityTrainingProvidersQuery, jest.fn().mockResolvedValue(initialProviderData)],
],
});
jest.spyOn(apolloProvider.defaultClient, 'mutate');
createComponent();
await waitForQueryToBeLoaded();
await toggleFirstProvider();
expect(apolloProvider.defaultClient.mutate).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
variables: {
input: expect.objectContaining({
...expectedMutationInput,
}),
},
}),
);
});
});
describe('with errors', () => {
const expectErrorAlertToExist = () => {
expect(findErrorAlert().props()).toMatchObject({
dismissible: false,
variant: 'danger',
});
};
describe('when fetching training providers', () => {
beforeEach(async () => {
createApolloProvider({
handlers: [[securityTrainingProvidersQuery, jest.fn().mockRejectedValue()]],
});
createComponent();
await waitForQueryToBeLoaded();
});
it('shows an non-dismissible error alert', () => {
expectErrorAlertToExist();
});
it('shows an error description', () => {
expect(findErrorAlert().text()).toBe(TrainingProviderList.i18n.providerQueryErrorMessage);
});
});
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 }) => {
it('logs the error to sentry', async () => {
jest.spyOn(Sentry, 'captureException').mockImplementation();
createApolloProvider({
handlers: [[dismissUserCalloutMutation, mutationHandler]],
resolvers: {
Mutation: {
configureSecurityTrainingProviders: () => ({
errors: [],
securityTrainingProviders: [],
}),
},
},
});
createComponent();
await waitForQueryToBeLoaded();
toggleFirstProvider();
expect(Sentry.captureException).not.toHaveBeenCalled();
await waitForMutationToBeLoaded();
expect(Sentry.captureException).toHaveBeenCalled();
});
});
});
});