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>
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 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 = {
trainingTitle: s__('Vulnerability|Training'),
......@@ -10,10 +12,22 @@ export const i18n = {
'Vulnerability|Learn more about this vulnerability and the best way to resolve it.',
),
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 {
i18n,
components: {
GlLink,
GlIcon,
},
mixins: [glFeatureFlagsMixin()],
props: {
identifiers: {
......@@ -29,36 +43,85 @@ export default {
data() {
return {
securityTrainingProviders: [],
training: null,
isLoading: true,
hasError: false,
};
},
computed: {
hasTraining() {
showVulnerabilityTraining() {
return (
this.glFeatures.secureVulnerabilityTraining &&
this.securityTrainingProviders?.length &&
this.identifiers?.length
);
},
isSupportedReferenceSchema() {
return this.referenceSchemas?.some(
(referenceSchema) => referenceSchema?.toLowerCase() === SUPPORTED_REFERENCE_SCHEMA.cwe,
supportedIdentifier() {
return this.identifiers?.find(
({ externalType }) => externalType?.toLowerCase() === SUPPORTED_IDENTIFIER_TYPES.cwe,
);
},
referenceSchemas() {
return this.identifiers?.map((identifier) => identifier?.externalType);
showTrainingNotFound() {
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>
<template>
<div v-if="hasTraining">
<div v-if="showVulnerabilityTraining">
<h3>{{ $options.i18n.trainingTitle }}</h3>
<p class="gl-text-gray-600!" data-testid="description">
{{ $options.i18n.trainingDescription }}
</p>
<p v-if="!isSupportedReferenceSchema" data-testid="unavailable-message">
<p v-if="showTrainingNotFound" data-testid="unavailable-message">
{{ $options.i18n.trainingUnavailable }}
</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>
</template>
......@@ -86,6 +86,6 @@ export const SUPPORTING_MESSAGE_TYPES = {
RECORDED: 'Recorded',
};
export const SUPPORTED_REFERENCE_SCHEMA = {
export const SUPPORTED_IDENTIFIER_TYPES = {
cwe: 'cwe',
};
import { GlLink } from '@gitlab/ui';
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 VulnerabilityDetails from 'ee/vulnerabilities/components/vulnerability_details.vue';
import { SUPPORTING_MESSAGE_TYPES } from 'ee/vulnerabilities/constants';
......@@ -18,14 +18,15 @@ describe('Vulnerability Details', () => {
identifiers: [],
};
const createWrapper = (vulnerabilityOverrides) => {
const createWrapper = (vulnerabilityOverrides, { mountFn = mount } = {}) => {
const propsData = {
vulnerability: { ...vulnerability, ...vulnerabilityOverrides },
};
wrapper = mount(VulnerabilityDetails, {
wrapper = mountFn(VulnerabilityDetails, {
propsData,
});
};
const createShallowWrapper = (...args) => createWrapper(...args, { mountFn: shallowMount });
const getById = (id) => wrapper.find(`[data-testid="${id}"]`);
const getAllById = (id) => wrapper.findAll(`[data-testid="${id}"]`);
......@@ -195,7 +196,7 @@ describe('Vulnerability Details', () => {
it('renders the vulnerabilityTraining component', () => {
const identifiers = [{ externalType: 'cwe' }, { externalType: 'cve' }];
createWrapper({ identifiers });
createShallowWrapper({ identifiers });
expect(wrapper.findComponent(VulnerabilityTraining).props()).toMatchObject({
identifiers,
});
......
import Vue from 'vue';
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, {
i18n,
mockProvider,
} from 'ee/vulnerabilities/components/vulnerability_training.vue';
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 waitForPromises from 'helpers/wait_for_promises';
import { createMockResolvers } from 'jest/security_configuration/mock_data';
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);
describe('VulnerabilityTraining component', () => {
let wrapper;
let apolloProvider;
let mock;
const createApolloProvider = ({ resolvers } = {}) => {
apolloProvider = createMockApollo([], createMockResolvers({ resolvers }));
......@@ -39,30 +48,35 @@ describe('VulnerabilityTraining component', () => {
};
beforeEach(async () => {
mock = new MockAdapter(axios);
createApolloProvider();
});
afterEach(() => {
wrapper.destroy();
apolloProvider = null;
mock.restore();
});
const mockTrainingSuccess = async () =>
mock.onGet(mockProvider.path).reply(httpStatus.OK, { url: mockSuccessTrainingUrl });
const waitForQueryToBeLoaded = () => waitForPromises();
const findTitle = () => wrapper.findByRole('heading', i18n.trainingTitle);
const findDescription = () => wrapper.findByTestId('description');
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', () => {
beforeEach(() => {
createComponent();
});
it('displays the title', async () => {
createComponent();
await waitForQueryToBeLoaded();
expect(findTitle().text()).toBe(i18n.trainingTitle);
});
it('displays the description', async () => {
createComponent();
await waitForQueryToBeLoaded();
expect(findDescription().text()).toBe(i18n.trainingDescription);
});
......@@ -73,6 +87,7 @@ describe('VulnerabilityTraining component', () => {
});
it('does not render component when there are no securityTrainingProviders', () => {
createComponent();
expect(wrapper.html()).toBeFalsy();
});
});
......@@ -89,15 +104,38 @@ describe('VulnerabilityTraining component', () => {
it.each`
identifier | exists
${'not supported identifier'} | ${true}
${SUPPORTED_REFERENCE_SCHEMA.cwe.toUpperCase()} | ${false}
${SUPPORTED_REFERENCE_SCHEMA.cwe.toLowerCase()} | ${false}
${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 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', () => {
it('does not render the VulnerabilityTraining component', () => {
createComponent({}, { secureVulnerabilityTraining: false });
......
......@@ -39848,6 +39848,9 @@ msgstr ""
msgid "Vulnerability|Scanner Provider"
msgstr ""
msgid "Vulnerability|Secure Code Warrior"
msgstr ""
msgid "Vulnerability|Security Audit"
msgstr ""
......@@ -39881,6 +39884,9 @@ msgstr ""
msgid "Vulnerability|Unmodified Response"
msgstr ""
msgid "Vulnerability|View training"
msgstr ""
msgid "WARNING:"
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