mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-28 15:14:06 +00:00
408 lines
13 KiB
JavaScript
408 lines
13 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const { EventEmitter } = require('events');
|
|
const {
|
|
isValidNpmPackageName,
|
|
runNpmInstall,
|
|
resolveNodeExecutable,
|
|
resolveNpmCli,
|
|
resolveNpmInvocation,
|
|
clearNpmInvocationCache,
|
|
buildSafeEnv,
|
|
buildSpawnOptions,
|
|
isWindowsBatchFile
|
|
} = require('../../src/utils/install-packages');
|
|
|
|
const nodeExecutableName = () => (process.platform === 'win32' ? 'node.exe' : 'node');
|
|
const fixturePath = (...segments) => path.join('fixtures', 'install-packages', ...segments);
|
|
|
|
const NODE_BIN = fixturePath('node', 'bin');
|
|
const NODE_EXECUTABLE = path.join(NODE_BIN, nodeExecutableName());
|
|
const NPM_SHIM = path.join(NODE_BIN, 'npm');
|
|
const NPM_CLI_LIB_LAYOUT = path.join(NODE_BIN, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js');
|
|
const NPM_CLI_BESIDE_NODE = path.join(NODE_BIN, 'node_modules', 'npm', 'bin', 'npm-cli.js');
|
|
const NODE_DIR_BESIDE = fixturePath('nodejs');
|
|
const NODE_EXECUTABLE_BESIDE = path.join(NODE_DIR_BESIDE, nodeExecutableName());
|
|
const NPM_CLI_BESIDE_LAYOUT = path.join(NODE_DIR_BESIDE, 'node_modules', 'npm', 'bin', 'npm-cli.js');
|
|
const NVM_BIN_DIR = fixturePath('nvm', 'v20', 'bin');
|
|
const NVM_NODE_EXECUTABLE = path.join(NVM_BIN_DIR, nodeExecutableName());
|
|
const SYSTEM_BIN_DIR = fixturePath('system', 'bin');
|
|
const COLLECTION_DIR = fixturePath('collection');
|
|
|
|
const mockNpmInvocation = () => ({
|
|
nodePath: NODE_EXECUTABLE,
|
|
npmCliPath: NPM_CLI_LIB_LAYOUT
|
|
});
|
|
|
|
// Minimal stand-in for a child_process handle: stdout/stderr are emitters and
|
|
// the child itself emits 'close' / 'error'. Lets us drive npm outcomes
|
|
// deterministically without spawning a real process.
|
|
const makeFakeChild = () => {
|
|
const child = new EventEmitter();
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
child.kill = jest.fn();
|
|
return child;
|
|
};
|
|
|
|
describe('isValidNpmPackageName', () => {
|
|
test.each([
|
|
'lodash',
|
|
'dayjs',
|
|
'uuid',
|
|
'@scope/pkg',
|
|
'csv-parse',
|
|
'package.name',
|
|
'@team/secret-sauce'
|
|
])('accepts valid package name: %s', (name) => {
|
|
expect(isValidNpmPackageName(name)).toBe(true);
|
|
});
|
|
|
|
test.each([
|
|
['empty string', ''],
|
|
['whitespace', 'foo bar'],
|
|
['shell injection', 'foo; rm -rf /'],
|
|
['command substitution', '$(whoami)'],
|
|
['leading dot', '.hidden'],
|
|
['non-string', 123],
|
|
['null', null],
|
|
['undefined', undefined]
|
|
])('rejects %s', (_label, name) => {
|
|
expect(isValidNpmPackageName(name)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('resolveNodeExecutable', () => {
|
|
let existsSyncSpy;
|
|
|
|
beforeEach(() => {
|
|
existsSyncSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(false);
|
|
delete process.env.NVM_BIN;
|
|
delete process.env.FNM_MULTISHELL_PATH;
|
|
delete process.env.PATH;
|
|
});
|
|
|
|
afterEach(() => {
|
|
existsSyncSpy.mockRestore();
|
|
});
|
|
|
|
test('prefers NVM_BIN over PATH', () => {
|
|
process.env.NVM_BIN = NVM_BIN_DIR;
|
|
process.env.PATH = SYSTEM_BIN_DIR;
|
|
existsSyncSpy.mockImplementation((candidate) => candidate === NVM_NODE_EXECUTABLE);
|
|
|
|
expect(resolveNodeExecutable()).toBe(NVM_NODE_EXECUTABLE);
|
|
});
|
|
|
|
test('walks PATH when shim env vars are unset', () => {
|
|
process.env.PATH = [NODE_BIN, SYSTEM_BIN_DIR].join(path.delimiter);
|
|
existsSyncSpy.mockImplementation((candidate) => candidate === NODE_EXECUTABLE);
|
|
|
|
expect(resolveNodeExecutable()).toBe(NODE_EXECUTABLE);
|
|
});
|
|
|
|
test('returns null when node is not found', () => {
|
|
process.env.PATH = SYSTEM_BIN_DIR;
|
|
expect(resolveNodeExecutable()).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('resolveNpmCli', () => {
|
|
let existsSyncSpy;
|
|
|
|
beforeEach(() => {
|
|
existsSyncSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(false);
|
|
});
|
|
|
|
afterEach(() => {
|
|
existsSyncSpy.mockRestore();
|
|
});
|
|
|
|
test('skips bin/npm shim when npm-cli.js is present (nvm-windows)', () => {
|
|
existsSyncSpy.mockImplementation(
|
|
(candidate) => candidate === NPM_SHIM || candidate === NPM_CLI_BESIDE_NODE
|
|
);
|
|
|
|
expect(resolveNpmCli(NODE_EXECUTABLE)).toBe(NPM_CLI_BESIDE_NODE);
|
|
});
|
|
|
|
test('finds npm-cli via lib layout', () => {
|
|
existsSyncSpy.mockImplementation((candidate) => candidate === NPM_CLI_LIB_LAYOUT);
|
|
|
|
expect(resolveNpmCli(NODE_EXECUTABLE)).toBe(NPM_CLI_LIB_LAYOUT);
|
|
});
|
|
|
|
test('finds npm-cli via node_modules layout beside node', () => {
|
|
existsSyncSpy.mockImplementation((candidate) => candidate === NPM_CLI_BESIDE_LAYOUT);
|
|
|
|
expect(resolveNpmCli(NODE_EXECUTABLE_BESIDE)).toBe(NPM_CLI_BESIDE_LAYOUT);
|
|
});
|
|
});
|
|
|
|
describe('resolveNpmInvocation', () => {
|
|
beforeEach(() => {
|
|
clearNpmInvocationCache();
|
|
});
|
|
|
|
test('caches the resolved invocation', () => {
|
|
const existsSyncSpy = jest.spyOn(fs, 'existsSync').mockImplementation((candidate) => {
|
|
return candidate === NODE_EXECUTABLE || candidate === NPM_CLI_LIB_LAYOUT;
|
|
});
|
|
process.env.PATH = NODE_BIN;
|
|
|
|
const first = resolveNpmInvocation();
|
|
existsSyncSpy.mockRestore();
|
|
|
|
expect(first).toEqual({ nodePath: NODE_EXECUTABLE, npmCliPath: NPM_CLI_LIB_LAYOUT });
|
|
expect(resolveNpmInvocation()).toBe(first);
|
|
});
|
|
});
|
|
|
|
describe('buildSafeEnv', () => {
|
|
test('prepends the node bin directory to PATH', () => {
|
|
process.env.PATH = SYSTEM_BIN_DIR;
|
|
process.env.HOME = fixturePath('home');
|
|
|
|
const env = buildSafeEnv(NODE_BIN);
|
|
|
|
expect(env.PATH).toBe([NODE_BIN, SYSTEM_BIN_DIR].join(path.delimiter));
|
|
expect(env.HOME).toBe(fixturePath('home'));
|
|
});
|
|
});
|
|
|
|
describe('buildSpawnOptions', () => {
|
|
const originalPlatform = process.platform;
|
|
|
|
afterEach(() => {
|
|
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
|
});
|
|
|
|
test('uses shell:false when spawning node.exe with npm-cli.js (CVE-2024-27980 safe)', () => {
|
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
|
|
|
const nodePath = path.join('fixtures', 'install-packages', 'node', 'bin', 'node.exe');
|
|
const npmCliPath = path.join('fixtures', 'install-packages', 'node', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js');
|
|
const options = buildSpawnOptions({ nodePath, collectionPath: COLLECTION_DIR });
|
|
|
|
expect(isWindowsBatchFile(nodePath)).toBe(false);
|
|
expect(isWindowsBatchFile(npmCliPath)).toBe(false);
|
|
expect(options.shell).toBe(false);
|
|
expect(options.windowsHide).toBe(true);
|
|
expect(options.cwd).toBe(COLLECTION_DIR);
|
|
});
|
|
|
|
test('uses shell:true only when the executable is a Windows batch file', () => {
|
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
|
|
|
const npmCmd = path.join('fixtures', 'install-packages', 'node', 'bin', 'npm.cmd');
|
|
expect(isWindowsBatchFile(npmCmd)).toBe(true);
|
|
|
|
const options = buildSpawnOptions({ nodePath: npmCmd, collectionPath: COLLECTION_DIR });
|
|
expect(options.shell).toBe(true);
|
|
});
|
|
|
|
test('does not treat batch extensions as special on non-Windows platforms', () => {
|
|
Object.defineProperty(process, 'platform', { value: 'linux' });
|
|
|
|
const npmCmd = path.join('fixtures', 'install-packages', 'node', 'bin', 'npm.cmd');
|
|
expect(isWindowsBatchFile(npmCmd)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('runNpmInstall', () => {
|
|
test('resolves success on exit code 0 and captures stdout', async () => {
|
|
const child = makeFakeChild();
|
|
const spawnFn = jest.fn(() => child);
|
|
|
|
const promise = runNpmInstall({
|
|
collectionPath: COLLECTION_DIR,
|
|
packages: ['dayjs'],
|
|
spawnFn,
|
|
resolveNpmInvocationFn: mockNpmInvocation
|
|
});
|
|
child.stdout.emit('data', Buffer.from('added 1 package'));
|
|
child.emit('close', 0);
|
|
|
|
const result = await promise;
|
|
expect(result.success).toBe(true);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.stdout).toContain('added 1 package');
|
|
expect(result.installed).toEqual(['dayjs']);
|
|
});
|
|
|
|
test('spawns node with npm-cli.js, correct args, cwd, and no shell', async () => {
|
|
const child = makeFakeChild();
|
|
const spawnFn = jest.fn(() => child);
|
|
const systemPath = fixturePath('system', 'path');
|
|
process.env.PATH = systemPath;
|
|
|
|
const promise = runNpmInstall({
|
|
collectionPath: COLLECTION_DIR,
|
|
packages: ['dayjs', 'dayjs', 'zod'],
|
|
spawnFn,
|
|
resolveNpmInvocationFn: mockNpmInvocation
|
|
});
|
|
child.emit('close', 0);
|
|
await promise;
|
|
|
|
expect(spawnFn).toHaveBeenCalledWith(
|
|
NODE_EXECUTABLE,
|
|
[NPM_CLI_LIB_LAYOUT, 'install', '--save', 'dayjs', 'zod'],
|
|
expect.objectContaining({
|
|
cwd: COLLECTION_DIR,
|
|
shell: false,
|
|
windowsHide: true,
|
|
env: expect.objectContaining({
|
|
PATH: [NODE_BIN, systemPath].join(path.delimiter)
|
|
})
|
|
})
|
|
);
|
|
});
|
|
|
|
test('dedupes packages in the result', async () => {
|
|
const child = makeFakeChild();
|
|
const promise = runNpmInstall({
|
|
collectionPath: COLLECTION_DIR,
|
|
packages: ['a', 'a', 'b'],
|
|
spawnFn: () => child,
|
|
resolveNpmInvocationFn: mockNpmInvocation
|
|
});
|
|
child.emit('close', 0);
|
|
const result = await promise;
|
|
expect(result.installed).toEqual(['a', 'b']);
|
|
});
|
|
|
|
test('resolves failure on a non-zero exit and surfaces stderr', async () => {
|
|
const child = makeFakeChild();
|
|
const promise = runNpmInstall({
|
|
collectionPath: COLLECTION_DIR,
|
|
packages: ['bad-pkg'],
|
|
spawnFn: () => child,
|
|
resolveNpmInvocationFn: mockNpmInvocation
|
|
});
|
|
child.stderr.emit('data', Buffer.from('npm ERR! 404 Not Found'));
|
|
child.emit('close', 1);
|
|
|
|
const result = await promise;
|
|
expect(result.success).toBe(false);
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.stderr).toContain('404 Not Found');
|
|
});
|
|
|
|
test('reports NPM_NOT_FOUND when npm cannot be resolved', async () => {
|
|
const result = await runNpmInstall({
|
|
collectionPath: COLLECTION_DIR,
|
|
packages: ['a'],
|
|
resolveNpmInvocationFn: () => null
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.errorCode).toBe('NPM_NOT_FOUND');
|
|
expect(result.stderr).toMatch(/not found on your PATH/i);
|
|
});
|
|
|
|
test('reports NPM_NOT_FOUND when spawn fails with ENOENT', async () => {
|
|
const child = makeFakeChild();
|
|
const promise = runNpmInstall({
|
|
collectionPath: COLLECTION_DIR,
|
|
packages: ['a'],
|
|
spawnFn: () => child,
|
|
resolveNpmInvocationFn: mockNpmInvocation
|
|
});
|
|
const err = new Error('spawn node ENOENT');
|
|
err.code = 'ENOENT';
|
|
child.emit('error', err);
|
|
|
|
const result = await promise;
|
|
expect(result.success).toBe(false);
|
|
expect(result.errorCode).toBe('NPM_NOT_FOUND');
|
|
expect(result.stderr).toMatch(/not found on your PATH/i);
|
|
});
|
|
|
|
test('reports SPAWN_ERROR for non-ENOENT spawn errors', async () => {
|
|
const child = makeFakeChild();
|
|
const promise = runNpmInstall({
|
|
collectionPath: COLLECTION_DIR,
|
|
packages: ['a'],
|
|
spawnFn: () => child,
|
|
resolveNpmInvocationFn: mockNpmInvocation
|
|
});
|
|
const err = new Error('EACCES permission denied');
|
|
err.code = 'EACCES';
|
|
child.emit('error', err);
|
|
|
|
const result = await promise;
|
|
expect(result.success).toBe(false);
|
|
expect(result.errorCode).toBe('SPAWN_ERROR');
|
|
});
|
|
|
|
test('reports SPAWN_FAILED when spawn throws synchronously', async () => {
|
|
const spawnFn = jest.fn(() => {
|
|
throw new Error('boom');
|
|
});
|
|
const result = await runNpmInstall({
|
|
collectionPath: COLLECTION_DIR,
|
|
packages: ['a'],
|
|
spawnFn,
|
|
resolveNpmInvocationFn: mockNpmInvocation
|
|
});
|
|
expect(result.success).toBe(false);
|
|
expect(result.errorCode).toBe('SPAWN_FAILED');
|
|
expect(result.stderr).toContain('boom');
|
|
});
|
|
|
|
test('times out and kills the process if npm never exits', async () => {
|
|
jest.useFakeTimers();
|
|
const child = makeFakeChild();
|
|
const promise = runNpmInstall({
|
|
collectionPath: COLLECTION_DIR,
|
|
packages: ['a'],
|
|
spawnFn: () => child,
|
|
resolveNpmInvocationFn: mockNpmInvocation,
|
|
timeoutMs: 1000
|
|
});
|
|
|
|
jest.advanceTimersByTime(1000);
|
|
const result = await promise;
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.errorCode).toBe('TIMEOUT');
|
|
expect(child.kill).toHaveBeenCalled();
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
test('caps captured output to the trailing maxOutputBytes', async () => {
|
|
const child = makeFakeChild();
|
|
const promise = runNpmInstall({
|
|
collectionPath: COLLECTION_DIR,
|
|
packages: ['a'],
|
|
spawnFn: () => child,
|
|
resolveNpmInvocationFn: mockNpmInvocation,
|
|
maxOutputBytes: 10
|
|
});
|
|
child.stdout.emit('data', 'abcdefghijklmnop'); // 16 chars
|
|
child.emit('close', 0);
|
|
|
|
const result = await promise;
|
|
expect(result.stdout.length).toBeLessThanOrEqual(10);
|
|
expect(result.stdout).toBe('ghijklmnop'); // keeps the tail
|
|
});
|
|
|
|
test('only settles once even if close fires after error', async () => {
|
|
const child = makeFakeChild();
|
|
const promise = runNpmInstall({
|
|
collectionPath: COLLECTION_DIR,
|
|
packages: ['a'],
|
|
spawnFn: () => child,
|
|
resolveNpmInvocationFn: mockNpmInvocation
|
|
});
|
|
const err = new Error('spawn node ENOENT');
|
|
err.code = 'ENOENT';
|
|
child.emit('error', err);
|
|
child.emit('close', 1); // should be ignored
|
|
|
|
const result = await promise;
|
|
expect(result.errorCode).toBe('NPM_NOT_FOUND');
|
|
});
|
|
});
|