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!) {
vulnerability(id: $id) @client {
query getSecurityTrainingUrls($projectFullPath: ID!, $identifierExternalIds: [String!]!) {
project(fullPath: $projectFullPath) {
id
identifiers {
externalType
}
securityTrainingUrls {
securityTrainingUrls(identifierExternalIds: $identifierExternalIds) {
name
url
status
url
}
}
}
......@@ -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(:rebase_without_ci_ui, 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
push_frontend_feature_flag(:users_expanding_widgets_usage_data, project, default_enabled: :yaml)
push_frontend_feature_flag(:diff_settings_usage_data, default_enabled: :yaml)
......
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import tempResolver from './temp_resolver';
Vue.use(VueApollo);
const defaultClient = createDefaultClient({
...tempResolver,
});
const defaultClient = createDefaultClient();
export default new VueApollo({
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
import { REPORT_TYPES } from 'ee/security_dashboard/store/constants';
import FalsePositiveAlert from 'ee/vulnerabilities/components/false_positive_alert.vue';
import GenericReportSection from 'ee/vulnerabilities/components/generic_report/report_section.vue';
import { SUPPORTING_MESSAGE_TYPES } from 'ee/vulnerabilities/constants';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import {
SUPPORTING_MESSAGE_TYPES,
VULNERABILITY_TRAINING_HEADING,
} from 'ee/vulnerabilities/constants';
import {
convertObjectPropsToCamelCase,
convertArrayOfObjectsToCamelCase,
} from '~/lib/utils/common_utils';
import { s__, sprintf } from '~/locale';
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 { bodyWithFallBack } from './helpers';
import SeverityBadge from './severity_badge.vue';
......@@ -23,11 +30,17 @@ export default {
GlLink,
GlBadge,
FalsePositiveAlert,
VulnerabilityTraining,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
},
props: { vulnerability: { type: Object, required: true } },
data() {
return {
showTraining: false,
};
},
computed: {
url() {
return this.vulnerability.request?.url || getFileLocation(this.vulnLocation);
......@@ -141,6 +154,14 @@ export default {
hasRecordedResponse() {
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: {
getHeadersAsCodeBlockLines(headers) {
......@@ -175,6 +196,12 @@ export default {
? [`${method} ${url}\n`, headerLines, '\n\n', bodyWithFallBack(body)].join('')
: '';
},
handleShowTraining(showVulnerabilityTraining) {
this.showTraining = showVulnerabilityTraining;
},
},
i18n: {
VULNERABILITY_TRAINING_HEADING,
},
};
</script>
......@@ -309,5 +336,14 @@ export default {
class="gl-mt-4"
: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>
</template>
......@@ -27,6 +27,11 @@ export default {
directives: {
SafeHtml: GlSafeHtmlDirective,
},
inject: {
projectFullPath: {
default: '',
},
},
props: {
vulnerability: {
type: Object,
......@@ -153,6 +158,9 @@ export default {
hasResponses() {
return Boolean(this.hasResponse || this.hasRecordedResponse);
},
shouldShowTraining() {
return this.vulnerability.identifiers?.length > 0 && Boolean(this.projectFullPath);
},
},
methods: {
getHeadersAsCodeBlockLines(headers) {
......@@ -380,7 +388,11 @@ export default {
</ul>
</template>
<vulnerability-training :id="vulnerability.id">
<vulnerability-training
v-if="shouldShowTraining"
:project-full-path="projectFullPath"
:identifiers="vulnerability.identifiers"
>
<template #header>
<h3>{{ $options.VULNERABILITY_TRAINING_HEADING.title }}</h3>
</template>
......
......@@ -4,8 +4,6 @@ import * as Sentry from '@sentry/browser';
import { s__, __ } from '~/locale';
import securityTrainingProvidersQuery from '~/security_configuration/graphql/security_training_providers.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 Tracking from '~/tracking';
import {
......@@ -33,10 +31,13 @@ export default {
GlSkeletonLoader,
},
mixins: [glFeatureFlagsMixin(), Tracking.mixin()],
inject: ['projectFullPath'],
props: {
id: {
type: Number,
projectFullPath: {
type: String,
required: true,
},
identifiers: {
type: Array,
required: true,
},
},
......@@ -55,23 +56,31 @@ export default {
};
},
},
vulnerability: {
securityTrainingUrls: {
query: securityTrainingVulnerabilityQuery,
pollInterval: 5000,
update({ vulnerability }) {
const allUrlsAreReady = vulnerability?.securityTrainingUrls?.every(
update({ project }) {
if (!project) {
return [];
}
const { securityTrainingUrls = [] } = project;
const allUrlsAreReady = securityTrainingUrls.every(
({ status }) => status === SECURITY_TRAINING_URL_STATUS_COMPLETED,
);
if (allUrlsAreReady) {
this.$apollo.queries.vulnerability.stopPolling();
this.$apollo.queries.securityTrainingUrls.stopPolling();
this.isUrlsLoading = false;
}
return vulnerability;
return securityTrainingUrls;
},
variables() {
return { id: convertToGraphQLId(TYPE_VULNERABILITY, this.id) };
return {
projectFullPath: this.projectFullPath,
identifierExternalIds: this.supportedIdentifiersExternalIds,
};
},
error(e) {
Sentry.captureException(e);
......@@ -98,23 +107,25 @@ export default {
hasSecurityTrainingProviders() {
return this.securityTrainingProviders?.some(({ isEnabled }) => isEnabled);
},
hasSupportedIdentifier() {
return this.vulnerability?.identifiers?.some(
({ externalType }) => externalType?.toLowerCase() === SUPPORTED_IDENTIFIER_TYPES.cwe,
supportedIdentifiersExternalIds() {
return this.identifiers.flatMap(({ externalType, externalId }) =>
externalType?.toLowerCase() === SUPPORTED_IDENTIFIER_TYPES.cwe ? externalId : [],
);
},
hasSupportedIdentifier() {
return this.supportedIdentifiersExternalIds.length > 0;
},
hasSecurityTrainingUrls() {
const hasSecurityTrainingUrls = this.vulnerability?.securityTrainingUrls?.length > 0;
const hasSecurityTrainingUrls = this.securityTrainingUrls?.length > 0;
if (hasSecurityTrainingUrls) {
this.track(TRACK_TRAINING_LOADED_ACTION, {
property: this.projectFullPath,
});
}
return hasSecurityTrainingUrls;
},
securityTrainingUrls() {
return this.vulnerability?.securityTrainingUrls;
},
},
watch: {
showVulnerabilityTraining: {
......
......@@ -17,10 +17,6 @@ const pipelineId = 123;
const pipelineIid = 12;
const vulnerabilitiesEndpoint = `${TEST_HOST}/vulnerabilities`;
jest.mock('~/lib/utils/url_utility', () => ({
getParameterValues: jest.fn().mockReturnValue([]),
}));
jest.mock('~/flash');
describe('Security Dashboard component', () => {
......
......@@ -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>
`;
......@@ -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 FalsePositiveAlert from 'ee/vulnerabilities/components/false_positive_alert.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 { mountExtended } from 'helpers/vue_test_utils_helper';
import { TEST_HOST } from 'helpers/test_constants';
......@@ -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', () => {
const factory = (vulnFinding) => {
wrapper = shallowMount(VulnerabilityDetails, {
......
......@@ -5,8 +5,8 @@ import {
} from 'ee/vulnerabilities/constants';
export const testIdentifiers = [
{ externalType: SUPPORTED_IDENTIFIER_TYPES.cwe },
{ externalType: 'cve' },
{ externalType: SUPPORTED_IDENTIFIER_TYPES.cwe, externalId: 'cwe-1' },
{ externalType: 'cve', externalId: 'cve-1' },
];
export const generateNote = ({ id = 1295 } = {}) => ({
......@@ -43,14 +43,8 @@ export const addTypenamesToDiscussion = (discussion) => {
};
};
export const defaultProps = {
id: 200,
};
const createSecurityTrainingVulnerability = ({ urlOverrides = {}, urls, identifiers } = {}) => ({
...defaultProps,
identifiers: identifiers || testIdentifiers,
securityTrainingUrls: urls || [
const createSecurityTrainingUrls = ({ urlOverrides = {}, urls } = {}) =>
urls || [
{
name: testProviderName[0],
url: testTrainingUrls[0],
......@@ -63,20 +57,16 @@ const createSecurityTrainingVulnerability = ({ urlOverrides = {}, urls, identifi
status: SECURITY_TRAINING_URL_STATUS_COMPLETED,
...urlOverrides.second,
},
],
});
];
export const getSecurityTrainingVulnerabilityData = (vulnerabilityOverrides = {}) => {
const vulnerability = createSecurityTrainingVulnerability(vulnerabilityOverrides);
const response = {
export const getSecurityTrainingProjectData = (urlOverrides = {}) => ({
response: {
data: {
vulnerability,
project: {
id: 'gid://gitlab/Project/1',
__typename: 'Project',
securityTrainingUrls: createSecurityTrainingUrls(urlOverrides),
},
};
return {
response,
data: vulnerability,
};
};
},
},
});
......@@ -21,6 +21,8 @@ describe('Vulnerability Details', () => {
descriptionHtml: 'vulnerability description <code>sample</code>',
};
const TEST_PROJECT_FULL_PATH = 'namespace/project';
const createWrapper = (vulnerabilityOverrides, { mountFn = mount, options = {} } = {}) => {
const propsData = {
vulnerability: { ...vulnerability, ...vulnerabilityOverrides },
......@@ -28,7 +30,7 @@ describe('Vulnerability Details', () => {
wrapper = mountFn(VulnerabilityDetails, {
propsData,
provide: {
projectFullPath: 'namespace/project',
projectFullPath: TEST_PROJECT_FULL_PATH,
},
...options,
});
......@@ -204,24 +206,28 @@ describe('Vulnerability Details', () => {
});
describe('VulnerabilityTraining', () => {
const { id } = vulnerability;
const identifiers = [{ externalType: 'cwe', externalId: 'cwe-123' }];
it('renders component', () => {
createShallowWrapper();
createShallowWrapper({ identifiers });
expect(findVulnerabilityTraining().props()).toMatchObject({
id,
identifiers,
projectFullPath: TEST_PROJECT_FULL_PATH,
});
});
it('renders title text', () => {
createShallowWrapper(null, {
createShallowWrapper(
{ identifiers },
{
stubs: {
VulnerabilityTraining: {
template: '<div><slot name="header"></slot></div>',
},
},
});
},
);
expect(wrapper.text()).toContain(VULNERABILITY_TRAINING_HEADING.title);
});
......
......@@ -13,6 +13,7 @@ describe('Vulnerability Report', () => {
const el = document.createElement('div');
const elDataSet = {
vulnerability: JSON.stringify(mockVulnerability),
projectFullPath: 'namespace/project',
};
Object.assign(el.dataset, {
......
......@@ -41,7 +41,8 @@ export const getSecurityTrainingProvidersData = (providerOverrides = {}) => {
const response = {
data: {
project: {
id: 1,
id: 'gid://gitlab/Project/1',
__typename: 'Project',
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