Commit d1f724cf authored by Dave Pisek's avatar Dave Pisek

Use updated sec-training query

This commit uses the updated version of the query to fetch
security training material for a given pipeline finding or
vulnerability.

It also integrates it with: the vulnerability details page, security
MR widget and security pipeline section.
parent aa3c1bef
query getSecurityTrainingVulnerability($id: ID!) { query getSecurityTrainingUrls($projectFullPath: ID!, $identifierExternalIds: [String!]!) {
vulnerability(id: $id) @client { project(fullPath: $projectFullPath) {
id id
identifiers { securityTrainingUrls(identifierExternalIds: $identifierExternalIds) {
externalType
}
securityTrainingUrls {
name name
url
status status
url
} }
} }
} }
...@@ -42,6 +42,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo ...@@ -42,6 +42,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:refactor_mr_widgets_extensions, project, default_enabled: :yaml) push_frontend_feature_flag(:refactor_mr_widgets_extensions, project, default_enabled: :yaml)
push_frontend_feature_flag(:rebase_without_ci_ui, project, default_enabled: :yaml) push_frontend_feature_flag(:rebase_without_ci_ui, project, default_enabled: :yaml)
push_frontend_feature_flag(:markdown_continue_lists, project, default_enabled: :yaml) push_frontend_feature_flag(:markdown_continue_lists, project, default_enabled: :yaml)
push_frontend_feature_flag(:secure_vulnerability_training, project, default_enabled: :yaml)
# Usage data feature flags # Usage data feature flags
push_frontend_feature_flag(:users_expanding_widgets_usage_data, project, default_enabled: :yaml) push_frontend_feature_flag(:users_expanding_widgets_usage_data, project, default_enabled: :yaml)
push_frontend_feature_flag(:diff_settings_usage_data, default_enabled: :yaml) push_frontend_feature_flag(:diff_settings_usage_data, default_enabled: :yaml)
......
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 tempResolver from './temp_resolver';
Vue.use(VueApollo); Vue.use(VueApollo);
const defaultClient = createDefaultClient({ const defaultClient = createDefaultClient();
...tempResolver,
});
export default new VueApollo({ export default new VueApollo({
defaultClient, defaultClient,
......
// Note: this is behind a feature flag and only a placeholder
// until the actual GraphQL fields have been added
// https://gitlab.com/gitlab-org/gitlab/-/issues/349910
export default {
Query: {
vulnerability() {
/* eslint-disable @gitlab/require-i18n-strings */
return {
__typename: 'Vulnerability',
id: 'id: "gid://gitlab/Vulnerability/295"',
identifiers: [{ externalType: 'cwe', __typename: 'VulnerabilityIdentifier' }],
securityTrainingUrls: [
{
__typename: 'SecurityTrainingUrls',
id: 101,
name: 'Kontra',
url: null,
status: 'COMPLETED',
},
{
__typename: 'SecurityTrainingUrls',
id: 102,
name: 'Secure Code Warrior',
url: 'https://www.securecodewarrior.com/',
status: 'COMPLETED',
},
],
};
},
},
};
...@@ -3,10 +3,17 @@ import { GlFriendlyWrap, GlLink, GlBadge, GlSafeHtmlDirective } from '@gitlab/ui ...@@ -3,10 +3,17 @@ import { GlFriendlyWrap, GlLink, GlBadge, GlSafeHtmlDirective } from '@gitlab/ui
import { REPORT_TYPES } from 'ee/security_dashboard/store/constants'; import { REPORT_TYPES } from 'ee/security_dashboard/store/constants';
import FalsePositiveAlert from 'ee/vulnerabilities/components/false_positive_alert.vue'; import FalsePositiveAlert from 'ee/vulnerabilities/components/false_positive_alert.vue';
import GenericReportSection from 'ee/vulnerabilities/components/generic_report/report_section.vue'; import GenericReportSection from 'ee/vulnerabilities/components/generic_report/report_section.vue';
import { SUPPORTING_MESSAGE_TYPES } from 'ee/vulnerabilities/constants'; import {
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; SUPPORTING_MESSAGE_TYPES,
VULNERABILITY_TRAINING_HEADING,
} from 'ee/vulnerabilities/constants';
import {
convertObjectPropsToCamelCase,
convertArrayOfObjectsToCamelCase,
} from '~/lib/utils/common_utils';
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import CodeBlock from '~/vue_shared/components/code_block.vue'; import CodeBlock from '~/vue_shared/components/code_block.vue';
import VulnerabilityTraining from 'ee/vulnerabilities/components/vulnerability_training.vue';
import getFileLocation from '../store/utils/get_file_location'; import getFileLocation from '../store/utils/get_file_location';
import { bodyWithFallBack } from './helpers'; import { bodyWithFallBack } from './helpers';
import SeverityBadge from './severity_badge.vue'; import SeverityBadge from './severity_badge.vue';
...@@ -23,11 +30,17 @@ export default { ...@@ -23,11 +30,17 @@ export default {
GlLink, GlLink,
GlBadge, GlBadge,
FalsePositiveAlert, FalsePositiveAlert,
VulnerabilityTraining,
}, },
directives: { directives: {
SafeHtml: GlSafeHtmlDirective, SafeHtml: GlSafeHtmlDirective,
}, },
props: { vulnerability: { type: Object, required: true } }, props: { vulnerability: { type: Object, required: true } },
data() {
return {
showTraining: false,
};
},
computed: { computed: {
url() { url() {
return this.vulnerability.request?.url || getFileLocation(this.vulnLocation); return this.vulnerability.request?.url || getFileLocation(this.vulnLocation);
...@@ -141,6 +154,14 @@ export default { ...@@ -141,6 +154,14 @@ export default {
hasRecordedResponse() { hasRecordedResponse() {
return Boolean(this.constructedRecordedResponse); return Boolean(this.constructedRecordedResponse);
}, },
normalizedProjectFullPath() {
const projectFullPath = this.vulnerability.project?.full_path || '';
// in some cases the project's full path contains a leading slash and we need to remove it
return projectFullPath.startsWith('/') ? projectFullPath.substr(1) : projectFullPath;
},
camelCaseFormattedIdentifiers() {
return convertArrayOfObjectsToCamelCase(this.identifiers);
},
}, },
methods: { methods: {
getHeadersAsCodeBlockLines(headers) { getHeadersAsCodeBlockLines(headers) {
...@@ -175,6 +196,12 @@ export default { ...@@ -175,6 +196,12 @@ export default {
? [`${method} ${url}\n`, headerLines, '\n\n', bodyWithFallBack(body)].join('') ? [`${method} ${url}\n`, headerLines, '\n\n', bodyWithFallBack(body)].join('')
: ''; : '';
}, },
handleShowTraining(showVulnerabilityTraining) {
this.showTraining = showVulnerabilityTraining;
},
},
i18n: {
VULNERABILITY_TRAINING_HEADING,
}, },
}; };
</script> </script>
...@@ -309,5 +336,14 @@ export default { ...@@ -309,5 +336,14 @@ export default {
class="gl-mt-4" class="gl-mt-4"
:details="vulnerability.details" :details="vulnerability.details"
/> />
<div v-if="identifiers && normalizedProjectFullPath" v-show="showTraining">
<vulnerability-detail :label="$options.i18n.VULNERABILITY_TRAINING_HEADING.title">
<vulnerability-training
:project-full-path="normalizedProjectFullPath"
:identifiers="camelCaseFormattedIdentifiers"
@show-vulnerability-training="handleShowTraining"
/>
</vulnerability-detail>
</div>
</div> </div>
</template> </template>
...@@ -27,6 +27,11 @@ export default { ...@@ -27,6 +27,11 @@ export default {
directives: { directives: {
SafeHtml: GlSafeHtmlDirective, SafeHtml: GlSafeHtmlDirective,
}, },
inject: {
projectFullPath: {
default: '',
},
},
props: { props: {
vulnerability: { vulnerability: {
type: Object, type: Object,
...@@ -153,6 +158,9 @@ export default { ...@@ -153,6 +158,9 @@ export default {
hasResponses() { hasResponses() {
return Boolean(this.hasResponse || this.hasRecordedResponse); return Boolean(this.hasResponse || this.hasRecordedResponse);
}, },
shouldShowTraining() {
return this.vulnerability.identifiers?.length > 0 && Boolean(this.projectFullPath);
},
}, },
methods: { methods: {
getHeadersAsCodeBlockLines(headers) { getHeadersAsCodeBlockLines(headers) {
...@@ -380,7 +388,11 @@ export default { ...@@ -380,7 +388,11 @@ export default {
</ul> </ul>
</template> </template>
<vulnerability-training :id="vulnerability.id"> <vulnerability-training
v-if="shouldShowTraining"
:project-full-path="projectFullPath"
:identifiers="vulnerability.identifiers"
>
<template #header> <template #header>
<h3>{{ $options.VULNERABILITY_TRAINING_HEADING.title }}</h3> <h3>{{ $options.VULNERABILITY_TRAINING_HEADING.title }}</h3>
</template> </template>
......
...@@ -4,8 +4,6 @@ import * as Sentry from '@sentry/browser'; ...@@ -4,8 +4,6 @@ import * as Sentry from '@sentry/browser';
import { s__, __ } from '~/locale'; 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 securityTrainingVulnerabilityQuery from '~/security_configuration/graphql/security_training_vulnerability.query.graphql'; import securityTrainingVulnerabilityQuery from '~/security_configuration/graphql/security_training_vulnerability.query.graphql';
import { TYPE_VULNERABILITY } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import { import {
...@@ -33,10 +31,13 @@ export default { ...@@ -33,10 +31,13 @@ export default {
GlSkeletonLoader, GlSkeletonLoader,
}, },
mixins: [glFeatureFlagsMixin(), Tracking.mixin()], mixins: [glFeatureFlagsMixin(), Tracking.mixin()],
inject: ['projectFullPath'],
props: { props: {
id: { projectFullPath: {
type: Number, type: String,
required: true,
},
identifiers: {
type: Array,
required: true, required: true,
}, },
}, },
...@@ -55,23 +56,31 @@ export default { ...@@ -55,23 +56,31 @@ export default {
}; };
}, },
}, },
vulnerability: { securityTrainingUrls: {
query: securityTrainingVulnerabilityQuery, query: securityTrainingVulnerabilityQuery,
pollInterval: 5000, pollInterval: 5000,
update({ vulnerability }) { update({ project }) {
const allUrlsAreReady = vulnerability?.securityTrainingUrls?.every( if (!project) {
return [];
}
const { securityTrainingUrls = [] } = project;
const allUrlsAreReady = securityTrainingUrls.every(
({ status }) => status === SECURITY_TRAINING_URL_STATUS_COMPLETED, ({ status }) => status === SECURITY_TRAINING_URL_STATUS_COMPLETED,
); );
if (allUrlsAreReady) { if (allUrlsAreReady) {
this.$apollo.queries.vulnerability.stopPolling(); this.$apollo.queries.securityTrainingUrls.stopPolling();
this.isUrlsLoading = false; this.isUrlsLoading = false;
} }
return vulnerability; return securityTrainingUrls;
}, },
variables() { variables() {
return { id: convertToGraphQLId(TYPE_VULNERABILITY, this.id) }; return {
projectFullPath: this.projectFullPath,
identifierExternalIds: this.supportedIdentifiersExternalIds,
};
}, },
error(e) { error(e) {
Sentry.captureException(e); Sentry.captureException(e);
...@@ -98,23 +107,25 @@ export default { ...@@ -98,23 +107,25 @@ export default {
hasSecurityTrainingProviders() { hasSecurityTrainingProviders() {
return this.securityTrainingProviders?.some(({ isEnabled }) => isEnabled); return this.securityTrainingProviders?.some(({ isEnabled }) => isEnabled);
}, },
hasSupportedIdentifier() { supportedIdentifiersExternalIds() {
return this.vulnerability?.identifiers?.some( return this.identifiers.flatMap(({ externalType, externalId }) =>
({ externalType }) => externalType?.toLowerCase() === SUPPORTED_IDENTIFIER_TYPES.cwe, externalType?.toLowerCase() === SUPPORTED_IDENTIFIER_TYPES.cwe ? externalId : [],
); );
}, },
hasSupportedIdentifier() {
return this.supportedIdentifiersExternalIds.length > 0;
},
hasSecurityTrainingUrls() { hasSecurityTrainingUrls() {
const hasSecurityTrainingUrls = this.vulnerability?.securityTrainingUrls?.length > 0; const hasSecurityTrainingUrls = this.securityTrainingUrls?.length > 0;
if (hasSecurityTrainingUrls) { if (hasSecurityTrainingUrls) {
this.track(TRACK_TRAINING_LOADED_ACTION, { this.track(TRACK_TRAINING_LOADED_ACTION, {
property: this.projectFullPath, property: this.projectFullPath,
}); });
} }
return hasSecurityTrainingUrls; return hasSecurityTrainingUrls;
}, },
securityTrainingUrls() {
return this.vulnerability?.securityTrainingUrls;
},
}, },
watch: { watch: {
showVulnerabilityTraining: { showVulnerabilityTraining: {
......
...@@ -17,10 +17,6 @@ const pipelineId = 123; ...@@ -17,10 +17,6 @@ const pipelineId = 123;
const pipelineIid = 12; const pipelineIid = 12;
const vulnerabilitiesEndpoint = `${TEST_HOST}/vulnerabilities`; const vulnerabilitiesEndpoint = `${TEST_HOST}/vulnerabilities`;
jest.mock('~/lib/utils/url_utility', () => ({
getParameterValues: jest.fn().mockReturnValue([]),
}));
jest.mock('~/flash'); jest.mock('~/flash');
describe('Security Dashboard component', () => { describe('Security Dashboard component', () => {
......
...@@ -200,5 +200,18 @@ key2: value2 ...@@ -200,5 +200,18 @@ key2: value2
<!----> <!---->
<!----> <!---->
<div
style="display: none;"
>
<vulnerability-detail-stub
label="Training"
>
<vulnerability-training-stub
identifiers="[object Object],[object Object]"
projectfullpath="gitlab-org/gitlab-ui"
/>
</vulnerability-detail-stub>
</div>
</div> </div>
`; `;
...@@ -6,6 +6,7 @@ import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_ba ...@@ -6,6 +6,7 @@ import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_ba
import VulnerabilityDetails from 'ee/vue_shared/security_reports/components/vulnerability_details.vue'; import VulnerabilityDetails from 'ee/vue_shared/security_reports/components/vulnerability_details.vue';
import FalsePositiveAlert from 'ee/vulnerabilities/components/false_positive_alert.vue'; import FalsePositiveAlert from 'ee/vulnerabilities/components/false_positive_alert.vue';
import GenericReportSection from 'ee/vulnerabilities/components/generic_report/report_section.vue'; import GenericReportSection from 'ee/vulnerabilities/components/generic_report/report_section.vue';
import VulnerabilityTraining from 'ee/vulnerabilities/components/vulnerability_training.vue';
import { SUPPORTING_MESSAGE_TYPES } from 'ee/vulnerabilities/constants'; import { SUPPORTING_MESSAGE_TYPES } from 'ee/vulnerabilities/constants';
import { mountExtended } from 'helpers/vue_test_utils_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
...@@ -401,6 +402,46 @@ describe('VulnerabilityDetails component', () => { ...@@ -401,6 +402,46 @@ describe('VulnerabilityDetails component', () => {
}); });
}); });
describe('vulnerability training', () => {
describe('with vulnerability identifiers', () => {
const identifiers = [{ externalType: 'cwe', externalId: 'cwe-123' }];
const project = {
id: 7071551,
name: 'project',
full_path: '/namespace/project',
full_name: 'GitLab.org / gitlab-ui',
};
beforeEach(() => {
const vulnerability = makeVulnerability({ identifiers, project });
componentFactory(vulnerability);
});
it(`passes the vulnerability's identifiers to the training section`, () => {
expect(wrapper.findComponent(VulnerabilityTraining).props('identifiers')).toEqual(
identifiers,
);
});
it(`passes the project's full path without a leading slash`, () => {
expect(wrapper.findComponent(VulnerabilityTraining).props('projectFullPath')).toBe(
'namespace/project',
);
});
});
describe('without vulnerability identifiers', () => {
beforeEach(() => {
const vulnerability = makeVulnerability({ identifiers: [] });
componentFactory(vulnerability);
});
it('does not render the vulnerability training section', () => {
expect(wrapper.findComponent(VulnerabilityTraining).exists()).toBe(false);
});
});
});
describe('pin test', () => { describe('pin test', () => {
const factory = (vulnFinding) => { const factory = (vulnFinding) => {
wrapper = shallowMount(VulnerabilityDetails, { wrapper = shallowMount(VulnerabilityDetails, {
......
...@@ -5,8 +5,8 @@ import { ...@@ -5,8 +5,8 @@ import {
} from 'ee/vulnerabilities/constants'; } from 'ee/vulnerabilities/constants';
export const testIdentifiers = [ export const testIdentifiers = [
{ externalType: SUPPORTED_IDENTIFIER_TYPES.cwe }, { externalType: SUPPORTED_IDENTIFIER_TYPES.cwe, externalId: 'cwe-1' },
{ externalType: 'cve' }, { externalType: 'cve', externalId: 'cve-1' },
]; ];
export const generateNote = ({ id = 1295 } = {}) => ({ export const generateNote = ({ id = 1295 } = {}) => ({
...@@ -43,14 +43,8 @@ export const addTypenamesToDiscussion = (discussion) => { ...@@ -43,14 +43,8 @@ export const addTypenamesToDiscussion = (discussion) => {
}; };
}; };
export const defaultProps = { const createSecurityTrainingUrls = ({ urlOverrides = {}, urls } = {}) =>
id: 200, urls || [
};
const createSecurityTrainingVulnerability = ({ urlOverrides = {}, urls, identifiers } = {}) => ({
...defaultProps,
identifiers: identifiers || testIdentifiers,
securityTrainingUrls: urls || [
{ {
name: testProviderName[0], name: testProviderName[0],
url: testTrainingUrls[0], url: testTrainingUrls[0],
...@@ -63,20 +57,16 @@ const createSecurityTrainingVulnerability = ({ urlOverrides = {}, urls, identifi ...@@ -63,20 +57,16 @@ const createSecurityTrainingVulnerability = ({ urlOverrides = {}, urls, identifi
status: SECURITY_TRAINING_URL_STATUS_COMPLETED, status: SECURITY_TRAINING_URL_STATUS_COMPLETED,
...urlOverrides.second, ...urlOverrides.second,
}, },
], ];
});
export const getSecurityTrainingVulnerabilityData = (vulnerabilityOverrides = {}) => { export const getSecurityTrainingProjectData = (urlOverrides = {}) => ({
const vulnerability = createSecurityTrainingVulnerability(vulnerabilityOverrides); response: {
const response = {
data: { data: {
vulnerability, project: {
id: 'gid://gitlab/Project/1',
__typename: 'Project',
securityTrainingUrls: createSecurityTrainingUrls(urlOverrides),
}, },
}; },
},
return { });
response,
data: vulnerability,
};
};
...@@ -21,6 +21,8 @@ describe('Vulnerability Details', () => { ...@@ -21,6 +21,8 @@ describe('Vulnerability Details', () => {
descriptionHtml: 'vulnerability description <code>sample</code>', descriptionHtml: 'vulnerability description <code>sample</code>',
}; };
const TEST_PROJECT_FULL_PATH = 'namespace/project';
const createWrapper = (vulnerabilityOverrides, { mountFn = mount, options = {} } = {}) => { const createWrapper = (vulnerabilityOverrides, { mountFn = mount, options = {} } = {}) => {
const propsData = { const propsData = {
vulnerability: { ...vulnerability, ...vulnerabilityOverrides }, vulnerability: { ...vulnerability, ...vulnerabilityOverrides },
...@@ -28,7 +30,7 @@ describe('Vulnerability Details', () => { ...@@ -28,7 +30,7 @@ describe('Vulnerability Details', () => {
wrapper = mountFn(VulnerabilityDetails, { wrapper = mountFn(VulnerabilityDetails, {
propsData, propsData,
provide: { provide: {
projectFullPath: 'namespace/project', projectFullPath: TEST_PROJECT_FULL_PATH,
}, },
...options, ...options,
}); });
...@@ -204,24 +206,28 @@ describe('Vulnerability Details', () => { ...@@ -204,24 +206,28 @@ describe('Vulnerability Details', () => {
}); });
describe('VulnerabilityTraining', () => { describe('VulnerabilityTraining', () => {
const { id } = vulnerability; const identifiers = [{ externalType: 'cwe', externalId: 'cwe-123' }];
it('renders component', () => { it('renders component', () => {
createShallowWrapper(); createShallowWrapper({ identifiers });
expect(findVulnerabilityTraining().props()).toMatchObject({ expect(findVulnerabilityTraining().props()).toMatchObject({
id, identifiers,
projectFullPath: TEST_PROJECT_FULL_PATH,
}); });
}); });
it('renders title text', () => { it('renders title text', () => {
createShallowWrapper(null, { createShallowWrapper(
{ identifiers },
{
stubs: { stubs: {
VulnerabilityTraining: { VulnerabilityTraining: {
template: '<div><slot name="header"></slot></div>', template: '<div><slot name="header"></slot></div>',
}, },
}, },
}); },
);
expect(wrapper.text()).toContain(VULNERABILITY_TRAINING_HEADING.title); expect(wrapper.text()).toContain(VULNERABILITY_TRAINING_HEADING.title);
}); });
......
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import * as Sentry from '@sentry/browser';
import { GlLink, GlIcon, GlSkeletonLoader } from '@gitlab/ui';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import VulnerabilityTraining, {
i18n,
} from 'ee/vulnerabilities/components/vulnerability_training.vue';
import securityTrainingProvidersQuery from '~/security_configuration/graphql/security_training_providers.query.graphql';
import securityTrainingVulnerabilityQuery from '~/security_configuration/graphql/security_training_vulnerability.query.graphql';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import {
SUPPORTED_IDENTIFIER_TYPES,
SECURITY_TRAINING_URL_STATUS_PENDING,
} from 'ee/vulnerabilities/constants';
import {
TRACK_CLICK_TRAINING_LINK_ACTION,
TRACK_TRAINING_LOADED_ACTION,
} from '~/security_configuration/constants';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import {
testProviderName,
testTrainingUrls,
getSecurityTrainingProvidersData,
tempProviderLogos,
} from 'jest/security_configuration/mock_data';
import { getSecurityTrainingProjectData, testIdentifiers } from './mock_data';
Vue.use(VueApollo);
const createTrainingData = (first = {}, second = {}, urls) =>
getSecurityTrainingProjectData({
urls,
urlOverrides: {
first,
second,
},
});
const projectFullPath = 'namespace/project';
const TEST_TRAINING_PROVIDERS_ALL_DISABLED = getSecurityTrainingProvidersData();
const TEST_TRAINING_PROVIDERS_FIRST_ENABLED = getSecurityTrainingProvidersData({
providerOverrides: { first: { isEnabled: true } },
});
const TEST_TRAINING_PROVIDERS_DEFAULT = TEST_TRAINING_PROVIDERS_FIRST_ENABLED;
const TEST_TRAINING_VULNERABILITY_DEFAULT = getSecurityTrainingProjectData();
const TEST_TRAINING_VULNERABILITY_NO_URLS = createTrainingData(null, null, []);
describe('VulnerabilityTraining component', () => {
let wrapper;
let apolloProvider;
const createApolloProvider = ({ providersQueryHandler, projectQueryHandler } = {}) => {
apolloProvider = createMockApollo([
[
securityTrainingProvidersQuery,
providersQueryHandler ||
jest.fn().mockResolvedValue(TEST_TRAINING_PROVIDERS_DEFAULT.response),
],
[
securityTrainingVulnerabilityQuery,
projectQueryHandler ||
jest.fn().mockResolvedValue(TEST_TRAINING_VULNERABILITY_DEFAULT.response),
],
]);
};
const createComponent = (props = {}, { slots = {}, secureVulnerabilityTraining = true } = {}) => {
wrapper = shallowMountExtended(VulnerabilityTraining, {
propsData: {
projectFullPath,
identifiers: testIdentifiers,
...props,
},
slots,
apolloProvider,
provide: {
glFeatures: {
secureVulnerabilityTraining,
},
},
});
};
beforeEach(async () => {
createApolloProvider();
});
afterEach(() => {
wrapper.destroy();
apolloProvider = null;
});
const waitForQueryToBeLoaded = () => waitForPromises();
const findDescription = () => wrapper.findByTestId('description');
const findUnavailableMessage = () => wrapper.findByTestId('unavailable-message');
const findTrainingItemName = (name) => wrapper.findByText(name);
const findTrainingItemLinks = () => wrapper.findAllComponents(GlLink);
const findTrainingItemLinkIcons = () => wrapper.findAllComponents(GlIcon);
const findTrainingLogos = () => wrapper.findAll('img');
describe('with the query being successful', () => {
describe('basic structure', () => {
it('displays the description', async () => {
createApolloProvider();
createComponent();
await waitForQueryToBeLoaded();
expect(findDescription().text()).toBe(i18n.trainingDescription);
});
it('does not render component when there are no enabled securityTrainingProviders', async () => {
createApolloProvider({
providersQueryHandler: jest
.fn()
.mockResolvedValue(TEST_TRAINING_PROVIDERS_ALL_DISABLED.response),
});
createComponent();
await waitForQueryToBeLoaded();
expect(wrapper.html()).toBeFalsy();
});
it('watches showVulnerabilityTraining and emits change', async () => {
createApolloProvider();
createComponent();
await waitForQueryToBeLoaded();
await nextTick();
// Note: the event emits twice - the second time is when the query is loaded
expect(wrapper.emitted('show-vulnerability-training')).toEqual([[false], [true]]);
});
});
describe('with title slot', () => {
it('renders slot content', async () => {
const mockSlotText = 'some title';
createComponent({}, { slots: { header: mockSlotText } });
await waitForQueryToBeLoaded();
expect(wrapper.text()).toContain(mockSlotText);
});
});
describe('training availability message', () => {
it('displays message when there are no supported identifier', async () => {
createComponent({ identifiers: [{ externalType: 'not supported identifier' }] });
await waitForQueryToBeLoaded();
expect(findUnavailableMessage().text()).toBe(i18n.trainingUnavailable);
});
it('displays message when there are no security training urls', async () => {
createApolloProvider({
projectQueryHandler: jest
.fn()
.mockResolvedValue(TEST_TRAINING_VULNERABILITY_NO_URLS.response),
});
createComponent();
await waitForQueryToBeLoaded();
expect(findUnavailableMessage().exists()).toBe(true);
});
it.each`
identifier | exists
${SUPPORTED_IDENTIFIER_TYPES.cwe.toUpperCase()} | ${false}
${SUPPORTED_IDENTIFIER_TYPES.cwe.toLowerCase()} | ${false}
`('sets it to "$exists" for "$identifier"', async ({ identifier, exists }) => {
createApolloProvider();
createComponent({ identifiers: [{ externalType: identifier, externalId: 'cwe-1' }] });
await waitForQueryToBeLoaded();
expect(findUnavailableMessage().exists()).toBe(exists);
});
});
describe('GlSkeletonLoader', () => {
it('displays when there are supported identifiers and some urls are in pending status', async () => {
createApolloProvider({
projectQueryHandler: jest.fn().mockResolvedValue(
createTrainingData({
status: SECURITY_TRAINING_URL_STATUS_PENDING,
}).response,
),
});
createComponent();
await waitForQueryToBeLoaded();
expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true);
});
});
describe('polling', () => {
let apolloQuery;
beforeEach(() => {
createApolloProvider();
createComponent();
apolloQuery = wrapper.vm.$apollo.queries.securityTrainingUrls;
});
it('sets polling at 5000 ms', () => {
expect(apolloQuery.options.pollInterval).toBe(5000);
});
it('stops polling when every training url status is completed', async () => {
jest.spyOn(apolloQuery, 'stopPolling').mockImplementation(jest.fn());
await waitForQueryToBeLoaded();
await nextTick();
expect(apolloQuery.stopPolling).toHaveBeenCalled();
});
});
describe('training logo', () => {
beforeEach(async () => {
createApolloProvider();
createComponent();
wrapper.vm.$options.TEMP_PROVIDER_LOGOS = tempProviderLogos;
await waitForQueryToBeLoaded();
});
const providerIndexArray = [0, 1];
it.each(providerIndexArray)('displays the correct width for training item %s', (index) => {
expect(findTrainingLogos().at(index).attributes('width')).toBe('12');
});
it.each(providerIndexArray)('has a11y decorative attribute for provider %s', (index) => {
expect(findTrainingLogos().at(index).attributes('role')).toBe('presentation');
});
it.each(providerIndexArray)('displays the correct svg path for training item %s', (index) => {
expect(findTrainingLogos().at(index).attributes('src')).toBe(
tempProviderLogos[testProviderName[index]].svg,
);
});
});
describe('training item', () => {
it('displays correct number of training items', async () => {
createApolloProvider();
createComponent();
await waitForQueryToBeLoaded();
expect(findTrainingItemLinks()).toHaveLength(testTrainingUrls.length);
});
it.each([0, 1])('displays training item %s', async (index) => {
createApolloProvider();
createComponent();
await waitForQueryToBeLoaded();
expect(findTrainingItemName(testProviderName[index]).exists()).toBe(true);
expect(findTrainingItemLinks().at(index).attributes('href')).toBe(testTrainingUrls[index]);
expect(findTrainingItemLinkIcons().at(index).attributes('name')).toBe('external-link');
});
it('does not display training item if there are no securityTrainingUrls', async () => {
createApolloProvider({
projectQueryHandler: jest
.fn()
.mockResolvedValue(TEST_TRAINING_VULNERABILITY_NO_URLS.response),
});
createComponent();
await waitForQueryToBeLoaded();
expect(findTrainingItemLinks().exists()).toBe(false);
expect(findTrainingItemLinkIcons().exists()).toBe(false);
});
});
});
describe('with the query resulting in an error', () => {
beforeEach(() => {
jest.spyOn(Sentry, 'captureException');
createApolloProvider({ providersQueryHandler: jest.fn().mockResolvedValue(new Error()) });
createComponent();
});
it('reports the error to sentry', async () => {
expect(Sentry.captureException).not.toHaveBeenCalled();
await waitForQueryToBeLoaded();
expect(Sentry.captureException).toHaveBeenCalled();
});
});
describe('metrics', () => {
let trackingSpy;
beforeEach(() => {
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
});
afterEach(() => {
unmockTracking();
});
const expectedTrackingOptions = (index) => ({
label: `vendor_${testProviderName[index]}`,
property: projectFullPath,
});
it('tracks when the training link is loading', async () => {
createApolloProvider({
projectQueryHandler: jest.fn().mockResolvedValue(
createTrainingData({
status: SECURITY_TRAINING_URL_STATUS_PENDING,
}).response,
),
});
createComponent();
await waitForQueryToBeLoaded();
expect(trackingSpy).toHaveBeenCalledWith(undefined, TRACK_TRAINING_LOADED_ACTION, {
property: projectFullPath,
});
});
it.each([0, 1])('tracks when training link %s gets clicked', async (index) => {
createApolloProvider();
createComponent();
await waitForQueryToBeLoaded();
await findTrainingItemLinks().at(index).vm.$emit('click');
expect(trackingSpy).toHaveBeenCalledWith(
undefined,
TRACK_CLICK_TRAINING_LINK_ACTION,
expectedTrackingOptions(index),
);
});
});
describe('when secureVulnerabilityTraining feature flag is disabled', () => {
it('does not render the VulnerabilityTraining component', () => {
createComponent({}, { secureVulnerabilityTraining: false });
expect(wrapper.html()).toBeFalsy();
});
});
});
...@@ -13,6 +13,7 @@ describe('Vulnerability Report', () => { ...@@ -13,6 +13,7 @@ describe('Vulnerability Report', () => {
const el = document.createElement('div'); const el = document.createElement('div');
const elDataSet = { const elDataSet = {
vulnerability: JSON.stringify(mockVulnerability), vulnerability: JSON.stringify(mockVulnerability),
projectFullPath: 'namespace/project',
}; };
Object.assign(el.dataset, { Object.assign(el.dataset, {
......
...@@ -41,7 +41,8 @@ export const getSecurityTrainingProvidersData = (providerOverrides = {}) => { ...@@ -41,7 +41,8 @@ export const getSecurityTrainingProvidersData = (providerOverrides = {}) => {
const response = { const response = {
data: { data: {
project: { project: {
id: 1, id: 'gid://gitlab/Project/1',
__typename: 'Project',
securityTrainingProviders, securityTrainingProviders,
}, },
}, },
......
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