Files
2026-07-13 13:39:12 +08:00

326 lines
13 KiB
TypeScript

// tests/unit/build/check-licenses.test.ts
// TDD unit tests for scripts/check/check-licenses.mjs — Task 7.20 license compliance.
//
// Strategy: test the three exported pure functions without spawning license-checker
// or reading the real .license-allowlist.json. All fixtures are synthetic.
// - loadAllowlist() — parses + validates the allowlist JSON shape
// - classifyLicense() — core policy decision (allowed / exception / denied)
// - stripVersion() — strips @version suffix from package keys
import test from "node:test";
import assert from "node:assert/strict";
// @ts-expect-error — .mjs helper has no type declarations; runtime shape is known.
import {
classifyLicense,
stripVersion,
loadAllowlist,
} from "../../../scripts/check/check-licenses.mjs";
// ---------------------------------------------------------------------------
// Helpers — synthetic allowlists for testing classifyLicense in isolation
// ---------------------------------------------------------------------------
function makeAllowlist(overrides: Partial<{
allowed: string[];
allowedExpressions: string[];
exceptions: Record<string, { license: string; justification: string; risk: string }>;
}> = {}) {
return {
allowed: ["MIT", "Apache-2.0", "BSD-3-Clause", "ISC", "0BSD"],
allowedExpressions: ["(MIT OR Apache-2.0)", "MIT AND ISC", "MIT*"],
exceptions: {},
...overrides,
};
}
// ---------------------------------------------------------------------------
// stripVersion
// ---------------------------------------------------------------------------
test("stripVersion: strips @version from a regular package", () => {
assert.equal(stripVersion("lodash@4.17.21"), "lodash");
});
test("stripVersion: strips @version from a scoped package", () => {
assert.equal(stripVersion("@img/sharp-libvips-linux-x64@1.2.4"), "@img/sharp-libvips-linux-x64");
});
test("stripVersion: returns bare name unchanged (no version)", () => {
assert.equal(stripVersion("lodash"), "lodash");
});
test("stripVersion: handles scoped package without version", () => {
assert.equal(stripVersion("@scope/pkg"), "@scope/pkg");
});
test("stripVersion: handles nested scope-like name with version", () => {
assert.equal(stripVersion("@aws-sdk/client-bedrock-runtime@3.1063.0"), "@aws-sdk/client-bedrock-runtime");
});
// ---------------------------------------------------------------------------
// classifyLicense — allowed
// ---------------------------------------------------------------------------
test("classifyLicense: MIT is allowed", () => {
const result = classifyLicense("some-pkg@1.0.0", "MIT", makeAllowlist());
assert.equal(result.status, "allowed");
});
test("classifyLicense: Apache-2.0 is allowed", () => {
const result = classifyLicense("some-pkg@1.0.0", "Apache-2.0", makeAllowlist());
assert.equal(result.status, "allowed");
});
test("classifyLicense: ISC is allowed", () => {
const result = classifyLicense("some-pkg@1.0.0", "ISC", makeAllowlist());
assert.equal(result.status, "allowed");
});
test("classifyLicense: 0BSD is allowed", () => {
const result = classifyLicense("some-pkg@1.0.0", "0BSD", makeAllowlist());
assert.equal(result.status, "allowed");
});
// ---------------------------------------------------------------------------
// classifyLicense — allowed expressions
// ---------------------------------------------------------------------------
test("classifyLicense: (MIT OR Apache-2.0) expression is allowed", () => {
const result = classifyLicense("some-pkg@1.0.0", "(MIT OR Apache-2.0)", makeAllowlist());
assert.equal(result.status, "allowed");
});
test("classifyLicense: MIT AND ISC expression is allowed", () => {
const result = classifyLicense("some-pkg@1.0.0", "MIT AND ISC", makeAllowlist());
assert.equal(result.status, "allowed");
});
test("classifyLicense: MIT* expression is allowed (e.g. khroma)", () => {
const result = classifyLicense("khroma@2.1.0", "MIT*", makeAllowlist());
assert.equal(result.status, "allowed");
});
// ---------------------------------------------------------------------------
// classifyLicense — denied
// ---------------------------------------------------------------------------
test("classifyLicense: GPL-3.0 is denied", () => {
const result = classifyLicense("gpl-pkg@1.0.0", "GPL-3.0", makeAllowlist());
assert.equal(result.status, "denied");
assert.ok(result.reason.includes("GPL-3.0"), `reason should mention license: ${result.reason}`);
});
test("classifyLicense: AGPL-3.0 is denied (strong copyleft)", () => {
const result = classifyLicense("agpl-pkg@1.0.0", "AGPL-3.0", makeAllowlist());
assert.equal(result.status, "denied");
});
test("classifyLicense: LGPL-3.0-or-later is denied without exception", () => {
const result = classifyLicense("lgpl-pkg@1.0.0", "LGPL-3.0-or-later", makeAllowlist());
assert.equal(result.status, "denied");
});
test("classifyLicense: MPL-2.0 is denied without exception or expression", () => {
const result = classifyLicense("mpl-pkg@1.0.0", "MPL-2.0", makeAllowlist());
assert.equal(result.status, "denied");
});
test("classifyLicense: unknown/UNKNOWN license is denied", () => {
const result = classifyLicense("mystery-pkg@1.0.0", "UNKNOWN", makeAllowlist());
assert.equal(result.status, "denied");
});
test("classifyLicense: Custom license is denied", () => {
const result = classifyLicense("custom-pkg@1.0.0", "Custom: LICENSE", makeAllowlist());
assert.equal(result.status, "denied");
});
// ---------------------------------------------------------------------------
// classifyLicense — exceptions
// ---------------------------------------------------------------------------
test("classifyLicense: LGPL package with registered exception returns 'exception'", () => {
const allowlist = makeAllowlist({
exceptions: {
"lgpl-native-pkg": {
license: "LGPL-3.0-or-later",
justification: "Dynamically linked native binary; user can replace.",
risk: "low",
},
},
});
const result = classifyLicense("lgpl-native-pkg@1.2.3", "LGPL-3.0-or-later", allowlist);
assert.equal(result.status, "exception");
assert.ok(result.reason.includes("exception"), `reason should mention exception: ${result.reason}`);
});
test("classifyLicense: scoped package with exception: version is stripped for lookup", () => {
const allowlist = makeAllowlist({
exceptions: {
"@img/sharp-libvips-linux-x64": {
license: "LGPL-3.0-or-later",
justification: "Prebuilt shared lib.",
risk: "low",
},
},
});
const result = classifyLicense(
"@img/sharp-libvips-linux-x64@1.2.4",
"LGPL-3.0-or-later",
allowlist
);
assert.equal(result.status, "exception", "scoped exception should be found after version strip");
});
test("classifyLicense: exception does not apply to different package", () => {
const allowlist = makeAllowlist({
exceptions: {
"only-this-pkg": {
license: "GPL-3.0",
justification: "Special case.",
risk: "high",
},
},
});
const result = classifyLicense("other-gpl-pkg@1.0.0", "GPL-3.0", allowlist);
assert.equal(result.status, "denied", "exception must be per-package, not per-license");
});
test("classifyLicense: exception with risk=medium still returns 'exception' (not denied)", () => {
const allowlist = makeAllowlist({
exceptions: {
"tls-client-node": {
license: "Custom: LICENSE",
justification: "Commons Clause + Apache-2.0. TODO: revisar.",
risk: "medium",
},
},
});
const result = classifyLicense("tls-client-node@0.2.0", "Custom: LICENSE", allowlist);
assert.equal(result.status, "exception");
});
// ---------------------------------------------------------------------------
// classifyLicense — reason field content
// ---------------------------------------------------------------------------
test("classifyLicense: denied result includes package name in reason", () => {
const result = classifyLicense("bad-pkg@1.0.0", "GPL-3.0", makeAllowlist());
assert.ok(
result.reason.includes("bad-pkg"),
`reason should include package name; got: ${result.reason}`
);
});
test("classifyLicense: allowed result mentions the matched license", () => {
const result = classifyLicense("ok-pkg@1.0.0", "MIT", makeAllowlist());
assert.ok(result.reason.includes("MIT"), `reason should include license; got: ${result.reason}`);
});
// ---------------------------------------------------------------------------
// loadAllowlist — shape validation (reads the real .license-allowlist.json)
// ---------------------------------------------------------------------------
test("loadAllowlist: returns an object with allowed, allowedExpressions, and exceptions keys", () => {
const allowlist = loadAllowlist();
assert.ok(typeof allowlist === "object" && allowlist !== null, "should be an object");
assert.ok(Array.isArray(allowlist.allowed), "allowed should be an array");
assert.ok(Array.isArray(allowlist.allowedExpressions), "allowedExpressions should be an array");
assert.ok(typeof allowlist.exceptions === "object", "exceptions should be an object");
});
test("loadAllowlist: allowed includes MIT", () => {
const allowlist = loadAllowlist();
assert.ok(allowlist.allowed.includes("MIT"), "MIT must be in allowed");
});
test("loadAllowlist: allowed includes Apache-2.0", () => {
const allowlist = loadAllowlist();
assert.ok(allowlist.allowed.includes("Apache-2.0"), "Apache-2.0 must be in allowed");
});
test("loadAllowlist: allowed includes ISC", () => {
const allowlist = loadAllowlist();
assert.ok(allowlist.allowed.includes("ISC"), "ISC must be in allowed");
});
test("loadAllowlist: exceptions entries have required fields", () => {
const allowlist = loadAllowlist();
for (const [pkgName, exc] of Object.entries(allowlist.exceptions)) {
assert.ok(
typeof (exc as any).license === "string",
`exceptions.${pkgName}.license should be a string`
);
assert.ok(
typeof (exc as any).justification === "string",
`exceptions.${pkgName}.justification should be a string`
);
assert.ok(
typeof (exc as any).risk === "string",
`exceptions.${pkgName}.risk should be a string`
);
assert.ok(
(exc as any).justification.length > 10,
`exceptions.${pkgName}.justification must be non-trivial (> 10 chars)`
);
}
});
test("loadAllowlist: tls-client-node exception has risk=medium (Commons Clause)", () => {
const allowlist = loadAllowlist();
const exc = allowlist.exceptions["tls-client-node"] as any;
assert.ok(exc, "tls-client-node exception must be registered");
assert.equal(exc.risk, "medium", "tls-client-node is a medium-risk exception (Commons Clause)");
});
test("loadAllowlist: LGPL packages have registered exceptions", () => {
const allowlist = loadAllowlist();
const lgplPkgs = ["@img/sharp-libvips-linux-x64", "@img/sharp-libvips-linuxmusl-x64"];
for (const pkg of lgplPkgs) {
assert.ok(
allowlist.exceptions[pkg],
`${pkg} (LGPL-3.0-or-later) must have a registered exception`
);
}
});
test("loadAllowlist: MPL-2.0 packages have registered exceptions or allowed expressions", () => {
const allowlist = loadAllowlist();
const mplPkgs = ["lightningcss", "lightningcss-linux-x64-gnu", "lightningcss-linux-x64-musl"];
for (const pkg of mplPkgs) {
const hasException = Boolean(allowlist.exceptions[pkg]);
const mplExpr = allowlist.allowedExpressions.some((e: string) => e.includes("MPL"));
assert.ok(
hasException || mplExpr,
`${pkg} (MPL-2.0) must be in exceptions or have an allowed expression`
);
}
});
// ---------------------------------------------------------------------------
// Integration: real allowlist correctly classifies known packages
// ---------------------------------------------------------------------------
test("integration: classifyLicense passes MIT packages against real allowlist", () => {
const allowlist = loadAllowlist();
const result = classifyLicense("lodash@4.17.21", "MIT", allowlist);
assert.equal(result.status, "allowed");
});
test("integration: classifyLicense passes tls-client-node as exception against real allowlist", () => {
const allowlist = loadAllowlist();
const result = classifyLicense("tls-client-node@0.2.0", "Custom: LICENSE", allowlist);
assert.equal(result.status, "exception");
});
test("integration: classifyLicense denies GPL-3.0 against real allowlist", () => {
const allowlist = loadAllowlist();
const result = classifyLicense("hypothetical-gpl@1.0.0", "GPL-3.0", allowlist);
assert.equal(result.status, "denied");
});
test("integration: classifyLicense denies AGPL-3.0 against real allowlist", () => {
const allowlist = loadAllowlist();
const result = classifyLicense("hypothetical-agpl@1.0.0", "AGPL-3.0", allowlist);
assert.equal(result.status, "denied");
});