Commit 4efe88d4 authored by Dave Pisek's avatar Dave Pisek

Use GraphQL endpoint for sec training mutation

This change moves from a client-side mutation to a server-side mutation
for the security training mutation.

It also does a slight refactor due to a small GraphQL schema difference
between the client-side resolver and the backend.
parent c2440e5e
...@@ -49,7 +49,7 @@ export default { ...@@ -49,7 +49,7 @@ export default {
data() { data() {
return { return {
errorMessage: '', errorMessage: '',
toggleLoading: false, providerLoadingId: null,
securityTrainingProviders: [], securityTrainingProviders: [],
hasTouchedConfiguration: false, hasTouchedConfiguration: false,
}; };
...@@ -89,37 +89,29 @@ export default { ...@@ -89,37 +89,29 @@ export default {
Sentry.captureException(e); Sentry.captureException(e);
} }
}, },
toggleProvider(selectedProviderId) { toggleProvider(provider) {
const toggledProviders = this.securityTrainingProviders.map((provider) => ({ const { isEnabled } = provider;
...provider, const toggledIsEnabled = !isEnabled;
...(provider.id === selectedProviderId && { isEnabled: !provider.isEnabled }),
}));
const enabledProviderIds = toggledProviders this.trackProviderToggle(provider.id, toggledIsEnabled);
.filter(({ isEnabled }) => isEnabled) this.storeProvider({ ...provider, isEnabled: toggledIsEnabled });
.map(({ id }) => id);
const { isEnabled: selectedProviderIsEnabled } = toggledProviders.find(
(provider) => provider.id === selectedProviderId,
);
this.trackProviderToggle(selectedProviderId, selectedProviderIsEnabled);
this.storeEnabledProviders(enabledProviderIds);
}, },
async storeEnabledProviders(enabledProviderIds) { async storeProvider({ id, isEnabled, isPrimary }) {
this.toggleLoading = true; this.providerLoadingId = id;
try { try {
const { const {
data: { data: {
configureSecurityTrainingProviders: { errors = [] }, securityTrainingUpdate: { errors = [] },
}, },
} = await this.$apollo.mutate({ } = await this.$apollo.mutate({
mutation: configureSecurityTrainingProvidersMutation, mutation: configureSecurityTrainingProvidersMutation,
variables: { variables: {
input: { input: {
enabledProviders: enabledProviderIds, projectPath: this.projectFullPath,
fullPath: this.projectFullPath, providerId: id,
isEnabled,
isPrimary,
}, },
}, },
}); });
...@@ -133,7 +125,7 @@ export default { ...@@ -133,7 +125,7 @@ export default {
} catch { } catch {
this.errorMessage = this.$options.i18n.configMutationErrorMessage; this.errorMessage = this.$options.i18n.configMutationErrorMessage;
} finally { } finally {
this.toggleLoading = false; this.providerLoadingId = null;
} }
}, },
trackProviderToggle(providerId, providerIsEnabled) { trackProviderToggle(providerId, providerIsEnabled) {
...@@ -166,25 +158,21 @@ export default { ...@@ -166,25 +158,21 @@ export default {
</gl-skeleton-loader> </gl-skeleton-loader>
</div> </div>
<ul v-else class="gl-list-style-none gl-m-0 gl-p-0"> <ul v-else class="gl-list-style-none gl-m-0 gl-p-0">
<li <li v-for="provider in securityTrainingProviders" :key="provider.id" class="gl-mb-6">
v-for="{ id, isEnabled, name, description, url } in securityTrainingProviders"
:key="id"
class="gl-mb-6"
>
<gl-card> <gl-card>
<div class="gl-display-flex"> <div class="gl-display-flex">
<gl-toggle <gl-toggle
:value="isEnabled" :value="provider.isEnabled"
:label="__('Training mode')" :label="__('Training mode')"
label-position="hidden" label-position="hidden"
:is-loading="toggleLoading" :is-loading="providerLoadingId === provider.id"
@change="toggleProvider(id)" @change="toggleProvider(provider)"
/> />
<div class="gl-ml-5"> <div class="gl-ml-5">
<h3 class="gl-font-lg gl-m-0 gl-mb-2">{{ name }}</h3> <h3 class="gl-font-lg gl-m-0 gl-mb-2">{{ provider.name }}</h3>
<p> <p>
{{ description }} {{ provider.description }}
<gl-link :href="url" target="_blank">{{ __('Learn more.') }}</gl-link> <gl-link :href="provider.url" target="_blank">{{ __('Learn more.') }}</gl-link>
</p> </p>
</div> </div>
</div> </div>
......
mutation configureSecurityTrainingProviders($input: configureSecurityTrainingProvidersInput!) { mutation updateSecurityTraining($input: SecurityTrainingUpdateInput!) {
configureSecurityTrainingProviders(input: $input) @client { securityTrainingUpdate(input: $input) {
errors errors
securityTrainingProviders { training {
id id
isEnabled isEnabled
isPrimary
} }
} }
} }
...@@ -5,6 +5,7 @@ query getSecurityTrainingProviders($fullPath: ID!) { ...@@ -5,6 +5,7 @@ query getSecurityTrainingProviders($fullPath: ID!) {
name name
id id
description description
isPrimary
isEnabled isEnabled
url url
} }
......
...@@ -5,7 +5,6 @@ import { parseBooleanDataAttributes } from '~/lib/utils/dom_utils'; ...@@ -5,7 +5,6 @@ import { parseBooleanDataAttributes } from '~/lib/utils/dom_utils';
import SecurityConfigurationApp from './components/app.vue'; import SecurityConfigurationApp from './components/app.vue';
import { securityFeatures, complianceFeatures } from './components/constants'; import { securityFeatures, complianceFeatures } from './components/constants';
import { augmentFeatures } from './utils'; import { augmentFeatures } from './utils';
import tempResolvers from './resolver';
export const initSecurityConfiguration = (el) => { export const initSecurityConfiguration = (el) => {
if (!el) { if (!el) {
...@@ -15,7 +14,7 @@ export const initSecurityConfiguration = (el) => { ...@@ -15,7 +14,7 @@ export const initSecurityConfiguration = (el) => {
Vue.use(VueApollo); Vue.use(VueApollo);
const apolloProvider = new VueApollo({ const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(tempResolvers), defaultClient: createDefaultClient(),
}); });
const { const {
......
import produce from 'immer';
import { __ } from '~/locale';
import securityTrainingProvidersQuery from './graphql/security_training_providers.query.graphql';
// Note: this is behind a feature flag and only a placeholder
// until the actual GraphQL fields have been added
// https://gitlab.com/gitlab-org/gi tlab/-/issues/346480
export default {
Query: {
securityTrainingProviders() {
return [
{
__typename: 'SecurityTrainingProvider',
id: 101,
name: __('Kontra'),
description: __('Interactive developer security education.'),
url: 'https://application.security/',
isEnabled: false,
},
{
__typename: 'SecurityTrainingProvider',
id: 102,
name: __('SecureCodeWarrior'),
description: __('Security training with guide and learning pathways.'),
url: 'https://www.securecodewarrior.com/',
isEnabled: true,
},
];
},
},
Mutation: {
configureSecurityTrainingProviders: (
_,
{ input: { enabledProviders, primaryProvider, fullPath } },
{ cache },
) => {
const sourceData = cache.readQuery({
query: securityTrainingProvidersQuery,
variables: {
fullPath,
},
});
const data = produce(sourceData.project, (draftData) => {
/* eslint-disable no-param-reassign */
draftData.securityTrainingProviders.forEach((provider) => {
provider.isPrimary = provider.id === primaryProvider;
provider.isEnabled =
provider.id === primaryProvider || enabledProviders.includes(provider.id);
});
});
return {
__typename: 'configureSecurityTrainingProvidersPayload',
securityTrainingProviders: data.securityTrainingProviders,
};
},
},
};
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import tempResolvers from '~/security_configuration/resolver';
Vue.use(VueApollo); Vue.use(VueApollo);
const defaultClient = createDefaultClient({ const defaultClient = createDefaultClient();
...tempResolvers,
});
export default new VueApollo({ export default new VueApollo({
defaultClient, defaultClient,
......
...@@ -19717,9 +19717,6 @@ msgstr "" ...@@ -19717,9 +19717,6 @@ msgstr ""
msgid "Integrations|can't exceed %{recipients_limit}" msgid "Integrations|can't exceed %{recipients_limit}"
msgstr "" msgstr ""
msgid "Interactive developer security education."
msgstr ""
msgid "Interactive mode" msgid "Interactive mode"
msgstr "" msgstr ""
...@@ -21058,9 +21055,6 @@ msgstr "" ...@@ -21058,9 +21055,6 @@ msgstr ""
msgid "Ki" msgid "Ki"
msgstr "" msgstr ""
msgid "Kontra"
msgstr ""
msgid "Kroki" msgid "Kroki"
msgstr "" msgstr ""
...@@ -32018,9 +32012,6 @@ msgstr "" ...@@ -32018,9 +32012,6 @@ msgstr ""
msgid "Secure token that identifies an external storage request." msgid "Secure token that identifies an external storage request."
msgstr "" msgstr ""
msgid "SecureCodeWarrior"
msgstr ""
msgid "Security" msgid "Security"
msgstr "" msgstr ""
...@@ -32045,9 +32036,6 @@ msgstr "" ...@@ -32045,9 +32036,6 @@ msgstr ""
msgid "Security report is out of date. Run %{newPipelineLinkStart}a new pipeline%{newPipelineLinkEnd} for the target branch (%{targetBranchName})" msgid "Security report is out of date. Run %{newPipelineLinkStart}a new pipeline%{newPipelineLinkEnd} for the target branch (%{targetBranchName})"
msgstr "" msgstr ""
msgid "Security training with guide and learning pathways."
msgstr ""
msgid "SecurityApprovals|A merge request approval is required when a security report contains a new vulnerability." msgid "SecurityApprovals|A merge request approval is required when a security report contains a new vulnerability."
msgstr "" msgstr ""
......
...@@ -19,6 +19,8 @@ import { ...@@ -19,6 +19,8 @@ import {
dismissUserCalloutErrorResponse, dismissUserCalloutErrorResponse,
securityTrainingProviders, securityTrainingProviders,
securityTrainingProvidersResponse, securityTrainingProvidersResponse,
updateSecurityTrainingProvidersResponse,
updateSecurityTrainingProvidersErrorResponse,
testProjectPath, testProjectPath,
textProviderIds, textProviderIds,
} from '../mock_data'; } from '../mock_data';
...@@ -29,18 +31,22 @@ describe('TrainingProviderList component', () => { ...@@ -29,18 +31,22 @@ describe('TrainingProviderList component', () => {
let wrapper; let wrapper;
let apolloProvider; let apolloProvider;
const createApolloProvider = ({ resolvers, handlers = [] } = {}) => { const createApolloProvider = ({ handlers = [] } = {}) => {
const defaultHandlers = [ const defaultHandlers = [
[ [
securityTrainingProvidersQuery, securityTrainingProvidersQuery,
jest.fn().mockResolvedValue(securityTrainingProvidersResponse), 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 // make sure we don't have any duplicate handlers to avoid 'Request handler already defined for query` errors
const mergedHandlers = [...new Map([...defaultHandlers, ...handlers])]; const mergedHandlers = [...new Map([...defaultHandlers, ...handlers])];
apolloProvider = createMockApollo(mergedHandlers, resolvers); apolloProvider = createMockApollo(mergedHandlers);
}; };
const createComponent = () => { const createComponent = () => {
...@@ -62,7 +68,7 @@ describe('TrainingProviderList component', () => { ...@@ -62,7 +68,7 @@ describe('TrainingProviderList component', () => {
const findLoader = () => wrapper.findComponent(GlSkeletonLoader); const findLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findErrorAlert = () => wrapper.findComponent(GlAlert); const findErrorAlert = () => wrapper.findComponent(GlAlert);
const toggleFirstProvider = () => findFirstToggle().vm.$emit('change'); const toggleFirstProvider = () => findFirstToggle().vm.$emit('change', textProviderIds[0]);
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
...@@ -146,9 +152,9 @@ describe('TrainingProviderList component', () => { ...@@ -146,9 +152,9 @@ describe('TrainingProviderList component', () => {
beforeEach(async () => { beforeEach(async () => {
jest.spyOn(apolloProvider.defaultClient, 'mutate'); jest.spyOn(apolloProvider.defaultClient, 'mutate');
await waitForMutationToBeLoaded(); await waitForQueryToBeLoaded();
toggleFirstProvider(); await toggleFirstProvider();
}); });
it.each` it.each`
...@@ -166,7 +172,14 @@ describe('TrainingProviderList component', () => { ...@@ -166,7 +172,14 @@ describe('TrainingProviderList component', () => {
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith( expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
mutation: configureSecurityTrainingProvidersMutation, mutation: configureSecurityTrainingProvidersMutation,
variables: { input: { enabledProviders: textProviderIds, fullPath: testProjectPath } }, variables: {
input: {
providerId: textProviderIds[0],
isEnabled: true,
isPrimary: false,
projectPath: testProjectPath,
},
},
}), }),
); );
}); });
...@@ -264,14 +277,12 @@ describe('TrainingProviderList component', () => { ...@@ -264,14 +277,12 @@ describe('TrainingProviderList component', () => {
describe('when storing training provider configurations', () => { describe('when storing training provider configurations', () => {
beforeEach(async () => { beforeEach(async () => {
createApolloProvider({ createApolloProvider({
resolvers: { handlers: [
Mutation: { [
configureSecurityTrainingProviders: () => ({ configureSecurityTrainingProvidersMutation,
errors: ['something went wrong!'], jest.fn().mockReturnValue(updateSecurityTrainingProvidersErrorResponse),
securityTrainingProviders: [], ],
}), ],
},
},
}); });
createComponent(); createComponent();
......
...@@ -9,6 +9,7 @@ export const securityTrainingProviders = [ ...@@ -9,6 +9,7 @@ export const securityTrainingProviders = [
description: 'Interactive developer security education', description: 'Interactive developer security education',
url: 'https://www.example.org/security/training', url: 'https://www.example.org/security/training',
isEnabled: false, isEnabled: false,
isPrimary: false,
}, },
{ {
id: textProviderIds[1], id: textProviderIds[1],
...@@ -16,6 +17,7 @@ export const securityTrainingProviders = [ ...@@ -16,6 +17,7 @@ export const securityTrainingProviders = [
description: 'Security training with guide and learning pathways.', description: 'Security training with guide and learning pathways.',
url: 'https://www.vendornametwo.com/', url: 'https://www.vendornametwo.com/',
isEnabled: true, isEnabled: true,
isPrimary: false,
}, },
]; ];
...@@ -51,3 +53,26 @@ export const dismissUserCalloutErrorResponse = { ...@@ -51,3 +53,26 @@ export const dismissUserCalloutErrorResponse = {
}, },
}, },
}; };
export const updateSecurityTrainingProvidersResponse = {
data: {
securityTrainingUpdate: {
errors: [],
training: {
id: 101,
name: 'Acme',
isEnabled: true,
isPrimary: false,
},
},
},
};
export const updateSecurityTrainingProvidersErrorResponse = {
data: {
securityTrainingUpdate: {
errors: ['something went wrong!'],
training: null,
},
},
};
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