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

75 lines
2.9 KiB
TypeScript

/**
* Regression tests for #5379 — `spawn EINVAL` installing embedded services
* (9Router / CLIProxy) on Windows + Node.js 24+.
*
* Node 24 no longer lets `child_process.execFile()` run `.cmd` batch files on
* Windows without a shell (nodejs/node#52554). npm on Windows is `npm.cmd`, so
* `runNpm()` threw `EINVAL` immediately. The fix flips `shell` on win32.
*
* Because `shell: true` makes the shell — not execFile — parse the command line,
* NO runtime value may be interpolated into argv (Hard Rule #13). The install
* `--prefix` (a DATA_DIR path that can contain spaces, e.g.
* `C:\Users\John Doe\.omniroute\…`) is therefore passed via the
* `npm_config_prefix` environment variable instead of an argv entry, and the
* user-supplied install `version` is constrained by SERVICE_VERSION_PATTERN.
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
buildNpmExecOptions,
SERVICE_VERSION_PATTERN,
} from "../../../../src/lib/services/installers/utils.ts";
test("buildNpmExecOptions: win32 enables shell so npm.cmd runs on Node 24 (#5379)", () => {
const opts = buildNpmExecOptions("win32", { timeoutMs: 1000 });
assert.equal(opts.shell, true);
});
test("buildNpmExecOptions: non-win32 platforms never enable shell", () => {
for (const platform of ["linux", "darwin", "freebsd"] as NodeJS.Platform[]) {
const opts = buildNpmExecOptions(platform, { timeoutMs: 1000 });
assert.equal(opts.shell, undefined, `${platform} must not use a shell`);
}
});
test("buildNpmExecOptions: prefix is passed via npm_config_prefix env, never argv (Hard Rule #13)", () => {
const prefix = "C:\\Users\\John Doe\\.omniroute\\services\\9router";
const opts = buildNpmExecOptions("win32", { timeoutMs: 1000, prefix });
assert.equal(opts.env.npm_config_prefix, prefix);
});
test("buildNpmExecOptions: without a prefix npm_config_prefix is left untouched", () => {
const inherited = process.env.npm_config_prefix;
const opts = buildNpmExecOptions("linux", { timeoutMs: 1000 });
assert.equal(opts.env.npm_config_prefix, inherited);
});
test("buildNpmExecOptions: carries cwd, timeout and maxBuffer through", () => {
const opts = buildNpmExecOptions("linux", { cwd: "/tmp/install", timeoutMs: 4242 });
assert.equal(opts.cwd, "/tmp/install");
assert.equal(opts.timeout, 4242);
assert.equal(opts.maxBuffer, 10 * 1024 * 1024);
});
test("SERVICE_VERSION_PATTERN: accepts dist-tags and semver", () => {
for (const v of ["latest", "next", "1.2.3", "1.2.3-beta.1", "1.2.3+build.5", "0.4.59"]) {
assert.ok(SERVICE_VERSION_PATTERN.test(v), `${v} should be valid`);
}
});
test("SERVICE_VERSION_PATTERN: rejects shell metacharacters (injection guard)", () => {
for (const v of [
"latest && calc",
"1.2.3; rm -rf /",
"$(whoami)",
"`id`",
"a|b",
"a b",
"",
"-flag",
]) {
assert.equal(SERVICE_VERSION_PATTERN.test(v), false, `${JSON.stringify(v)} must be rejected`);
}
});