mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-19 07:36:52 +00:00
e77dd74a70
Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
478 lines
15 KiB
JavaScript
478 lines
15 KiB
JavaScript
import { describe, it, before, after } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import {
|
|
renderSbom,
|
|
qualifiedName,
|
|
isFirstParty,
|
|
licenseKey,
|
|
licenseTextFor,
|
|
copyrightFor,
|
|
applyOverride,
|
|
loadOverrides,
|
|
readLicenseFromDisk,
|
|
} from './render-licenses-md.mjs';
|
|
|
|
const mit = { license: { id: 'MIT' } };
|
|
|
|
describe('qualifiedName', () => {
|
|
it('includes group for scoped packages', () => {
|
|
assert.equal(qualifiedName({ group: '@smithy', name: 'core' }), '@smithy/core');
|
|
});
|
|
|
|
it('returns bare name for unscoped packages', () => {
|
|
assert.equal(qualifiedName({ name: 'busboy' }), 'busboy');
|
|
});
|
|
|
|
it('treats empty/missing group as unscoped', () => {
|
|
assert.equal(qualifiedName({ group: '', name: 'busboy' }), 'busboy');
|
|
assert.equal(qualifiedName({ group: undefined, name: 'busboy' }), 'busboy');
|
|
});
|
|
});
|
|
|
|
describe('isFirstParty', () => {
|
|
it('matches n8n scoped packages', () => {
|
|
assert.equal(isFirstParty('pkg:npm/%40n8n/config@2.22.0'), true);
|
|
assert.equal(isFirstParty('pkg:npm/%40n8n_io/license-sdk@2.25.0'), true);
|
|
});
|
|
|
|
it('matches unscoped n8n packages', () => {
|
|
assert.equal(isFirstParty('pkg:npm/n8n-workflow@2.23.0'), true);
|
|
assert.equal(isFirstParty('pkg:npm/n8n@2.23.0'), true);
|
|
});
|
|
|
|
it('rejects other scoped packages', () => {
|
|
assert.equal(isFirstParty('pkg:npm/%40smithy/core@3.23.12'), false);
|
|
assert.equal(isFirstParty('pkg:npm/busboy@1.6.0'), false);
|
|
});
|
|
});
|
|
|
|
describe('licenseKey', () => {
|
|
it('returns SPDX id from single license', () => {
|
|
assert.equal(licenseKey([mit]), 'MIT');
|
|
});
|
|
|
|
it('returns expression when present', () => {
|
|
assert.equal(licenseKey([{ expression: '(MIT OR Apache-2.0)' }]), '(MIT OR Apache-2.0)');
|
|
});
|
|
|
|
it('returns null when no licenses', () => {
|
|
assert.equal(licenseKey([]), null);
|
|
assert.equal(licenseKey(null), null);
|
|
});
|
|
|
|
it('joins multiple distinct licenses with OR (CycloneDX choice semantics)', () => {
|
|
assert.equal(
|
|
licenseKey([{ license: { id: 'MIT' } }, { license: { id: 'Apache-2.0' } }]),
|
|
'(MIT OR Apache-2.0)',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('applyOverride', () => {
|
|
it('replaces licenses array when override exists for purl', () => {
|
|
const component = {
|
|
name: 'ssh2',
|
|
version: '1.15.0',
|
|
purl: 'pkg:npm/ssh2@1.15.0',
|
|
licenses: [],
|
|
};
|
|
const overrides = { 'pkg:npm/ssh2@1.15.0': { license: 'MIT' } };
|
|
const result = applyOverride(component, overrides);
|
|
assert.equal(licenseKey(result.licenses), 'MIT');
|
|
});
|
|
|
|
it('leaves component untouched when no override matches', () => {
|
|
const component = { purl: 'pkg:npm/other@1.0.0', licenses: [mit] };
|
|
const result = applyOverride(component, {});
|
|
assert.equal(result, component);
|
|
});
|
|
});
|
|
|
|
describe('renderSbom — scope handling (regression)', () => {
|
|
const sbom = {
|
|
components: [
|
|
{ group: '@smithy', name: 'core', version: '3.23.12', purl: 'pkg:npm/%40smithy/core@3.23.12', licenses: [mit] },
|
|
{ group: '@opentelemetry', name: 'core', version: '2.7.1', purl: 'pkg:npm/%40opentelemetry/core@2.7.1', licenses: [mit] },
|
|
{ group: '@aws-sdk', name: 'core', version: '3.808.0', purl: 'pkg:npm/%40aws-sdk/core@3.808.0', licenses: [mit] },
|
|
{ name: 'busboy', version: '1.6.0', purl: 'pkg:npm/busboy@1.6.0', licenses: [mit] },
|
|
{ group: '@n8n', name: 'config', version: '2.22.0', purl: 'pkg:npm/%40n8n/config@2.22.0', licenses: [mit] },
|
|
],
|
|
};
|
|
|
|
it('preserves scope in rendered package names', async () => {
|
|
const { markdown } = await renderSbom(sbom, {});
|
|
assert.match(markdown, /^\* @smithy\/core 3\.23\.12$/m);
|
|
assert.match(markdown, /^\* @opentelemetry\/core 2\.7\.1$/m);
|
|
assert.match(markdown, /^\* @aws-sdk\/core 3\.808\.0$/m);
|
|
});
|
|
|
|
it('does not emit bare "* core" lines when scoped variants exist', async () => {
|
|
const { markdown } = await renderSbom(sbom, {});
|
|
assert.equal(
|
|
/^\* core\s/m.test(markdown),
|
|
false,
|
|
'bare "* core" line found — scope was stripped',
|
|
);
|
|
});
|
|
|
|
it('keeps unscoped packages unscoped', async () => {
|
|
const { markdown } = await renderSbom(sbom, {});
|
|
assert.match(markdown, /^\* busboy 1\.6\.0$/m);
|
|
});
|
|
|
|
it('filters first-party @n8n/* packages', async () => {
|
|
const { summary } = await renderSbom(sbom, {});
|
|
assert.equal(summary.skippedFirstParty, 1);
|
|
assert.equal(summary.externalComponents, 4);
|
|
});
|
|
|
|
it('reports unresolved components with qualified name', async () => {
|
|
const sbomMissing = {
|
|
components: [
|
|
{ group: '@smithy', name: 'core', version: '1.0.0', purl: 'pkg:npm/%40smithy/core@1.0.0' },
|
|
],
|
|
};
|
|
const { unresolved } = await renderSbom(sbomMissing, {});
|
|
assert.equal(unresolved.length, 1);
|
|
assert.match(unresolved[0], /^@smithy\/core@1\.0\.0\t/);
|
|
});
|
|
});
|
|
|
|
describe('renderSbom — unused override detection', () => {
|
|
it('reports overrides whose PURL did not match any component', async () => {
|
|
const sbom = {
|
|
components: [
|
|
{ name: 'busboy', version: '1.6.0', purl: 'pkg:npm/busboy@1.6.0', licenses: [mit] },
|
|
],
|
|
};
|
|
const overrides = {
|
|
'pkg:npm/busboy@1.6.0': { license: 'MIT' },
|
|
'pkg:npm/ssh2@1.15.0': { license: 'MIT' },
|
|
'pkg:npm/utf7@1.0.2': { license: 'MIT' },
|
|
};
|
|
|
|
const { unusedOverrides, summary } = await renderSbom(sbom, overrides);
|
|
assert.deepEqual(unusedOverrides.sort(), [
|
|
'pkg:npm/ssh2@1.15.0',
|
|
'pkg:npm/utf7@1.0.2',
|
|
]);
|
|
assert.equal(summary.unusedOverrides, 2);
|
|
});
|
|
|
|
it('reports zero unused overrides when every override matches', async () => {
|
|
const sbom = {
|
|
components: [
|
|
{ name: 'busboy', version: '1.6.0', purl: 'pkg:npm/busboy@1.6.0', licenses: [] },
|
|
],
|
|
};
|
|
const overrides = { 'pkg:npm/busboy@1.6.0': { license: 'MIT' } };
|
|
const { unusedOverrides, summary } = await renderSbom(sbom, overrides);
|
|
assert.deepEqual(unusedOverrides, []);
|
|
assert.equal(summary.unusedOverrides, 0);
|
|
});
|
|
});
|
|
|
|
describe('renderSbom — disk text lookup', () => {
|
|
it('calls readDiskText with rawComponent (not override-mutated)', async () => {
|
|
const sbom = {
|
|
components: [
|
|
{
|
|
group: '@smithy',
|
|
name: 'core',
|
|
version: '1.0.0',
|
|
purl: 'pkg:npm/%40smithy/core@1.0.0',
|
|
licenses: [],
|
|
},
|
|
],
|
|
};
|
|
const overrides = { 'pkg:npm/%40smithy/core@1.0.0': { license: 'MIT' } };
|
|
|
|
const seen = [];
|
|
const readDiskText = async (component) => {
|
|
seen.push({ group: component.group, name: component.name });
|
|
return 'MIT TEXT';
|
|
};
|
|
|
|
const { markdown } = await renderSbom(sbom, overrides, { readDiskText });
|
|
assert.deepEqual(seen, [{ group: '@smithy', name: 'core' }]);
|
|
assert.match(markdown, /MIT TEXT/);
|
|
});
|
|
|
|
it('skips disk text lookup when override sets skipDiskText:true', async () => {
|
|
const sbom = {
|
|
components: [
|
|
{
|
|
name: 'utf7',
|
|
version: '1.0.2',
|
|
purl: 'pkg:npm/utf7@1.0.2',
|
|
licenses: [],
|
|
},
|
|
],
|
|
};
|
|
const overrides = {
|
|
'pkg:npm/utf7@1.0.2': { license: 'BSD-3-Clause', skipDiskText: true },
|
|
};
|
|
|
|
let called = false;
|
|
const readDiskText = async () => {
|
|
called = true;
|
|
return 'should not appear';
|
|
};
|
|
|
|
const { markdown } = await renderSbom(sbom, overrides, { readDiskText });
|
|
assert.equal(called, false);
|
|
assert.doesNotMatch(markdown, /should not appear/);
|
|
});
|
|
});
|
|
|
|
describe('licenseKey — additional branches', () => {
|
|
it('falls back to license.name when id/expression are absent', () => {
|
|
assert.equal(licenseKey([{ license: { name: 'Custom License' } }]), 'Custom License');
|
|
});
|
|
|
|
it('returns null when every entry has no usable field', () => {
|
|
assert.equal(licenseKey([{ license: {} }, { license: { id: null } }]), null);
|
|
});
|
|
|
|
it('wraps multi-entry licenses in parens with OR (CycloneDX choice)', () => {
|
|
assert.equal(
|
|
licenseKey([
|
|
{ license: { id: 'MIT' } },
|
|
{ license: { id: 'Apache-2.0' } },
|
|
{ license: { id: 'BSD-3-Clause' } },
|
|
]),
|
|
'(MIT OR Apache-2.0 OR BSD-3-Clause)',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('licenseTextFor', () => {
|
|
it('returns inline text content from first license entry that has it', () => {
|
|
const text = licenseTextFor([
|
|
{ license: { id: 'MIT' } },
|
|
{ license: { id: 'Apache-2.0', text: { content: 'INLINE APACHE TEXT' } } },
|
|
]);
|
|
assert.equal(text, 'INLINE APACHE TEXT');
|
|
});
|
|
|
|
it('returns null when no entry has text', () => {
|
|
assert.equal(licenseTextFor([{ license: { id: 'MIT' } }]), null);
|
|
assert.equal(licenseTextFor([]), null);
|
|
assert.equal(licenseTextFor(null), null);
|
|
});
|
|
|
|
it('treats whitespace-only content as missing', () => {
|
|
assert.equal(licenseTextFor([{ license: { text: { content: ' \n\t' } } }]), null);
|
|
});
|
|
});
|
|
|
|
describe('copyrightFor', () => {
|
|
it('returns trimmed copyright string', () => {
|
|
assert.equal(copyrightFor({ copyright: ' Copyright (c) 2024 Foo ' }), 'Copyright (c) 2024 Foo');
|
|
});
|
|
|
|
it('returns null for missing or blank copyright', () => {
|
|
assert.equal(copyrightFor({}), null);
|
|
assert.equal(copyrightFor({ copyright: ' ' }), null);
|
|
});
|
|
});
|
|
|
|
describe('copyright rendering in markdown', () => {
|
|
it('appends trimmed copyright after version, comma-separated', async () => {
|
|
const sbom = {
|
|
components: [
|
|
{
|
|
name: 'busboy',
|
|
version: '1.6.0',
|
|
purl: 'pkg:npm/busboy@1.6.0',
|
|
licenses: [mit],
|
|
copyright: ' Copyright (c) 2024 Foo ',
|
|
},
|
|
],
|
|
};
|
|
const { markdown } = await renderSbom(sbom, {});
|
|
assert.match(markdown, /^\* busboy 1\.6\.0, Copyright \(c\) 2024 Foo$/m);
|
|
});
|
|
|
|
it('omits the trailing comma when copyright is missing', async () => {
|
|
const sbom = {
|
|
components: [
|
|
{ name: 'busboy', version: '1.6.0', purl: 'pkg:npm/busboy@1.6.0', licenses: [mit] },
|
|
],
|
|
};
|
|
const { markdown } = await renderSbom(sbom, {});
|
|
assert.match(markdown, /^\* busboy 1\.6\.0$/m);
|
|
});
|
|
});
|
|
|
|
describe('applyOverride — additional branches', () => {
|
|
it('overwrites an existing (wrong) license declaration', () => {
|
|
const component = {
|
|
purl: 'pkg:npm/utf7@1.0.2',
|
|
licenses: [{ license: { id: 'BSD-3-Clause' } }],
|
|
};
|
|
const overrides = { 'pkg:npm/utf7@1.0.2': { license: 'MIT' } };
|
|
const result = applyOverride(component, overrides);
|
|
assert.equal(licenseKey(result.licenses), 'MIT');
|
|
});
|
|
|
|
it('records matched keys when matchedKeys set is provided', () => {
|
|
const component = { purl: 'pkg:npm/x@1', licenses: [] };
|
|
const overrides = { 'pkg:npm/x@1': { license: 'MIT' } };
|
|
const matched = new Set();
|
|
applyOverride(component, overrides, matched);
|
|
assert.ok(matched.has('pkg:npm/x@1'));
|
|
});
|
|
});
|
|
|
|
describe('renderSbom — edge cases', () => {
|
|
it('handles SBOM with missing components key', async () => {
|
|
const { summary, markdown } = await renderSbom({}, {});
|
|
assert.equal(summary.totalComponents, 0);
|
|
assert.equal(summary.externalComponents, 0);
|
|
assert.equal(summary.unresolved, 0);
|
|
assert.match(markdown, /^# Third-Party Licenses/);
|
|
});
|
|
|
|
it('handles SBOM with empty components array', async () => {
|
|
const { summary } = await renderSbom({ components: [] }, {});
|
|
assert.equal(summary.totalComponents, 0);
|
|
assert.equal(summary.externalComponents, 0);
|
|
});
|
|
|
|
it('reports unresolved when license entries have no usable fields', async () => {
|
|
const sbom = {
|
|
components: [
|
|
{
|
|
name: 'mystery',
|
|
version: '1.0.0',
|
|
purl: 'pkg:npm/mystery@1.0.0',
|
|
licenses: [{ license: {} }],
|
|
},
|
|
],
|
|
};
|
|
const { unresolved, summary } = await renderSbom(sbom, {});
|
|
assert.equal(summary.unresolved, 1);
|
|
assert.match(unresolved[0], /^mystery@1\.0\.0\t/);
|
|
});
|
|
|
|
it('all documented overrides resolve to zero unresolved (end-to-end)', async () => {
|
|
const purls = [
|
|
'pkg:npm/%40ewoudenberg/difflib@0.1.0',
|
|
'pkg:npm/binascii@0.0.2',
|
|
'pkg:npm/busboy@1.6.0',
|
|
'pkg:npm/imap@0.8.19',
|
|
'pkg:npm/js-nacl@1.4.0',
|
|
'pkg:npm/seq-queue@0.0.5',
|
|
'pkg:npm/streamsearch@1.1.0',
|
|
'pkg:npm/utf7@1.0.2',
|
|
'pkg:npm/nub@0.0.0',
|
|
'pkg:npm/xml-escape@1.1.0',
|
|
'pkg:npm/duck@0.1.12',
|
|
];
|
|
const sbom = {
|
|
components: purls.map((purl) => {
|
|
const m = purl.match(/^pkg:npm\/(?:%40([^/]+)\/)?([^@]+)@(.+)$/);
|
|
return {
|
|
group: m[1] ? `@${m[1]}` : undefined,
|
|
name: m[2],
|
|
version: m[3],
|
|
purl,
|
|
licenses: [],
|
|
};
|
|
}),
|
|
};
|
|
|
|
const overridesModule = await loadOverrides();
|
|
const { summary, unresolved, unusedOverrides } = await renderSbom(sbom, overridesModule);
|
|
assert.equal(unresolved.length, 0, `unresolved: ${unresolved.join(', ')}`);
|
|
assert.equal(summary.unresolved, 0);
|
|
assert.equal(unusedOverrides.length, 0, `unused: ${unusedOverrides.join(', ')}`);
|
|
});
|
|
});
|
|
|
|
describe('loadOverrides', () => {
|
|
it('loads and parses license-overrides.json (happy path)', async () => {
|
|
const overrides = await loadOverrides();
|
|
assert.ok(typeof overrides === 'object' && overrides !== null);
|
|
assert.ok(overrides['pkg:npm/busboy@1.6.0'], 'expected busboy override entry');
|
|
assert.equal(overrides['pkg:npm/busboy@1.6.0'].license, 'MIT');
|
|
});
|
|
});
|
|
|
|
describe('readLicenseFromDisk', () => {
|
|
let tmpRoot;
|
|
before(async () => {
|
|
tmpRoot = await mkdtemp(path.join(os.tmpdir(), 'license-disk-'));
|
|
});
|
|
after(async () => {
|
|
if (tmpRoot) await rm(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it('reads LICENSE file when present', async () => {
|
|
const pkgDir = path.join(tmpRoot, 'pkga');
|
|
await mkdir(pkgDir, { recursive: true });
|
|
await writeFile(path.join(pkgDir, 'LICENSE'), 'PKGA LICENSE TEXT');
|
|
const text = await readLicenseFromDisk(tmpRoot, { name: 'pkga' });
|
|
assert.equal(text, 'PKGA LICENSE TEXT');
|
|
});
|
|
|
|
it('reads LICENSE for scoped package using group/name', async () => {
|
|
const pkgDir = path.join(tmpRoot, '@scope', 'pkgb');
|
|
await mkdir(pkgDir, { recursive: true });
|
|
await writeFile(path.join(pkgDir, 'LICENSE.md'), 'SCOPED PKGB TEXT');
|
|
const text = await readLicenseFromDisk(tmpRoot, { group: '@scope', name: 'pkgb' });
|
|
assert.equal(text, 'SCOPED PKGB TEXT');
|
|
});
|
|
|
|
it('falls through README/CHANGELOG/package.json filenames', async () => {
|
|
const pkgDir = path.join(tmpRoot, 'pkgc');
|
|
await mkdir(pkgDir, { recursive: true });
|
|
await writeFile(path.join(pkgDir, 'README.md'), 'not a license');
|
|
await writeFile(path.join(pkgDir, 'CHANGELOG'), 'not a license');
|
|
await writeFile(path.join(pkgDir, 'package.json'), '{}');
|
|
await writeFile(path.join(pkgDir, 'COPYING'), 'COPYING IS A LICENSE');
|
|
const text = await readLicenseFromDisk(tmpRoot, { name: 'pkgc' });
|
|
assert.equal(text, 'COPYING IS A LICENSE');
|
|
});
|
|
|
|
it('returns null when nodeModulesDir is empty/falsy', async () => {
|
|
assert.equal(await readLicenseFromDisk(null, { name: 'x' }), null);
|
|
assert.equal(await readLicenseFromDisk('', { name: 'x' }), null);
|
|
});
|
|
|
|
it('returns null when package dir does not exist', async () => {
|
|
assert.equal(await readLicenseFromDisk(tmpRoot, { name: 'does-not-exist' }), null);
|
|
});
|
|
|
|
it('rejects path traversal in component.name', async () => {
|
|
assert.equal(await readLicenseFromDisk(tmpRoot, { name: '../etc' }), null);
|
|
assert.equal(await readLicenseFromDisk(tmpRoot, { name: '..' }), null);
|
|
});
|
|
|
|
it('rejects path traversal in component.group', async () => {
|
|
assert.equal(
|
|
await readLicenseFromDisk(tmpRoot, { group: '../..', name: 'pkga' }),
|
|
null,
|
|
);
|
|
});
|
|
|
|
it('rejects absolute-path-looking component name', async () => {
|
|
assert.equal(await readLicenseFromDisk(tmpRoot, { name: '/etc/passwd' }), null);
|
|
});
|
|
|
|
it('rejects component.group without leading @', async () => {
|
|
assert.equal(
|
|
await readLicenseFromDisk(tmpRoot, { group: 'noatsign', name: 'pkga' }),
|
|
null,
|
|
);
|
|
});
|
|
|
|
it('returns null for missing component.name', async () => {
|
|
assert.equal(await readLicenseFromDisk(tmpRoot, {}), null);
|
|
});
|
|
});
|