Commit 9258aef7 authored by Frédéric Caplette's avatar Frédéric Caplette

Merge branch 'dpisek-security-training-providers-use-backend-api' into 'master'

Use GraphQL-API to fetch security training providers

See merge request gitlab-org/gitlab!78755
parents b7729478 b5ac2897
......@@ -50,7 +50,7 @@ export default {
TrainingProviderList,
},
mixins: [glFeatureFlagsMixin()],
inject: ['projectPath'],
inject: ['projectFullPath'],
props: {
augmentedSecurityFeatures: {
type: Array,
......@@ -107,14 +107,14 @@ export default {
shouldShowAutoDevopsEnabledAlert() {
return (
this.autoDevopsEnabled &&
!this.autoDevopsEnabledAlertDismissedProjects.includes(this.projectPath)
!this.autoDevopsEnabledAlertDismissedProjects.includes(this.projectFullPath)
);
},
},
methods: {
dismissAutoDevopsEnabledAlert() {
const dismissedProjects = new Set(this.autoDevopsEnabledAlertDismissedProjects);
dismissedProjects.add(this.projectPath);
dismissedProjects.add(this.projectFullPath);
this.autoDevopsEnabledAlertDismissedProjects = Array.from(dismissedProjects);
},
onError(message) {
......
......@@ -21,10 +21,18 @@ export default {
GlLink,
GlSkeletonLoader,
},
inject: ['projectPath'],
inject: ['projectFullPath'],
apollo: {
securityTrainingProviders: {
query: securityTrainingProvidersQuery,
variables() {
return {
fullPath: this.projectFullPath,
};
},
update({ project }) {
return project?.securityTrainingProviders;
},
error() {
this.errorMessage = this.$options.i18n.providerQueryErrorMessage;
},
......@@ -68,7 +76,7 @@ export default {
variables: {
input: {
enabledProviders: enabledProviderIds,
fullPath: this.projectPath,
fullPath: this.projectFullPath,
},
},
});
......
query Query {
securityTrainingProviders @client {
name
query getSecurityTrainingProviders($fullPath: ID!) {
project(fullPath: $fullPath) {
id
description
isEnabled
url
securityTrainingProviders {
name
id
description
isEnabled
url
}
}
}
......@@ -19,7 +19,7 @@ export const initSecurityConfiguration = (el) => {
});
const {
projectPath,
projectFullPath,
upgradePath,
features,
latestPipelinePath,
......@@ -38,7 +38,7 @@ export const initSecurityConfiguration = (el) => {
el,
apolloProvider,
provide: {
projectPath,
projectFullPath,
upgradePath,
autoDevopsHelpPagePath,
autoDevopsPath,
......
......@@ -14,7 +14,7 @@ export default {
components: {
GlButton,
},
inject: ['projectPath'],
inject: ['projectFullPath'],
props: {
feature: {
type: Object,
......@@ -47,7 +47,7 @@ export default {
try {
const { mutationSettings } = this;
const { data } = await this.$apollo.mutate(
mutationSettings.getMutationPayload(this.projectPath),
mutationSettings.getMutationPayload(this.projectFullPath),
);
const { errors, successPath } = data[mutationSettings.mutationId];
......
......@@ -2,4 +2,4 @@
- page_title _("Security Configuration")
- @content_class = "limit-container-width" unless fluid_layout
#js-security-configuration-static{ data: { project_path: @project.full_path, upgrade_path: security_upgrade_path } }
#js-security-configuration-static{ data: { project_full_path: @project.full_path, upgrade_path: security_upgrade_path } }
<script>
import { GlLink, GlIcon, GlSkeletonLoader } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { s__, __ } from '~/locale';
import securityTrainingProvidersQuery from '~/security_configuration/graphql/security_training_providers.query.graphql';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
......@@ -30,6 +31,7 @@ export default {
GlSkeletonLoader,
},
mixins: [glFeatureFlagsMixin()],
inject: ['projectFullPath'],
props: {
identifiers: {
type: Array,
......@@ -39,6 +41,17 @@ export default {
apollo: {
securityTrainingProviders: {
query: securityTrainingProvidersQuery,
update({ project }) {
return project?.securityTrainingProviders;
},
error(e) {
Sentry.captureException(e);
},
variables() {
return {
fullPath: this.projectFullPath,
};
},
},
},
data() {
......
......@@ -6,7 +6,7 @@
= render_ce 'projects/security/configuration/show'
- else
#js-security-configuration{ data: { **@configuration.to_html_data_attribute,
project_path: @project.full_path,
project_full_path: @project.full_path,
auto_fix_help_path: '/',
toggle_autofix_setting_endpoint: 'configuration/auto_fix',
container_scanning_help_path: help_page_path('user/application_security/container_scanning/index'),
......
......@@ -24,6 +24,9 @@ describe('Vulnerability Details', () => {
};
wrapper = mountFn(VulnerabilityDetails, {
propsData,
provide: {
projectFullPath: 'namespace/project',
},
});
};
const createShallowWrapper = (...args) => createWrapper(...args, { mountFn: shallowMount });
......
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import MockAdapter from 'axios-mock-adapter';
import * as Sentry from '@sentry/browser';
import { GlLink, GlIcon, GlSkeletonLoader } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status';
......@@ -9,11 +10,12 @@ import VulnerabilityTraining, {
i18n,
mockProvider,
} from 'ee/vulnerabilities/components/vulnerability_training.vue';
import securityTrainingProvidersQuery from '~/security_configuration/graphql/security_training_providers.query.graphql';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { SUPPORTED_IDENTIFIER_TYPES } from 'ee/vulnerabilities/constants';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createMockResolvers } from 'jest/security_configuration/mock_data';
import { securityTrainingProvidersResponse } from 'jest/security_configuration/mock_data';
const defaultProps = {
identifiers: [{ externalType: SUPPORTED_IDENTIFIER_TYPES.cwe }, { externalType: 'cve' }],
......@@ -28,8 +30,13 @@ describe('VulnerabilityTraining component', () => {
let apolloProvider;
let mock;
const createApolloProvider = ({ resolvers } = {}) => {
apolloProvider = createMockApollo([], createMockResolvers({ resolvers }));
const createApolloProvider = ({ queryHandler } = {}) => {
apolloProvider = createMockApollo([
[
securityTrainingProvidersQuery,
queryHandler || jest.fn().mockResolvedValue(securityTrainingProvidersResponse),
],
]);
};
const createComponent = (props = {}, { secureVulnerabilityTraining = true } = {}) => {
......@@ -40,6 +47,7 @@ describe('VulnerabilityTraining component', () => {
},
apolloProvider,
provide: {
projectFullPath: 'namespace/project',
glFeatures: {
secureVulnerabilityTraining,
},
......@@ -70,79 +78,101 @@ describe('VulnerabilityTraining component', () => {
const findTrainingItemLink = () => wrapper.findComponent(GlLink);
const findTrainingItemLinkIcon = () => wrapper.findComponent(GlIcon);
describe('basic structure', () => {
it('displays the title', async () => {
createComponent();
await waitForQueryToBeLoaded();
expect(findTitle().text()).toBe(i18n.trainingTitle);
describe('with the query being successful', () => {
beforeEach(() => {
createApolloProvider();
});
it('displays the description', async () => {
createComponent();
await waitForQueryToBeLoaded();
expect(findDescription().text()).toBe(i18n.trainingDescription);
});
describe('basic structure', () => {
it('displays the title', async () => {
createComponent();
await waitForQueryToBeLoaded();
expect(findTitle().text()).toBe(i18n.trainingTitle);
});
it('does not render component when there are no identifiers', () => {
createComponent({ identifiers: [] });
expect(wrapper.html()).toBeFalsy();
});
it('displays the description', async () => {
createComponent();
await waitForQueryToBeLoaded();
expect(findDescription().text()).toBe(i18n.trainingDescription);
});
it('does not render component when there are no securityTrainingProviders', () => {
createComponent();
expect(wrapper.html()).toBeFalsy();
});
});
it('does not render component when there are no identifiers', () => {
createComponent({ identifiers: [] });
expect(wrapper.html()).toBeFalsy();
});
describe('training availability message', () => {
it('displays the message', async () => {
createComponent({
identifiers: [{ externalType: 'not supported identifier' }],
it('does not render component when there are no securityTrainingProviders', () => {
createComponent();
expect(wrapper.html()).toBeFalsy();
});
await waitForQueryToBeLoaded();
expect(findUnavailableMessage().text()).toBe(i18n.trainingUnavailable);
});
it.each`
identifier | exists
${'not supported identifier'} | ${true}
${SUPPORTED_IDENTIFIER_TYPES.cwe.toUpperCase()} | ${false}
${SUPPORTED_IDENTIFIER_TYPES.cwe.toLowerCase()} | ${false}
`('sets it to "$exists" for "$identifier"', async ({ identifier, exists }) => {
await mockTrainingSuccess();
createComponent({ identifiers: [{ externalType: identifier }] });
await waitForQueryToBeLoaded();
expect(findUnavailableMessage().exists()).toBe(exists);
describe('training availability message', () => {
it('displays the message', async () => {
createComponent({
identifiers: [{ externalType: 'not supported identifier' }],
});
await waitForQueryToBeLoaded();
expect(findUnavailableMessage().text()).toBe(i18n.trainingUnavailable);
});
it.each`
identifier | exists
${'not supported identifier'} | ${true}
${SUPPORTED_IDENTIFIER_TYPES.cwe.toUpperCase()} | ${false}
${SUPPORTED_IDENTIFIER_TYPES.cwe.toLowerCase()} | ${false}
`('sets it to "$exists" for "$identifier"', async ({ identifier, exists }) => {
await mockTrainingSuccess();
createComponent({ identifiers: [{ externalType: identifier }] });
await waitForQueryToBeLoaded();
expect(findUnavailableMessage().exists()).toBe(exists);
});
});
});
describe('training item', () => {
it('displays GlSkeletonLoader when loading', async () => {
await delayTrainingResponse();
createComponent();
await waitForQueryToBeLoaded();
describe('training item', () => {
it('displays GlSkeletonLoader when loading', async () => {
await delayTrainingResponse();
createComponent();
await waitForQueryToBeLoaded();
expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true);
});
it('displays training item information', async () => {
await mockTrainingSuccess();
createComponent();
await waitForQueryToBeLoaded();
expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true);
expect(findTrainingItemName().exists()).toBe(true);
expect(findTrainingItemLink().attributes('href')).toBe(mockSuccessTrainingUrl);
expect(findTrainingItemLinkIcon().attributes('name')).toBe('external-link');
});
it('does not display training item information for non supported identifier', async () => {
await mockTrainingSuccess();
createComponent({ identifiers: [{ externalType: 'not supported identifier' }] });
await waitForQueryToBeLoaded();
expect(findTrainingItemName().exists()).toBe(false);
expect(findTrainingItemLink().exists()).toBe(false);
expect(findTrainingItemLinkIcon().exists()).toBe(false);
});
});
});
it('displays training item information', async () => {
await mockTrainingSuccess();
describe('with the query resulting in an error', () => {
beforeEach(() => {
jest.spyOn(Sentry, 'captureException');
createApolloProvider({ queryHandler: jest.fn().mockResolvedValue(new Error()) });
createComponent();
await waitForQueryToBeLoaded();
expect(findTrainingItemName().exists()).toBe(true);
expect(findTrainingItemLink().attributes('href')).toBe(mockSuccessTrainingUrl);
expect(findTrainingItemLinkIcon().attributes('name')).toBe('external-link');
});
it('does not display training item information for non supported identifier', async () => {
await mockTrainingSuccess();
createComponent({ identifiers: [{ externalType: 'not supported identifier' }] });
it('reports the error to sentry', async () => {
expect(Sentry.captureException).not.toHaveBeenCalled();
await waitForQueryToBeLoaded();
expect(findTrainingItemName().exists()).toBe(false);
expect(findTrainingItemLink().exists()).toBe(false);
expect(findTrainingItemLinkIcon().exists()).toBe(false);
expect(Sentry.captureException).toHaveBeenCalled();
});
});
......
......@@ -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 }) => {
......
......@@ -4,11 +4,12 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
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 waitForPromises from 'helpers/wait_for_promises';
import {
securityTrainingProviders,
createMockResolvers,
securityTrainingProvidersResponse,
testProjectPath,
textProviderIds,
} from '../mock_data';
......@@ -19,14 +20,19 @@ describe('TrainingProviderList component', () => {
let wrapper;
let apolloProvider;
const createApolloProvider = ({ resolvers } = {}) => {
apolloProvider = createMockApollo([], createMockResolvers({ resolvers }));
const createApolloProvider = ({ resolvers, queryHandler } = {}) => {
const defaultQueryHandler = jest.fn().mockResolvedValue(securityTrainingProvidersResponse);
apolloProvider = createMockApollo(
[[securityTrainingProvidersQuery, queryHandler || defaultQueryHandler]],
resolvers,
);
};
const createComponent = () => {
wrapper = shallowMount(TrainingProviderList, {
provide: {
projectPath: testProjectPath,
projectFullPath: testProjectPath,
},
apolloProvider,
});
......@@ -49,20 +55,29 @@ describe('TrainingProviderList component', () => {
apolloProvider = null;
});
describe('with a successful response', () => {
describe('when loading', () => {
beforeEach(() => {
createApolloProvider();
const pendingHandler = () => new Promise(() => {});
createApolloProvider({
queryHandler: 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();
createComponent();
});
describe('basic structure', () => {
......@@ -142,11 +157,7 @@ describe('TrainingProviderList component', () => {
describe('when fetching training providers', () => {
beforeEach(async () => {
createApolloProvider({
resolvers: {
Query: {
securityTrainingProviders: jest.fn().mockReturnValue(new Error()),
},
},
queryHandler: jest.fn().mockReturnValue(new Error()),
});
createComponent();
......
......@@ -21,19 +21,9 @@ export const securityTrainingProviders = [
export const securityTrainingProvidersResponse = {
data: {
securityTrainingProviders,
},
};
const defaultMockResolvers = {
Query: {
securityTrainingProviders() {
return securityTrainingProviders;
project: {
id: 1,
securityTrainingProviders,
},
},
};
export const createMockResolvers = ({ resolvers: customMockResolvers = {} } = {}) => ({
...defaultMockResolvers,
...customMockResolvers,
});
......@@ -16,7 +16,7 @@ jest.mock('~/lib/utils/url_utility');
Vue.use(VueApollo);
const projectPath = 'namespace/project';
const projectFullPath = 'namespace/project';
describe('ManageViaMr component', () => {
let wrapper;
......@@ -40,7 +40,7 @@ describe('ManageViaMr component', () => {
wrapper = extendedWrapper(
mount(ManageViaMr, {
provide: {
projectPath,
projectFullPath,
},
propsData: {
feature: {
......@@ -65,7 +65,7 @@ describe('ManageViaMr component', () => {
// the ones available in the current test context.
const supportedReportTypes = Object.entries(featureToMutationMap).map(
([featureType, { getMutationPayload, mutationId }]) => {
const { mutation, variables: mutationVariables } = getMutationPayload(projectPath);
const { mutation, variables: mutationVariables } = getMutationPayload(projectFullPath);
return [humanize(featureType), featureType, mutation, mutationId, mutationVariables];
},
);
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment