Commit 6d29ff33 authored by Jannik Lehmann's avatar Jannik Lehmann Committed by Phil Hughes

License compliance MR widget extension

* Add component and unit tests
parent 4bfa8a0e
import { s__, n__, __, sprintf } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants';
import { parseDependencies } from './utils';
// TODO: Clean up both status versions as part of https://gitlab.com/gitlab-org/gitlab/-/issues/356206
const APPROVAL_STATUS_TO_ICON = {
allowed: EXTENSION_ICONS.success,
approved: EXTENSION_ICONS.success,
denied: EXTENSION_ICONS.failed,
blacklisted: EXTENSION_ICONS.failed,
unclassified: EXTENSION_ICONS.notice,
};
export default {
name: 'WidgetLicenseCompliance',
i18n: {
label: s__('ciReport|License Compliance'),
loading: s__('ciReport|License Compliance test metrics results are being parsed'),
error: s__('ciReport|License Compliance failed loading results'),
},
expandEvent: 'i_testing_license_compliance_widget_total',
props: ['licenseCompliance'],
computed: {
summary() {
if (
this.collapsedData[0].new_licenses.length > 0 &&
this.collapsedData[0].removed_licenses.length > 0
) {
const newLicenses = n__(
'%d new license',
'%d new licenses',
this.collapsedData[0].new_licenses.length,
);
const removedLicenses = n__(
'%d removed license',
'%d removed licenses',
this.collapsedData[0].removed_licenses.length,
);
return sprintf(__(`License Compliance detected ${newLicenses} and ${removedLicenses}`));
} else if (this.collapsedData[0].new_licenses.length > 0) {
return n__(
'LicenseCompliance|License Compliance detected %d new license',
'LicenseCompliance|License Compliance detected %d new licenses',
this.collapsedData[0].new_licenses.length,
);
} else if (this.collapsedData[0].removed_licenses.length > 0) {
return n__(
'LicenseCompliance|License Compliance detected %d removed license',
'LicenseCompliance|License Compliance detected %d removed licenses',
this.collapsedData[0].removed_licenses.length,
);
}
return s__('LicenseCompliance|License Compliance detected no new licenses');
},
statusIcon() {
if (this.collapsedData[0].new_licenses.length === 0) {
return EXTENSION_ICONS.success;
}
return EXTENSION_ICONS.warning;
},
},
methods: {
fetchCollapsedData() {
const { license_scanning_comparison_path } = this.licenseCompliance;
return Promise.all([this.fetchReport(license_scanning_comparison_path)]).then(
(values) => values,
);
},
fetchFullData() {
const { license_scanning_comparison_path } = this.licenseCompliance;
return Promise.all([this.fetchReport(license_scanning_comparison_path)]).then((values) => {
let newLicenses = values[0].new_licenses;
newLicenses = newLicenses.map((e) => ({
status: e.classification.approval_status,
icon: {
name: APPROVAL_STATUS_TO_ICON[e.classification.approval_status],
},
link: {
href: e.url,
text: e.name,
},
supportingText: `${s__('License Compliance| Used by')} ${parseDependencies(
e.dependencies,
)}`,
}));
const groupedLicenses = newLicenses.reduce(
(licenses, license) => ({
...licenses,
[license.status]: [...(licenses[license.status] || []), license],
}),
{},
);
// TODO: Clean up both status versions as part of https://gitlab.com/gitlab-org/gitlab/-/issues/356206
const licenseSections = [
...(groupedLicenses.denied?.length > 0 || groupedLicenses.blacklisted?.length > 0
? [
{
header: s__('LicenseCompliance|Denied'),
text: s__(
"LicenseCompliance|Out-of-compliance with the project's policies and should be removed",
),
children: groupedLicenses.denied || groupedLicenses.blacklisted,
},
]
: []),
...(groupedLicenses.unclassified?.length > 0
? [
{
header: s__('LicenseCompliance|Uncategorized'),
text: s__('LicenseCompliance|No policy matches this license'),
children: groupedLicenses.unclassified,
},
]
: []),
...(groupedLicenses.allowed?.length > 0 || groupedLicenses.approved?.length > 0
? [
{
header: s__('LicenseCompliance|Allowed'),
text: s__('LicenseCompliance|Acceptable for use in this project'),
children: groupedLicenses.allowed || groupedLicenses.approved,
},
]
: []),
];
return licenseSections;
});
},
fetchReport(endpoint) {
return axios.get(endpoint).then((res) => res.data);
},
},
};
export const parseDependencies = (dependencies) => {
return dependencies
.map((dependency) => {
return dependency.name;
})
.join(', ');
};
......@@ -13,6 +13,7 @@ import loadPerformanceExtension from './extensions/load_performance';
import browserPerformanceExtension from './extensions/browser_performance';
import statusChecksExtension from './extensions/status_checks';
import metricsExtension from './extensions/metrics';
import licenseComplianceExtension from './extensions/license_compliance';
export default {
components: {
......@@ -53,7 +54,7 @@ export default {
},
computed: {
shouldRenderLicenseReport() {
return this.mr.enabledReports?.licenseScanning;
return this.mr?.enabledReports?.licenseScanning;
},
hasBrowserPerformanceMetrics() {
return (
......@@ -211,8 +212,18 @@ export default {
this.registerMetrics();
}
},
shouldRenderLicenseReport(newVal) {
if (newVal) {
this.registerLicenseCompliance();
}
},
},
methods: {
registerLicenseCompliance() {
if (this.shouldShowExtension) {
registerExtension(licenseComplianceExtension);
}
},
registerLoadPerformance() {
if (this.shouldShowExtension) {
registerExtension(loadPerformanceExtension);
......@@ -464,7 +475,7 @@ export default {
</mr-widget-enable-feature-prompt>
<mr-widget-licenses
v-if="shouldRenderLicenseReport"
v-if="shouldRenderLicenseReport && !shouldShowExtension"
:api-url="mr.licenseScanning.managed_licenses_path"
:approvals-api-path="mr.apiApprovalsPath"
:licenses-api-path="licensesApiPath"
......
......@@ -26,10 +26,12 @@ export default class MergeRequestStore extends CEMergeRequestStore {
data.create_vulnerability_feedback_dismissal_path;
this.visualReviewAppAvailable = Boolean(data.visual_review_app_available);
this.appUrl = gon && gon.gitlab_url;
this.licenseScanning = data.license_scanning;
this.initBrowserPerformanceReport(data);
this.initLoadPerformanceReport(data);
this.licenseScanning = data.license_scanning;
this.initLicenseComplianceReport(data);
this.metricsReportsPath = data.metrics_reports_path;
this.enabledReports = convertObjectPropsToCamelCase(data.enabled_reports);
......@@ -119,6 +121,13 @@ export default class MergeRequestStore extends CEMergeRequestStore {
};
}
initLicenseComplianceReport({ license_scanning_comparison_path, api_approvals_path }) {
this.licenseCompliance = {
license_scanning_comparison_path,
api_approvals_path,
};
}
compareBrowserPerformanceMetrics(headMetrics, baseMetrics) {
const headMetricsIndexed = MergeRequestStore.normalizeBrowserPerformanceMetrics(headMetrics);
const baseMetricsIndexed = MergeRequestStore.normalizeBrowserPerformanceMetrics(baseMetrics);
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`License Compliance extension expanded data with new licesnes displays allowed licenses 1`] = `
<div
class="gl-py-3 gl-pl-7"
data-testid="extension-list-item"
>
<div
class="gl-w-full"
>
<div
class="gl-w-full"
>
<div
class="gl-mb-2"
>
<strong>
Allowed
</strong>
</div>
<div
class="gl-display-flex"
>
<!---->
<div
class="gl-w-full"
>
<div
class="gl-flex-wrap gl-display-flex gl-w-full"
>
<div
class="gl-mr-4 gl-display-flex gl-align-items-center"
>
<p
class="gl-m-0"
>
Acceptable for use in this project
</p>
</div>
<!---->
<!---->
<!---->
<div
class="gl-ml-auto"
>
<!---->
<!---->
</div>
</div>
<!---->
</div>
</div>
<ul
class="gl-m-0 gl-p-0 gl-list-style-none"
>
<li>
<div
class="gl-w-full gl-pl-6"
data-testid="child-content"
>
<!---->
<div
class="gl-display-flex"
>
<div
class="gl-rounded-full gl-mr-3 gl-relative gl-p-2 gl-pl-0 gl-text-green-500"
>
<svg
aria-label="Success "
class="gl-display-block gl-icon s12"
data-testid="status-success-icon"
role="img"
>
<use
href="#status-success"
/>
</svg>
</div>
<div
class="gl-w-full"
>
<div
class="gl-flex-wrap gl-display-flex gl-w-full"
>
<div
class="gl-mr-4 gl-display-flex gl-align-items-center"
>
<p
class="gl-m-0"
/>
</div>
<div>
<a
class="gl-link"
href="https://opensource.org/licenses/ISC"
>
ISC License
</a>
</div>
<div>
<p
class="gl-m-0"
>
Used by abbrev, anymatch
</p>
</div>
<!---->
<div
class="gl-ml-auto"
>
<!---->
<!---->
</div>
</div>
<!---->
</div>
</div>
<!---->
</div>
</li>
</ul>
</div>
</div>
</div>
`;
exports[`License Compliance extension expanded data with new licesnes displays denied licenses 1`] = `
<div
class="gl-py-3 gl-pl-7 gl-border-b-solid gl-border-b-1 gl-border-gray-100"
data-testid="extension-list-item"
>
<div
class="gl-w-full"
>
<div
class="gl-w-full"
>
<div
class="gl-mb-2"
>
<strong>
Denied
</strong>
</div>
<div
class="gl-display-flex"
>
<!---->
<div
class="gl-w-full"
>
<div
class="gl-flex-wrap gl-display-flex gl-w-full"
>
<div
class="gl-mr-4 gl-display-flex gl-align-items-center"
>
<p
class="gl-m-0"
>
Out-of-compliance with the project's policies and should be removed
</p>
</div>
<!---->
<!---->
<!---->
<div
class="gl-ml-auto"
>
<!---->
<!---->
</div>
</div>
<!---->
</div>
</div>
<ul
class="gl-m-0 gl-p-0 gl-list-style-none"
>
<li>
<div
class="gl-w-full gl-pl-6"
data-testid="child-content"
>
<!---->
<div
class="gl-display-flex"
>
<div
class="gl-rounded-full gl-mr-3 gl-relative gl-p-2 gl-pl-0 gl-text-red-500"
>
<svg
aria-label="Failed "
class="gl-display-block gl-icon s12"
data-testid="status-failed-icon"
role="img"
>
<use
href="#status-failed"
/>
</svg>
</div>
<div
class="gl-w-full"
>
<div
class="gl-flex-wrap gl-display-flex gl-w-full"
>
<div
class="gl-mr-4 gl-display-flex gl-align-items-center"
>
<p
class="gl-m-0"
/>
</div>
<div>
<a
class="gl-link"
href="https://opensource.org/licenses/Apache-2.0"
>
Apache License 2.0
</a>
</div>
<div>
<p
class="gl-m-0"
>
Used by websocket-driver, websocket-extensions, xml-name-validator
</p>
</div>
<!---->
<div
class="gl-ml-auto"
>
<!---->
<!---->
</div>
</div>
<!---->
</div>
</div>
<!---->
</div>
</li>
</ul>
</div>
</div>
</div>
`;
exports[`License Compliance extension expanded data with new licesnes displays uncategorized licenses 1`] = `
<div
class="gl-py-3 gl-pl-7 gl-border-b-solid gl-border-b-1 gl-border-gray-100"
data-testid="extension-list-item"
>
<div
class="gl-w-full"
>
<div
class="gl-w-full"
>
<div
class="gl-mb-2"
>
<strong>
Uncategorized
</strong>
</div>
<div
class="gl-display-flex"
>
<!---->
<div
class="gl-w-full"
>
<div
class="gl-flex-wrap gl-display-flex gl-w-full"
>
<div
class="gl-mr-4 gl-display-flex gl-align-items-center"
>
<p
class="gl-m-0"
>
No policy matches this license
</p>
</div>
<!---->
<!---->
<!---->
<div
class="gl-ml-auto"
>
<!---->
<!---->
</div>
</div>
<!---->
</div>
</div>
<ul
class="gl-m-0 gl-p-0 gl-list-style-none"
>
<li>
<div
class="gl-w-full gl-pl-6"
data-testid="child-content"
>
<!---->
<div
class="gl-display-flex"
>
<div
class="gl-rounded-full gl-mr-3 gl-relative gl-p-2 gl-pl-0 gl-text-gray-500"
>
<svg
aria-label="Notice "
class="gl-display-block gl-icon s12"
data-testid="status-alert-icon"
role="img"
>
<use
href="#status-alert"
/>
</svg>
</div>
<div
class="gl-w-full"
>
<div
class="gl-flex-wrap gl-display-flex gl-w-full"
>
<div
class="gl-mr-4 gl-display-flex gl-align-items-center"
>
<p
class="gl-m-0"
/>
</div>
<div>
<a
class="gl-link"
href="http://opensource.linux-mirror.org/licenses/afl-2.1.txt"
>
Academic Free License v2.1
</a>
</div>
<div>
<p
class="gl-m-0"
>
Used by json-schema
</p>
</div>
<!---->
<div
class="gl-ml-auto"
>
<!---->
<!---->
</div>
</div>
<!---->
</div>
</div>
<!---->
</div>
</li>
</ul>
</div>
</div>
</div>
`;
import MockAdapter from 'axios-mock-adapter';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import extensionsContainer from '~/vue_merge_request_widget/components/extensions/container';
import { registerExtension } from '~/vue_merge_request_widget/components/extensions';
import licenseComplianceExtension from 'ee/vue_merge_request_widget/extensions/license_compliance';
import httpStatusCodes from '~/lib/utils/http_status';
import {
licenseComplianceSuccess,
licenseComplianceRemovedLicenses,
licenseComplianceNewAndRemovedLicenses,
licenseComplianceEmpty,
} from './mock_data';
describe('License Compliance extension', () => {
let wrapper;
let mock;
registerExtension(licenseComplianceExtension);
const endpoint = '/group-name/project-name/-/merge_requests/78/license_scanning_reports';
const mockApi = (statusCode, data) => {
mock.onGet(endpoint).reply(statusCode, data);
};
const findToggleCollapsedButton = () => wrapper.findByTestId('toggle-button');
const findAllExtensionListItems = () => wrapper.findAllByTestId('extension-list-item');
const createComponent = () => {
wrapper = mountExtended(extensionsContainer, {
propsData: {
mr: {
licenseCompliance: {
license_scanning_comparison_path: endpoint,
api_approvals_path: endpoint,
},
},
},
});
};
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
wrapper.destroy();
mock.restore();
});
describe('summary', () => {
it('displays loading text', () => {
mockApi(httpStatusCodes.OK, licenseComplianceSuccess);
createComponent();
expect(wrapper.text()).toBe('License Compliance test metrics results are being parsed');
});
it('displays failed loading text', async () => {
mockApi(httpStatusCodes.INTERNAL_SERVER_ERROR);
createComponent();
await waitForPromises();
expect(wrapper.text()).toBe('License Compliance failed loading results');
});
it('displays no licenses', async () => {
mockApi(httpStatusCodes.OK, licenseComplianceEmpty);
createComponent();
await waitForPromises();
expect(wrapper.text()).toBe('License Compliance detected no new licenses');
});
it('displays new licenses count', async () => {
mockApi(httpStatusCodes.OK, licenseComplianceSuccess);
createComponent();
await waitForPromises();
expect(wrapper.text()).toBe('License Compliance detected 3 new licenses');
});
it('displays removed licenses count', async () => {
mockApi(httpStatusCodes.OK, licenseComplianceRemovedLicenses);
createComponent();
await waitForPromises();
expect(wrapper.text()).toBe('License Compliance detected 3 removed licenses');
});
it('displays new and removed licenses count', async () => {
mockApi(httpStatusCodes.OK, licenseComplianceNewAndRemovedLicenses);
createComponent();
await waitForPromises();
expect(wrapper.text()).toBe(
'License Compliance detected 3 new licenses and 3 removed licenses',
);
});
});
describe('expanded data', () => {
describe('with new licesnes', () => {
beforeEach(async () => {
mockApi(httpStatusCodes.OK, licenseComplianceSuccess);
createComponent();
await waitForPromises();
findToggleCollapsedButton().trigger('click');
await waitForPromises();
});
it('displays denied licenses', async () => {
expect(findAllExtensionListItems().at(0).element).toMatchSnapshot();
});
it('displays uncategorized licenses', async () => {
expect(findAllExtensionListItems().at(1).element).toMatchSnapshot();
});
it('displays allowed licenses', async () => {
expect(findAllExtensionListItems().at(2).element).toMatchSnapshot();
});
});
});
});
export const licenses = [
{
name: 'Academic Free License v2.1',
dependencies: [
{
name: 'json-schema',
version: '0.4.0',
package_manager: 'npm',
blob_path: 'package.json',
},
],
url: 'http://opensource.linux-mirror.org/licenses/afl-2.1.txt',
classification: {
id: null,
name: 'Academic Free License v2.1',
approval_status: 'unclassified',
},
count: 1,
},
{
name: 'Apache License 2.0',
dependencies: [
{
name: 'websocket-driver',
version: '0.7.4',
package_manager: 'npm',
blob_path: 'package.json',
},
{
name: 'websocket-extensions',
version: '0.1.4',
package_manager: 'npm',
blob_path: 'package.json',
},
{
name: 'xml-name-validator',
version: '3.0.0',
package_manager: 'npm',
blob_path: 'package.json',
},
],
url: 'https://opensource.org/licenses/Apache-2.0',
classification: {
id: null,
name: 'Apache License 2.0',
approval_status: 'blacklisted',
},
count: 3,
},
{
name: 'ISC License',
dependencies: [
{
name: 'abbrev',
version: '1.1.1',
package_manager: 'npm',
blob_path: 'package.json',
},
{
name: 'anymatch',
version: '2.0.0',
package_manager: 'npm',
blob_path: 'package.json',
},
],
url: 'https://opensource.org/licenses/ISC',
classification: {
id: 4,
name: 'ISC License',
approval_status: 'approved',
},
count: 2,
},
];
export const licenseComplianceSuccess = {
new_licenses: licenses,
existing_licenses: [],
removed_licenses: [],
};
export const licenseComplianceNewAndRemovedLicenses = {
new_licenses: licenses,
existing_licenses: [],
removed_licenses: licenses,
};
export const licenseComplianceRemovedLicenses = {
new_licenses: [],
existing_licenses: [],
removed_licenses: licenses,
};
export const licenseComplianceEmpty = {
new_licenses: [],
existing_licenses: [],
removed_licenses: [],
};
import { parseDependencies } from 'ee/vue_merge_request_widget/extensions/license_compliance/utils';
import { licenses } from './mock_data';
describe('parseDependencies', () => {
it('generates a string', () => {
expect(parseDependencies(licenses[1].dependencies)).toBe(
'websocket-driver, websocket-extensions, xml-name-validator',
);
});
});
......@@ -335,6 +335,11 @@ msgid_plural "%d more comments"
msgstr[0] ""
msgstr[1] ""
msgid "%d new license"
msgid_plural "%d new licenses"
msgstr[0] ""
msgstr[1] ""
msgid "%d open issue"
msgid_plural "%d open issues"
msgstr[0] ""
......@@ -370,6 +375,11 @@ msgid_plural "%d projects selected"
msgstr[0] ""
msgstr[1] ""
msgid "%d removed license"
msgid_plural "%d removed licenses"
msgstr[0] ""
msgstr[1] ""
msgid "%d second"
msgid_plural "%d seconds"
msgstr[0] ""
......@@ -22163,6 +22173,9 @@ msgstr ""
msgid "License Compliance"
msgstr ""
msgid "License Compliance| Used by"
msgstr ""
msgid "License compliance"
msgstr ""
......@@ -22181,6 +22194,9 @@ msgstr ""
msgid "LicenseCompliance|%{docLinkStart}License Approvals%{docLinkEnd} are inactive"
msgstr ""
msgid "LicenseCompliance|Acceptable for use in this project"
msgstr ""
msgid "LicenseCompliance|Acceptable license to be used in the project"
msgstr ""
......@@ -22241,6 +22257,11 @@ msgid_plural "LicenseCompliance|License Compliance detected %d new licenses and
msgstr[0] ""
msgstr[1] ""
msgid "LicenseCompliance|License Compliance detected %d removed license"
msgid_plural "LicenseCompliance|License Compliance detected %d removed licenses"
msgstr[0] ""
msgstr[1] ""
msgid "LicenseCompliance|License Compliance detected no licenses for the source branch only"
msgstr ""
......@@ -22250,6 +22271,12 @@ msgstr ""
msgid "LicenseCompliance|License name"
msgstr ""
msgid "LicenseCompliance|No policy matches this license"
msgstr ""
msgid "LicenseCompliance|Out-of-compliance with the project's policies and should be removed"
msgstr ""
msgid "LicenseCompliance|Remove license"
msgstr ""
......@@ -22265,6 +22292,9 @@ msgstr ""
msgid "LicenseCompliance|This license already exists in this project."
msgstr ""
msgid "LicenseCompliance|Uncategorized"
msgstr ""
msgid "LicenseCompliance|You are about to remove the license, %{name}, from this project."
msgstr ""
......@@ -43843,6 +43873,15 @@ msgstr ""
msgid "ciReport|Investigate this vulnerability by creating an issue"
msgstr ""
msgid "ciReport|License Compliance"
msgstr ""
msgid "ciReport|License Compliance failed loading results"
msgstr ""
msgid "ciReport|License Compliance test metrics results are being parsed"
msgstr ""
msgid "ciReport|Load Performance"
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