Files
bruno/packages/bruno-electron/tests/utils/install-packages.spec.js

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');
});
});