Commit 966448af authored by Phil Hughes's avatar Phil Hughes

Merge branch '349669-training-item-ui' into 'master'

Add training item UI in vulnerability details page

See merge request gitlab-org/gitlab!78503
parents d627ce1d 93503eff
<script> <script>
import { s__ } from '~/locale'; import { GlLink, GlIcon } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import securityTrainingProvidersQuery from '~/security_configuration/graphql/security_training_providers.query.graphql'; import securityTrainingProvidersQuery from '~/security_configuration/graphql/security_training_providers.query.graphql';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { SUPPORTED_REFERENCE_SCHEMA } from '../constants'; import axios from '~/lib/utils/axios_utils';
import { SUPPORTED_IDENTIFIER_TYPES } from '../constants';
export const i18n = { export const i18n = {
trainingTitle: s__('Vulnerability|Training'), trainingTitle: s__('Vulnerability|Training'),
...@@ -10,10 +12,22 @@ export const i18n = { ...@@ -10,10 +12,22 @@ export const i18n = {
'Vulnerability|Learn more about this vulnerability and the best way to resolve it.', 'Vulnerability|Learn more about this vulnerability and the best way to resolve it.',
), ),
trainingUnavailable: s__('Vulnerability|Training not available for this vulnerability.'), trainingUnavailable: s__('Vulnerability|Training not available for this vulnerability.'),
viewTraining: s__('Vulnerability|View training'),
loading: __('Loading'),
};
export const mockProvider = {
path: 'https://integration-api.securecodewarrior.com/api/v1/trial',
id: 'gitlab',
name: s__('Vulnerability|Secure Code Warrior'),
}; };
export default { export default {
i18n, i18n,
components: {
GlLink,
GlIcon,
},
mixins: [glFeatureFlagsMixin()], mixins: [glFeatureFlagsMixin()],
props: { props: {
identifiers: { identifiers: {
...@@ -29,36 +43,85 @@ export default { ...@@ -29,36 +43,85 @@ export default {
data() { data() {
return { return {
securityTrainingProviders: [], securityTrainingProviders: [],
training: null,
isLoading: true,
hasError: false,
}; };
}, },
computed: { computed: {
hasTraining() { showVulnerabilityTraining() {
return ( return (
this.glFeatures.secureVulnerabilityTraining && this.glFeatures.secureVulnerabilityTraining &&
this.securityTrainingProviders?.length && this.securityTrainingProviders?.length &&
this.identifiers?.length this.identifiers?.length
); );
}, },
isSupportedReferenceSchema() { supportedIdentifier() {
return this.referenceSchemas?.some( return this.identifiers?.find(
(referenceSchema) => referenceSchema?.toLowerCase() === SUPPORTED_REFERENCE_SCHEMA.cwe, ({ externalType }) => externalType?.toLowerCase() === SUPPORTED_IDENTIFIER_TYPES.cwe,
); );
}, },
referenceSchemas() { showTrainingNotFound() {
return this.identifiers?.map((identifier) => identifier?.externalType); return !this.supportedIdentifier || this.hasError;
},
},
watch: {
supportedIdentifier: {
immediate: true,
handler(supportedIdentifier) {
if (supportedIdentifier) {
const { externalType, externalId } = supportedIdentifier;
this.fetchTraining(externalType, externalId);
} else {
this.isLoading = false;
}
},
},
},
methods: {
async fetchTraining(mappingList, mappingKey) {
const { path, id, name } = mockProvider;
const params = {
id,
mappingList,
mappingKey,
};
try {
const {
data: { url },
} = await axios.get(path, { params });
this.training = { name, url };
} catch {
this.hasError = true;
} finally {
this.isLoading = false;
}
}, },
}, },
}; };
</script> </script>
<template> <template>
<div v-if="hasTraining"> <div v-if="showVulnerabilityTraining">
<h3>{{ $options.i18n.trainingTitle }}</h3> <h3>{{ $options.i18n.trainingTitle }}</h3>
<p class="gl-text-gray-600!" data-testid="description"> <p class="gl-text-gray-600!" data-testid="description">
{{ $options.i18n.trainingDescription }} {{ $options.i18n.trainingDescription }}
</p> </p>
<p v-if="!isSupportedReferenceSchema" data-testid="unavailable-message"> <p v-if="showTrainingNotFound" data-testid="unavailable-message">
{{ $options.i18n.trainingUnavailable }} {{ $options.i18n.trainingUnavailable }}
</p> </p>
<div v-else-if="isLoading">
<!-- Loading skeleton will be added in a follow up issue
https://gitlab.com/gitlab-org/gitlab/-/issues/349670 -->
{{ $options.i18n.loading }}
</div>
<div v-else>
<div class="gl-font-weight-bold gl-font-base">{{ training.name }}</div>
<gl-link :href="training.url" target="_blank">
{{ $options.i18n.viewTraining }}
<gl-icon class="gl-ml-2" name="external-link" :size="12" />
</gl-link>
</div>
</div> </div>
</template> </template>
...@@ -86,6 +86,6 @@ export const SUPPORTING_MESSAGE_TYPES = { ...@@ -86,6 +86,6 @@ export const SUPPORTING_MESSAGE_TYPES = {
RECORDED: 'Recorded', RECORDED: 'Recorded',
}; };
export const SUPPORTED_REFERENCE_SCHEMA = { export const SUPPORTED_IDENTIFIER_TYPES = {
cwe: 'cwe', cwe: 'cwe',
}; };
import { GlLink } from '@gitlab/ui'; import { GlLink } from '@gitlab/ui';
import { getAllByRole, getByTestId } from '@testing-library/dom'; import { getAllByRole, getByTestId } from '@testing-library/dom';
import { mount } from '@vue/test-utils'; import { mount, shallowMount } from '@vue/test-utils';
import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue'; import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue';
import VulnerabilityDetails from 'ee/vulnerabilities/components/vulnerability_details.vue'; import VulnerabilityDetails from 'ee/vulnerabilities/components/vulnerability_details.vue';
import { SUPPORTING_MESSAGE_TYPES } from 'ee/vulnerabilities/constants'; import { SUPPORTING_MESSAGE_TYPES } from 'ee/vulnerabilities/constants';
...@@ -18,14 +18,15 @@ describe('Vulnerability Details', () => { ...@@ -18,14 +18,15 @@ describe('Vulnerability Details', () => {
identifiers: [], identifiers: [],
}; };
const createWrapper = (vulnerabilityOverrides) => { const createWrapper = (vulnerabilityOverrides, { mountFn = mount } = {}) => {
const propsData = { const propsData = {
vulnerability: { ...vulnerability, ...vulnerabilityOverrides }, vulnerability: { ...vulnerability, ...vulnerabilityOverrides },
}; };
wrapper = mount(VulnerabilityDetails, { wrapper = mountFn(VulnerabilityDetails, {
propsData, propsData,
}); });
}; };
const createShallowWrapper = (...args) => createWrapper(...args, { mountFn: shallowMount });
const getById = (id) => wrapper.find(`[data-testid="${id}"]`); const getById = (id) => wrapper.find(`[data-testid="${id}"]`);
const getAllById = (id) => wrapper.findAll(`[data-testid="${id}"]`); const getAllById = (id) => wrapper.findAll(`[data-testid="${id}"]`);
...@@ -195,7 +196,7 @@ describe('Vulnerability Details', () => { ...@@ -195,7 +196,7 @@ describe('Vulnerability Details', () => {
it('renders the vulnerabilityTraining component', () => { it('renders the vulnerabilityTraining component', () => {
const identifiers = [{ externalType: 'cwe' }, { externalType: 'cve' }]; const identifiers = [{ externalType: 'cwe' }, { externalType: 'cve' }];
createWrapper({ identifiers }); createShallowWrapper({ identifiers });
expect(wrapper.findComponent(VulnerabilityTraining).props()).toMatchObject({ expect(wrapper.findComponent(VulnerabilityTraining).props()).toMatchObject({
identifiers, identifiers,
}); });
......
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import MockAdapter from 'axios-mock-adapter';
import { GlLink, GlIcon } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status';
import VulnerabilityTraining, { import VulnerabilityTraining, {
i18n, i18n,
mockProvider,
} from 'ee/vulnerabilities/components/vulnerability_training.vue'; } from 'ee/vulnerabilities/components/vulnerability_training.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { SUPPORTED_REFERENCE_SCHEMA } from 'ee/vulnerabilities/constants'; import { SUPPORTED_IDENTIFIER_TYPES } from 'ee/vulnerabilities/constants';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import { createMockResolvers } from 'jest/security_configuration/mock_data'; import { createMockResolvers } from 'jest/security_configuration/mock_data';
const defaultProps = { const defaultProps = {
identifiers: [{ externalType: SUPPORTED_REFERENCE_SCHEMA.cwe }, { externalType: 'cve' }], identifiers: [{ externalType: SUPPORTED_IDENTIFIER_TYPES.cwe }, { externalType: 'cve' }],
}; };
const mockSuccessTrainingUrl = 'training/path';
Vue.use(VueApollo); Vue.use(VueApollo);
describe('VulnerabilityTraining component', () => { describe('VulnerabilityTraining component', () => {
let wrapper; let wrapper;
let apolloProvider; let apolloProvider;
let mock;
const createApolloProvider = ({ resolvers } = {}) => { const createApolloProvider = ({ resolvers } = {}) => {
apolloProvider = createMockApollo([], createMockResolvers({ resolvers })); apolloProvider = createMockApollo([], createMockResolvers({ resolvers }));
...@@ -39,30 +48,35 @@ describe('VulnerabilityTraining component', () => { ...@@ -39,30 +48,35 @@ describe('VulnerabilityTraining component', () => {
}; };
beforeEach(async () => { beforeEach(async () => {
mock = new MockAdapter(axios);
createApolloProvider(); createApolloProvider();
}); });
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
apolloProvider = null; apolloProvider = null;
mock.restore();
}); });
const mockTrainingSuccess = async () =>
mock.onGet(mockProvider.path).reply(httpStatus.OK, { url: mockSuccessTrainingUrl });
const waitForQueryToBeLoaded = () => waitForPromises(); const waitForQueryToBeLoaded = () => waitForPromises();
const findTitle = () => wrapper.findByRole('heading', i18n.trainingTitle); const findTitle = () => wrapper.findByRole('heading', i18n.trainingTitle);
const findDescription = () => wrapper.findByTestId('description'); const findDescription = () => wrapper.findByTestId('description');
const findUnavailableMessage = () => wrapper.findByTestId('unavailable-message'); const findUnavailableMessage = () => wrapper.findByTestId('unavailable-message');
const findTrainingItemName = () => wrapper.findByText(mockProvider.name);
const findTrainingItemLink = () => wrapper.findComponent(GlLink);
const findTrainingItemLinkIcon = () => wrapper.findComponent(GlIcon);
describe('basic structure', () => { describe('basic structure', () => {
beforeEach(() => {
createComponent();
});
it('displays the title', async () => { it('displays the title', async () => {
createComponent();
await waitForQueryToBeLoaded(); await waitForQueryToBeLoaded();
expect(findTitle().text()).toBe(i18n.trainingTitle); expect(findTitle().text()).toBe(i18n.trainingTitle);
}); });
it('displays the description', async () => { it('displays the description', async () => {
createComponent();
await waitForQueryToBeLoaded(); await waitForQueryToBeLoaded();
expect(findDescription().text()).toBe(i18n.trainingDescription); expect(findDescription().text()).toBe(i18n.trainingDescription);
}); });
...@@ -73,6 +87,7 @@ describe('VulnerabilityTraining component', () => { ...@@ -73,6 +87,7 @@ describe('VulnerabilityTraining component', () => {
}); });
it('does not render component when there are no securityTrainingProviders', () => { it('does not render component when there are no securityTrainingProviders', () => {
createComponent();
expect(wrapper.html()).toBeFalsy(); expect(wrapper.html()).toBeFalsy();
}); });
}); });
...@@ -89,15 +104,38 @@ describe('VulnerabilityTraining component', () => { ...@@ -89,15 +104,38 @@ describe('VulnerabilityTraining component', () => {
it.each` it.each`
identifier | exists identifier | exists
${'not supported identifier'} | ${true} ${'not supported identifier'} | ${true}
${SUPPORTED_REFERENCE_SCHEMA.cwe.toUpperCase()} | ${false} ${SUPPORTED_IDENTIFIER_TYPES.cwe.toUpperCase()} | ${false}
${SUPPORTED_REFERENCE_SCHEMA.cwe.toLowerCase()} | ${false} ${SUPPORTED_IDENTIFIER_TYPES.cwe.toLowerCase()} | ${false}
`('sets it to "$exists" for "$identifier"', async ({ identifier, exists }) => { `('sets it to "$exists" for "$identifier"', async ({ identifier, exists }) => {
await mockTrainingSuccess();
createComponent({ identifiers: [{ externalType: identifier }] }); createComponent({ identifiers: [{ externalType: identifier }] });
await waitForQueryToBeLoaded(); await waitForQueryToBeLoaded();
expect(findUnavailableMessage().exists()).toBe(exists); expect(findUnavailableMessage().exists()).toBe(exists);
}); });
}); });
describe('training item', () => {
it('displays training item information', async () => {
await mockTrainingSuccess();
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' }] });
await waitForQueryToBeLoaded();
expect(findTrainingItemName().exists()).toBe(false);
expect(findTrainingItemLink().exists()).toBe(false);
expect(findTrainingItemLinkIcon().exists()).toBe(false);
});
});
describe('when secureVulnerabilityTraining feature flag is disabled', () => { describe('when secureVulnerabilityTraining feature flag is disabled', () => {
it('does not render the VulnerabilityTraining component', () => { it('does not render the VulnerabilityTraining component', () => {
createComponent({}, { secureVulnerabilityTraining: false }); createComponent({}, { secureVulnerabilityTraining: false });
......
...@@ -39848,6 +39848,9 @@ msgstr "" ...@@ -39848,6 +39848,9 @@ msgstr ""
msgid "Vulnerability|Scanner Provider" msgid "Vulnerability|Scanner Provider"
msgstr "" msgstr ""
msgid "Vulnerability|Secure Code Warrior"
msgstr ""
msgid "Vulnerability|Security Audit" msgid "Vulnerability|Security Audit"
msgstr "" msgstr ""
...@@ -39881,6 +39884,9 @@ msgstr "" ...@@ -39881,6 +39884,9 @@ msgstr ""
msgid "Vulnerability|Unmodified Response" msgid "Vulnerability|Unmodified Response"
msgstr "" msgstr ""
msgid "Vulnerability|View training"
msgstr ""
msgid "WARNING:" msgid "WARNING:"
msgstr "" msgstr ""
......
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