diff --git a/packages/bruno-js/src/sandbox/node-vm/cjs-loader.js b/packages/bruno-js/src/sandbox/node-vm/cjs-loader.js new file mode 100644 index 000000000..044056480 --- /dev/null +++ b/packages/bruno-js/src/sandbox/node-vm/cjs-loader.js @@ -0,0 +1,369 @@ +const vm = require('node:vm'); +const fs = require('node:fs'); +const path = require('node:path'); +const nodeModule = require('node:module'); + +const { isBuiltinModule, isPathWithinAllowedRoots } = require('./utils'); + +/** + * Resolve a local module path, handling files and directories + * Follows Node.js resolution algorithm: + * 1. Exact path (with extension) + * 2. Path + .js extension + * 3. Directory with package.json (main field) + * 4. Directory with index.js + * @param {string} fromDir - Directory to resolve from + * @param {string} moduleName - Module name/path + * @returns {string} Resolved absolute path + */ +function resolveLocalModulePath(fromDir, moduleName) { + const basePath = path.resolve(fromDir, moduleName); + + // 1. If has extension, use as-is + if (path.extname(moduleName)) { + return path.normalize(basePath); + } + + // 2. Try with .js extension + const withJs = basePath + '.js'; + if (fs.existsSync(withJs)) { + return path.normalize(withJs); + } + + // 3. Check if it's a directory + if (fs.existsSync(basePath) && fs.statSync(basePath).isDirectory()) { + // 3a. Check for package.json with main field + const pkgPath = path.join(basePath, 'package.json'); + if (fs.existsSync(pkgPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if (pkg.main) { + const mainPath = path.resolve(basePath, pkg.main); + if (fs.existsSync(mainPath)) { + return path.normalize(mainPath); + } + } + } catch { + // Ignore JSON parse errors, fall through to index.js + } + } + + // 3b. Check for index.js + const indexPath = path.join(basePath, 'index.js'); + if (fs.existsSync(indexPath)) { + return path.normalize(indexPath); + } + } + + // 4. Fall back to original path (will likely fail with file not found) + return path.normalize(basePath); +} + +/** + * Creates a custom require function with enhanced security and local module support + * @param {Object} options - Configuration options + * @param {string} options.collectionPath - Path to the collection directory + * @param {Object} options.isolatedContext - The VM isolated context created with vm.createContext() + * @param {string} options.currentModuleDir - Current module directory for resolving relative paths + * @param {Map} options.localModuleCache - Cache for loaded modules + * @param {string[]} options.additionalContextRootsAbsolute - Additional allowed root paths + * @returns {Function} Custom require function + */ +function createCustomRequire({ + collectionPath, + isolatedContext, + currentModuleDir = collectionPath, + localModuleCache = new Map(), + additionalContextRootsAbsolute = [] +}) { + return (moduleName) => { + const normalizedModuleName = moduleName.replace(/\\/g, '/'); + + // 1. Handle local modules (./path, ../path) + if (normalizedModuleName.startsWith('./') || normalizedModuleName.startsWith('../')) { + return loadLocalModule({ + moduleName: normalizedModuleName, + collectionPath, + isolatedContext, + localModuleCache, + currentModuleDir, + additionalContextRootsAbsolute + }); + } + + // 2. Handle absolute paths - route through local module security checks + // This prevents bypassing additionalContextRoots by using absolute paths + if (path.isAbsolute(normalizedModuleName)) { + return loadLocalModule({ + moduleName: normalizedModuleName, + collectionPath, + isolatedContext, + localModuleCache, + currentModuleDir, + additionalContextRootsAbsolute + }); + } + + // 3. Handle Node.js builtin modules + // Note: Builtins are loaded via native require, bypassing VM isolation. + // This is intentional - [`developer` mode] node-vm isolation need not be strict for builtins. + if (isBuiltinModule(moduleName)) { + return require(moduleName); + } + + // 4. Handle npm modules - load INTO vm context + return loadNpmModule({ + moduleName, + collectionPath, + isolatedContext, + localModuleCache + }); + }; +} + +/** + * Loads a local module from the filesystem with security checks and caching + * @param {Object} options - Configuration options + * @returns {*} The exported content of the loaded module + * @throws {Error} When module is outside collection path or cannot be loaded + */ +function loadLocalModule({ + moduleName, + collectionPath, + isolatedContext, + localModuleCache, + currentModuleDir, + additionalContextRootsAbsolute = [] +}) { + // Validate the raw module name doesn't try to escape allowed roots + const preliminaryPath = path.resolve(currentModuleDir, moduleName); + if (!isPathWithinAllowedRoots(path.normalize(preliminaryPath), additionalContextRootsAbsolute)) { + const allowedRootsDisplay = additionalContextRootsAbsolute.map((root) => ` - ${root}`).join('\n'); + throw new Error( + `Access to files outside of the allowed context roots is not allowed: ${moduleName}\n\n` + + `Allowed context roots:\n${allowedRootsDisplay}` + ); + } + + // Resolve the module path, handling files and directories + const normalizedFilePath = resolveLocalModulePath(currentModuleDir, moduleName); + + // Final security check after resolution + if (!isPathWithinAllowedRoots(normalizedFilePath, additionalContextRootsAbsolute)) { + const allowedRootsDisplay = additionalContextRootsAbsolute.map((root) => ` - ${root}`).join('\n'); + throw new Error( + `Access to files outside of the allowed context roots is not allowed: ${moduleName}\n\n` + + `Allowed context roots:\n${allowedRootsDisplay}` + ); + } + + // Check cache - we cache moduleObj, return its exports + if (localModuleCache.has(normalizedFilePath)) { + return localModuleCache.get(normalizedFilePath).exports; + } + + if (!fs.existsSync(normalizedFilePath)) { + throw new Error(`Cannot find module ${moduleName}`); + } + + const moduleCode = fs.readFileSync(normalizedFilePath, 'utf8'); + const moduleObj = { exports: {} }; + const moduleDir = path.dirname(normalizedFilePath); + + // Pre-populate cache with moduleObj BEFORE execution to handle circular dependencies + // This allows re-entrant requires to get partial exports (Node.js behavior) + // We cache moduleObj (not moduleObj.exports) so that module.exports reassignment works + localModuleCache.set(normalizedFilePath, moduleObj); + + // Create require function for nested imports + const moduleRequire = createCustomRequire({ + collectionPath, + isolatedContext, + currentModuleDir: moduleDir, + localModuleCache, + additionalContextRootsAbsolute + }); + + try { + // Wrap module code in a function that receives CJS parameters + const wrappedCode = `(function(module, exports, require, __filename, __dirname) {\n${moduleCode}\n})`; + const compiledScript = new vm.Script(wrappedCode, { filename: normalizedFilePath }); + const moduleFunction = compiledScript.runInContext(isolatedContext); + moduleFunction(moduleObj, moduleObj.exports, moduleRequire, normalizedFilePath, moduleDir); + return moduleObj.exports; + } catch (error) { + // Remove failed module from cache to allow retry + localModuleCache.delete(normalizedFilePath); + throw new Error(`Error loading local module ${moduleName}: ${error.message}`); + } +} + +/** + * Executes a module in the VM context with caching and special file handling + * @param {Object} options - Configuration options + * @returns {*} The exported content of the loaded module + * @throws {Error} When module cannot be loaded + */ +function executeModuleInVmContext({ + resolvedPath, + moduleName, + isolatedContext, + collectionPath, + localModuleCache +}) { + // Check cache - we cache moduleObj, return its exports + if (localModuleCache.has(resolvedPath)) { + return localModuleCache.get(resolvedPath).exports; + } + + // Native modules (.node files) - fall back to host require + // Note: This bypasses VM isolation for native addons. + // This is intentional - [`developer` mode] node-vm isolation need not be strict for native modules. + if (resolvedPath.endsWith('.node')) { + const result = require(resolvedPath); + // Wrap in moduleObj format for consistent cache retrieval + localModuleCache.set(resolvedPath, { exports: result }); + return result; + } + + // JSON files - parse directly + if (resolvedPath.endsWith('.json')) { + const jsonContent = fs.readFileSync(resolvedPath, 'utf8'); + const result = JSON.parse(jsonContent); + // Wrap in moduleObj format for consistent cache retrieval + localModuleCache.set(resolvedPath, { exports: result }); + return result; + } + + // JavaScript files + const moduleSource = fs.readFileSync(resolvedPath, 'utf8'); + const moduleDir = path.dirname(resolvedPath); + const moduleObj = { exports: {} }; + + // Pre-populate cache with moduleObj BEFORE execution to handle circular dependencies + // This allows re-entrant requires to get partial exports (Node.js behavior) + // We cache moduleObj (not moduleObj.exports) so that module.exports reassignment works + localModuleCache.set(resolvedPath, moduleObj); + + const moduleRequire = createNpmModuleRequire({ + collectionPath, + isolatedContext, + currentModuleDir: moduleDir, + localModuleCache + }); + + try { + // Wrap module code in a function that receives CJS parameters + const wrappedCode = `(function(module, exports, require, __filename, __dirname) {\n${moduleSource}\n})`; + const compiledScript = new vm.Script(wrappedCode, { filename: resolvedPath }); + const moduleFunction = compiledScript.runInContext(isolatedContext); + moduleFunction(moduleObj, moduleObj.exports, moduleRequire, resolvedPath, moduleDir); + } catch (error) { + // Remove failed module from cache to allow retry + localModuleCache.delete(resolvedPath); + const stack = error.stack || ''; + throw new Error(`Error loading module ${moduleName}: ${error.message}\nStack: ${stack}`); + } + + return moduleObj.exports; +} + +/** + * Loads an npm module into the vm context + * @param {Object} options - Configuration options + * @returns {*} The exported content of the loaded module + * @throws {Error} When module cannot be resolved or loaded + */ +function loadNpmModule({ + moduleName, + collectionPath, + isolatedContext, + localModuleCache +}) { + let resolvedPath; + + // Module resolution order: + // 1. Collection's node_modules (user-installed packages for their collection) + // 2. Bruno's node_modules (fallback for built-in dependencies) + // + // This order ensures user packages take precedence, allowing users to: + // - Override Bruno's bundled package versions + // - Install collection-specific dependencies + if (collectionPath) { + try { + const collectionRequire = nodeModule.createRequire(path.join(collectionPath, 'package.json')); + resolvedPath = collectionRequire.resolve(moduleName); + } catch { + // Module not found in collection, continue to fallback + } + } + + // Fall back to Bruno's node_modules + if (!resolvedPath) { + try { + resolvedPath = require.resolve(moduleName, { paths: module.paths }); + } catch (mainError) { + throw new Error( + `Could not resolve module "${moduleName}": ${mainError.message}\n\n` + + `Install it with: npm install ${moduleName}` + ); + } + } + + return executeModuleInVmContext({ + resolvedPath, + moduleName, + isolatedContext, + collectionPath, + localModuleCache + }); +} + +/** + * Creates require function for npm module dependencies + * @param {Object} options - Configuration options + * @returns {Function} Custom require function for npm module dependencies + */ +function createNpmModuleRequire({ + collectionPath, + isolatedContext, + currentModuleDir, + localModuleCache +}) { + const moduleRequire = nodeModule.createRequire(path.join(currentModuleDir, 'index.js')); + + return (moduleName) => { + // Handle relative imports within npm module + if (moduleName.startsWith('./') || moduleName.startsWith('../')) { + const resolvedPath = moduleRequire.resolve(moduleName); + return executeModuleInVmContext({ + resolvedPath, + moduleName, + isolatedContext, + collectionPath, + localModuleCache + }); + } + + // Handle builtins + // Note: Builtins are loaded via native require, bypassing VM isolation. + // This is intentional - [`developer` mode] node-vm isolation need not be strict for builtins. + if (isBuiltinModule(moduleName)) { + return require(moduleName); + } + + // Handle npm dependencies - resolve from current module's directory + const resolvedPath = moduleRequire.resolve(moduleName); + return executeModuleInVmContext({ + resolvedPath, + moduleName, + isolatedContext, + collectionPath, + localModuleCache + }); + }; +} + +module.exports = { + createCustomRequire +}; diff --git a/packages/bruno-js/src/sandbox/node-vm/constants.js b/packages/bruno-js/src/sandbox/node-vm/constants.js new file mode 100644 index 000000000..97eb106bb --- /dev/null +++ b/packages/bruno-js/src/sandbox/node-vm/constants.js @@ -0,0 +1,99 @@ +/** + * Constants for the Node.js VM sandbox. + * + * ECMAScript built-ins (Object, Array, Function, etc.) + * are NOT passed from the host. The VM provides its own versions, ensuring + * consistent prototype chains for libraries that use introspection. + * + * Handled separately in index.js: + * - global/globalThis: Points to isolated context (not host) + * - require: createCustomRequire() (custom module loader) + */ + +/** + * Safe globals to pass from host to VM context. + * + * ECMAScript built-ins (Object, Array, Function, String, Number, + * Boolean, Symbol, Date, RegExp, Map, Set, Promise, JSON, Math, + * parseInt, etc.) are intentionally NOT included here. + * + * The VM context provides its own versions of these, which ensures consistent + * prototype chains. Passing host versions causes prototype mismatches. + * + * Only Node.js-specific and Web APIs that the VM doesn't provide are listed. + */ +const safeGlobals = [ + 'process', + + // Node.js timers (not part of ECMAScript) + 'setTimeout', + 'setInterval', + 'clearTimeout', + 'clearInterval', + 'setImmediate', + 'clearImmediate', + 'queueMicrotask', + + // Node.js globals + 'Buffer', + + // Error types - needed for instanceof checks with errors from host APIs/modules + 'Error', + 'TypeError', + 'ReferenceError', + 'SyntaxError', + 'RangeError', + 'URIError', + 'EvalError', + 'AggregateError', + + // URL APIs (WHATWG - not ECMAScript) + 'URL', + 'URLSearchParams', + + // Encoding APIs + 'TextEncoder', + 'TextDecoder', + 'atob', + 'btoa', + + // Fetch API (Node 18+) + 'fetch', + 'Request', + 'Response', + 'Headers', + 'FormData', + 'AbortController', + 'AbortSignal', + 'Blob', + + // Streams API + 'ReadableStream', + 'WritableStream', + 'TransformStream', + + // Internationalization (needs host's locale data) + 'Intl', + + // Web Crypto API + 'crypto', + + // WebAssembly + 'WebAssembly', + + // Performance API + 'performance', + + // Events API + 'Event', + 'EventTarget', + 'CustomEvent', + + // Message passing + 'MessageChannel', + 'MessagePort' +]; + +module.exports = { + safeGlobals +}; diff --git a/packages/bruno-js/src/sandbox/node-vm/index.js b/packages/bruno-js/src/sandbox/node-vm/index.js index ac7a3c095..011bb4fcd 100644 --- a/packages/bruno-js/src/sandbox/node-vm/index.js +++ b/packages/bruno-js/src/sandbox/node-vm/index.js @@ -1,43 +1,31 @@ const vm = require('node:vm'); -const fs = require('node:fs'); const path = require('node:path'); const { get } = require('lodash'); const lodash = require('lodash'); -const { mixinTypedArrays } = require('../mixins/typed-arrays'); const { wrapConsoleWithSerializers } = require('./console'); - -class ScriptError extends Error { - constructor(error, script) { - super(error.message); - this.name = 'ScriptError'; - this.originalError = error; - this.script = script; - this.stack = error.stack; - } -} +const { ScriptError } = require('./utils'); +const { createCustomRequire } = require('./cjs-loader'); +const { safeGlobals } = require('./constants'); +const { mixinTypedArrays } = require('../mixins/typed-arrays'); /** * Executes a script in a Node.js VM context with enhanced security and module loading + * * @param {Object} options - Configuration options * @param {string} options.script - The script code to execute * @param {Object} options.context - The execution context with Bruno objects * @param {string} options.collectionPath - Path to the collection directory * @param {Object} options.scriptingConfig - Scripting configuration options - * @returns {Promise} Execution results including variables and test results + * @returns {Promise} * @throws {ScriptError} When script execution fails */ -async function runScriptInNodeVm({ - script, - context, - collectionPath, - scriptingConfig -}) { +async function runScriptInNodeVm({ script, context, collectionPath, scriptingConfig }) { if (script.trim().length === 0) { return; } try { - // Compute additional context roots + // Compute allowed context roots for security validation const additionalContextRoots = get(scriptingConfig, 'additionalContextRoots', []); const additionalContextRootsAbsolute = lodash .chain(additionalContextRoots) @@ -46,196 +34,70 @@ async function runScriptInNodeVm({ .value(); additionalContextRootsAbsolute.push(path.normalize(collectionPath)); - // Create script context with all necessary variables - const scriptContext = { - // Bruno context (wrap console with Set/Map support) - console: wrapConsoleWithSerializers(context.console), - req: context.req, - res: context.res, - bru: context.bru, - expect: context.expect, - assert: context.assert, - __brunoTestResults: context.__brunoTestResults, - test: context.test, - // Configuration for nested module loading - scriptingConfig: scriptingConfig, - // Global objects - Buffer: global.Buffer, - process: global.process, - setTimeout: global.setTimeout, - setInterval: global.setInterval, - clearTimeout: global.clearTimeout, - clearInterval: global.clearInterval, - setImmediate: global.setImmediate, - clearImmediate: global.clearImmediate, - Error: global.Error, - TypeError: global.TypeError, - ReferenceError: global.ReferenceError, - SyntaxError: global.SyntaxError, - RangeError: global.RangeError - }; + // Build the script context with Bruno objects and globals + const scriptContext = buildScriptContext(context, scriptingConfig); - mixinTypedArrays(scriptContext); + // Create truly isolated context - scriptContext becomes the global object + // Scripts can ONLY access what's explicitly in scriptContext + const isolatedContext = vm.createContext(scriptContext); - // Create shared cache for local modules + // Add global/globalThis pointing to the isolated context (not host global) + // This allows libraries that reference 'global' to work while maintaining isolation + scriptContext.global = scriptContext; + scriptContext.globalThis = scriptContext; + + // Create module cache for CJS modules const localModuleCache = new Map(); - // Create a custom require function and add it to the context + // Add require() function for CJS module loading scriptContext.require = createCustomRequire({ - scriptingConfig, collectionPath, - scriptContext, + isolatedContext, currentModuleDir: collectionPath, localModuleCache, additionalContextRootsAbsolute }); - // Execute the script in an isolated VM context - await vm.runInNewContext(` - (async function(){ - ${script} - })(); - `, scriptContext, { - filename: path.join(collectionPath, 'script.js'), - displayErrors: true + // Execute the script in the isolated context + const wrappedScript = `(async function(){ ${script} \n})();`; + const compiledScript = new vm.Script(wrappedScript, { + filename: path.join(collectionPath, 'script.js') }); + + await compiledScript.runInContext(isolatedContext); } catch (error) { throw new ScriptError(error, script); } - - return; } /** - * Creates a custom require function with enhanced security and local module support - * @param {Object} options - Configuration options - * @param {Object} options.scriptingConfig - Scripting configuration with additional context roots - * @param {string} options.collectionPath - Base collection path for security checks - * @param {Object} options.scriptContext - Script execution context - * @param {string} options.currentModuleDir - Current module directory for relative imports - * @param {Map} options.localModuleCache - Cache for loaded local modules - * @param {Array} options.additionalContextRootsAbsolute - Pre-computed absolute context roots - * @returns {Function} Custom require function + * Build the script context with Bruno objects and necessary globals + * @param {Object} context - Bruno context (bru, req, res, etc.) + * @param {Object} scriptingConfig - Scripting configuration + * @returns {Object} Script context object */ -function createCustomRequire({ - scriptingConfig, - collectionPath, - scriptContext, - currentModuleDir = collectionPath, - localModuleCache = new Map(), - additionalContextRootsAbsolute = [] -}) { - return (moduleName) => { - // Check if it's a local module (starts with ./ or ../ or .\ or ..\) - // Normalize backslashes to forward slashes for cross-platform compatibility - const normalizedModuleName = moduleName.replace(/\\/g, '/'); - if (normalizedModuleName.startsWith('./') || normalizedModuleName.startsWith('../')) { - return loadLocalModule({ moduleName: normalizedModuleName, collectionPath, scriptContext, localModuleCache, currentModuleDir, additionalContextRootsAbsolute }); - } +function buildScriptContext(context, scriptingConfig) { + const scriptContext = { + ...context, - // First try to require as a native/npm module - try { - const requiredModulePath = require.resolve(moduleName, { paths: [...additionalContextRootsAbsolute, ...module.paths] }); - return require(requiredModulePath); - } catch (requireError) { - // If that fails, try to resolve from additionalContextRoots - throw new Error(`Could not resolve module "${moduleName}": ${requireError.message}\n\nThis most likely means you did not install the module under the collection or the "additionalContextRoots" using a package manager like npm.\n\nThese are your current "additionalContextRoots":\n${additionalContextRootsAbsolute.map((root) => ` - ${root}`).join('\n') || ' - No "additionalContextRoots" defined'}`); - } - }; -} + // Bruno context (wrap console with Set/Map support) + console: wrapConsoleWithSerializers(context.console), -/** - * Loads a local module from the filesystem with security checks and caching - * @param {Object} options - Configuration options - * @param {string} options.moduleName - Name/path of the module to load - * @param {string} options.collectionPath - Base collection path for security validation - * @param {Object} options.scriptContext - Script execution context to inherit - * @param {Map} options.localModuleCache - Cache for loaded modules - * @param {string} options.currentModuleDir - Directory of the current module for relative resolution - * @param {Array} options.additionalContextRootsAbsolute - Additional allowed context root paths - * @returns {*} The exported content of the loaded module - * @throws {Error} When module is outside collection path or cannot be loaded - */ -function loadLocalModule({ - moduleName, - collectionPath, - scriptContext, - localModuleCache, - currentModuleDir, - additionalContextRootsAbsolute = [] -}) { - // Check if the filename has an extension - const hasExtension = path.extname(moduleName) !== ''; - const resolvedFilename = hasExtension ? moduleName : `${moduleName}.js`; + // Configuration for nested module loading + scriptingConfig: scriptingConfig, - // Resolve the file path relative to the current module's directory - const filePath = path.resolve(currentModuleDir, resolvedFilename); - const normalizedFilePath = path.normalize(filePath); - - const isWithinAllowedRoot = additionalContextRootsAbsolute.some((allowedRoot) => { - const normalizedAllowedRoot = path.normalize(allowedRoot); - const relativePath = path.relative(normalizedAllowedRoot, normalizedFilePath); - return !relativePath.startsWith('..') && !path.isAbsolute(relativePath); - }); - - if (!isWithinAllowedRoot) { - const allowedRootsDisplay = additionalContextRootsAbsolute.map((root) => ` - ${root}`).join('\n'); - throw new Error( - `Access to files outside of the allowed context roots is not allowed: ${moduleName}\n\n` - + `Allowed context roots:\n${allowedRootsDisplay}` - ); - } - - // Check cache first (use normalized path as key) - if (localModuleCache.has(normalizedFilePath)) { - return localModuleCache.get(normalizedFilePath); - } - - if (!fs.existsSync(normalizedFilePath)) { - throw new Error(`Cannot find module ${moduleName}`); - } - - // Read and execute the local module - const moduleCode = fs.readFileSync(normalizedFilePath, 'utf8'); - - // Create module object - const moduleObj = { exports: {} }; - - // Get the directory of this module for nested imports - const moduleDir = path.dirname(normalizedFilePath); - - // Create a new context that inherits from the script context - const moduleContext = { - ...scriptContext, - module: moduleObj, - exports: moduleObj.exports, - __filename: normalizedFilePath, - __dirname: moduleDir, - // Create a custom require function for this module that resolves relative to its directory - require: createCustomRequire({ - scriptingConfig: scriptContext.scriptingConfig || {}, - collectionPath, - scriptContext, - currentModuleDir: moduleDir, - localModuleCache, - additionalContextRootsAbsolute - }) + // Safe globals from allowlist (Node.js/Web APIs only, not ECMAScript built-ins) + ...Object.fromEntries( + safeGlobals + .filter((key) => global[key] !== undefined) + .map((key) => [key, global[key]]) + ) }; - try { - // Execute the module code in the shared context - vm.runInNewContext(moduleCode, moduleContext, { - filename: normalizedFilePath, - displayErrors: true - }); + // Add TypedArrays from host for compatibility with host APIs (TextEncoder, crypto, etc.) + mixinTypedArrays(scriptContext); - // Cache the result using normalized path - localModuleCache.set(normalizedFilePath, moduleObj.exports); - - return moduleObj.exports; - } catch (error) { - throw new Error(`Error loading local module ${moduleName}: ${error.message}`); - } + return scriptContext; } module.exports = { diff --git a/packages/bruno-js/src/sandbox/node-vm/index.spec.js b/packages/bruno-js/src/sandbox/node-vm/index.spec.js index 0b71d41da..bf74ae19e 100644 --- a/packages/bruno-js/src/sandbox/node-vm/index.spec.js +++ b/packages/bruno-js/src/sandbox/node-vm/index.spec.js @@ -106,6 +106,43 @@ describe('node-vm sandbox', () => { runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }) ).rejects.toThrow('Access to files outside of the allowed context roots is not allowed'); }); + + it('should block absolute paths outside allowed roots', async () => { + // Try to require an absolute path outside the collection + const script = ` + const secret = require('/etc/passwd'); + `; + + const context = { console: console }; + + await expect( + runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }) + ).rejects.toThrow('Access to files outside of the allowed context roots is not allowed'); + }); + + it('should allow absolute paths within allowed roots', async () => { + // Create a module in the collection + fs.writeFileSync( + path.join(collectionPath, 'absolute-test.js'), + 'module.exports = { loaded: true };' + ); + + // Use absolute path to require it + const absolutePath = path.join(collectionPath, 'absolute-test.js'); + const script = ` + const mod = require('${absolutePath.replace(/\\/g, '\\\\')}'); + bru.setVar('result', mod.loaded); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', true); + }); }); describe('createCustomRequire - additionalContextRoots', () => { @@ -225,18 +262,23 @@ describe('node-vm sandbox', () => { describe('createCustomRequire - module caching', () => { it('should cache loaded modules', async () => { - let callCount = 0; + // Module increments a counter each time it's executed + // If caching works, counter should only be 1 after multiple requires fs.writeFileSync( path.join(collectionPath, 'cached.js'), ` - module.exports = { count: ${++callCount} }; + if (!global._cacheTestCount) global._cacheTestCount = 0; + global._cacheTestCount++; + module.exports = { id: Date.now() }; ` ); const script = ` const mod1 = require('./cached'); const mod2 = require('./cached'); - bru.setVar('same', mod1.count === mod2.count); + const mod3 = require('./cached'); + bru.setVar('sameInstance', mod1 === mod2 && mod2 === mod3); + bru.setVar('loadCount', global._cacheTestCount); `; const context = { @@ -246,7 +288,911 @@ describe('node-vm sandbox', () => { await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); - expect(context.bru.setVar).toHaveBeenCalledWith('same', true); + // All requires should return the same cached instance + expect(context.bru.setVar).toHaveBeenCalledWith('sameInstance', true); + // Module should only be executed once + expect(context.bru.setVar).toHaveBeenCalledWith('loadCount', 1); + }); + + it('should handle circular dependencies', async () => { + // Create two modules that require each other + fs.writeFileSync( + path.join(collectionPath, 'circularA.js'), + ` + exports.name = 'A'; + const B = require('./circularB'); + exports.fromB = B.name; + ` + ); + fs.writeFileSync( + path.join(collectionPath, 'circularB.js'), + ` + exports.name = 'B'; + const A = require('./circularA'); + exports.fromA = A.name; + ` + ); + + const script = ` + const A = require('./circularA'); + // A loads first, sets exports.name='A', then requires B + // B loads, sets exports.name='B', requires A (gets partial: {name:'A'}) + // B finishes with {name:'B', fromA:'A'} + // A finishes with {name:'A', fromB:'B'} + bru.setVar('result', A.name + '-' + A.fromB); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 'A-B'); + }); + }); + + describe('createCustomRequire - Node.js builtin modules', () => { + it('should load builtin modules (crypto)', async () => { + const script = ` + const crypto = require('crypto'); + bru.setVar('result', typeof crypto.createHash); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 'function'); + }); + + it('should support node: prefix syntax', async () => { + const script = ` + const path = require('node:path'); + bru.setVar('result', typeof path.join); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 'function'); + }); + + it('should allow all builtin modules including fs', async () => { + const script = ` + const fs = require('fs'); + bru.setVar('result', typeof fs.readFileSync); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 'function'); + }); + + it('should load multiple builtins', async () => { + const script = ` + const url = require('url'); + const util = require('util'); + const buffer = require('buffer'); + const fs = require('fs'); + bru.setVar('result', typeof url.parse + '-' + typeof util.format + '-' + typeof buffer.Buffer + '-' + typeof fs.readFileSync); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 'function-function-function-function'); + }); + }); + + describe('createCustomRequire - npm modules in vm context', () => { + it('should load npm modules from collection into vm context', async () => { + // Create a mock npm module in collection's node_modules + const nodeModulesDir = path.join(collectionPath, 'node_modules', 'test-module'); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeModulesDir, 'index.js'), + 'module.exports = { name: "test-module", value: 123 };' + ); + + const script = ` + const testMod = require('test-module'); + bru.setVar('result', testMod.name + '-' + testMod.value); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 'test-module-123'); + }); + + it('should handle npm module with dependencies', async () => { + // Create a mock npm module with internal dependencies + const nodeModulesDir = path.join(collectionPath, 'node_modules', 'parent-module'); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeModulesDir, 'helper.js'), + 'module.exports = { helper: true };' + ); + fs.writeFileSync( + path.join(nodeModulesDir, 'index.js'), + 'const helper = require("./helper"); module.exports = { hasHelper: helper.helper };' + ); + + const script = ` + const parentMod = require('parent-module'); + bru.setVar('result', parentMod.hasHelper); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', true); + }); + + it('should provide bru object to npm modules', async () => { + const nodeModulesDir = path.join(collectionPath, 'node_modules', 'bru-access-module'); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeModulesDir, 'index.js'), + `module.exports = { + getEnvVar: function(name) { return bru.getEnvVar(name); }, + setVar: function(name, value) { bru.setVar(name, value); } + };` + ); + + const script = ` + const bruModule = require('bru-access-module'); + const envValue = bruModule.getEnvVar('TEST_VAR'); + bruModule.setVar('result', envValue); + `; + + const getEnvVarMock = jest.fn().mockReturnValue('test-value'); + const setVarMock = jest.fn(); + const context = { + bru: { + getEnvVar: getEnvVarMock, + setVar: setVarMock + }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(getEnvVarMock).toHaveBeenCalledWith('TEST_VAR'); + expect(setVarMock).toHaveBeenCalledWith('result', 'test-value'); + }); + + it('should provide req object to npm modules', async () => { + const nodeModulesDir = path.join(collectionPath, 'node_modules', 'req-access-module'); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeModulesDir, 'index.js'), + `module.exports = { + getUrl: function() { return req.getUrl(); }, + getMethod: function() { return req.getMethod(); }, + setHeader: function(name, value) { req.setHeader(name, value); } + };` + ); + + const script = ` + const reqModule = require('req-access-module'); + const url = reqModule.getUrl(); + const method = reqModule.getMethod(); + reqModule.setHeader('X-Custom', 'value'); + bru.setVar('result', method + ':' + url); + `; + + const setVarMock = jest.fn(); + const getUrlMock = jest.fn().mockReturnValue('https://api.example.com'); + const getMethodMock = jest.fn().mockReturnValue('POST'); + const setHeaderMock = jest.fn(); + const context = { + bru: { setVar: setVarMock }, + req: { + getUrl: getUrlMock, + getMethod: getMethodMock, + setHeader: setHeaderMock + }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(getUrlMock).toHaveBeenCalled(); + expect(getMethodMock).toHaveBeenCalled(); + expect(setHeaderMock).toHaveBeenCalledWith('X-Custom', 'value'); + expect(setVarMock).toHaveBeenCalledWith('result', 'POST:https://api.example.com'); + }); + + it('should provide res object to npm modules', async () => { + const nodeModulesDir = path.join(collectionPath, 'node_modules', 'res-access-module'); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeModulesDir, 'index.js'), + `module.exports = { + getStatus: function() { return res.getStatus(); }, + getBody: function() { return res.getBody(); }, + getHeader: function(name) { return res.getHeader(name); } + };` + ); + + const script = ` + const resModule = require('res-access-module'); + const status = resModule.getStatus(); + const body = resModule.getBody(); + const contentType = resModule.getHeader('content-type'); + bru.setVar('result', status + ':' + contentType + ':' + body.message); + `; + + const context = { + bru: { setVar: jest.fn() }, + res: { + getStatus: jest.fn().mockReturnValue(200), + getBody: jest.fn().mockReturnValue({ message: 'success' }), + getHeader: jest.fn().mockReturnValue('application/json') + }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.res.getStatus).toHaveBeenCalled(); + expect(context.res.getBody).toHaveBeenCalled(); + expect(context.res.getHeader).toHaveBeenCalledWith('content-type'); + expect(context.bru.setVar).toHaveBeenCalledWith('result', '200:application/json:success'); + }); + + it('should provide bru, req, res to nested npm module dependencies', async () => { + // Create parent module + const parentDir = path.join(collectionPath, 'node_modules', 'parent-ctx-module'); + fs.mkdirSync(parentDir, { recursive: true }); + fs.writeFileSync( + path.join(parentDir, 'index.js'), + `const child = require('./child'); + module.exports = { childResult: child.getData() };` + ); + // Create child module that accesses context + fs.writeFileSync( + path.join(parentDir, 'child.js'), + `module.exports = { + getData: function() { + return { + envVar: bru.getEnvVar('NESTED_VAR'), + reqUrl: req.getUrl(), + resStatus: res.getStatus() + }; + } + };` + ); + + const script = ` + const parent = require('parent-ctx-module'); + const data = parent.childResult; + bru.setVar('result', data.envVar + '|' + data.reqUrl + '|' + data.resStatus); + `; + + const getEnvVarMock = jest.fn().mockReturnValue('nested-value'); + const setVarMock = jest.fn(); + const getUrlMock = jest.fn().mockReturnValue('https://nested.example.com'); + const getStatusMock = jest.fn().mockReturnValue(201); + const context = { + bru: { + getEnvVar: getEnvVarMock, + setVar: setVarMock + }, + req: { + getUrl: getUrlMock + }, + res: { + getStatus: getStatusMock + }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(getEnvVarMock).toHaveBeenCalledWith('NESTED_VAR'); + expect(getUrlMock).toHaveBeenCalled(); + expect(getStatusMock).toHaveBeenCalled(); + expect(setVarMock).toHaveBeenCalledWith('result', 'nested-value|https://nested.example.com|201'); + }); + + describe('CommonJS module patterns', () => { + it('should handle module.exports = object pattern', async () => { + const nodeModulesDir = path.join(collectionPath, 'node_modules', 'cjs-object'); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeModulesDir, 'index.js'), + 'module.exports = { foo: "bar", num: 42 };' + ); + + const script = ` + const mod = require('cjs-object'); + bru.setVar('result', mod.foo + '-' + mod.num); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 'bar-42'); + }); + + it('should handle module.exports = function pattern', async () => { + const nodeModulesDir = path.join(collectionPath, 'node_modules', 'cjs-function'); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeModulesDir, 'index.js'), + 'module.exports = function(x) { return x * 2; };' + ); + + const script = ` + const double = require('cjs-function'); + bru.setVar('result', double(21)); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 42); + }); + + it('should handle module.exports = class pattern', async () => { + const nodeModulesDir = path.join(collectionPath, 'node_modules', 'cjs-class'); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeModulesDir, 'index.js'), + `class Calculator { + constructor(val) { this.val = val; } + add(x) { return this.val + x; } + } + module.exports = Calculator;` + ); + + const script = ` + const Calculator = require('cjs-class'); + const calc = new Calculator(10); + bru.setVar('result', calc.add(5)); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 15); + }); + + it('should handle exports.property pattern', async () => { + const nodeModulesDir = path.join(collectionPath, 'node_modules', 'cjs-exports'); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeModulesDir, 'index.js'), + `exports.add = function(a, b) { return a + b; }; + exports.multiply = function(a, b) { return a * b; }; + exports.VERSION = '1.0.0';` + ); + + const script = ` + const math = require('cjs-exports'); + bru.setVar('result', math.add(2, 3) + '-' + math.multiply(4, 5) + '-' + math.VERSION); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', '5-20-1.0.0'); + }); + + it('should handle mixed module.exports and exports pattern', async () => { + const nodeModulesDir = path.join(collectionPath, 'node_modules', 'cjs-mixed'); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeModulesDir, 'index.js'), + `// module.exports takes precedence + exports.ignored = 'this will be ignored'; + module.exports = { actual: 'value' };` + ); + + const script = ` + const mod = require('cjs-mixed'); + bru.setVar('result', mod.actual + '-' + (mod.ignored || 'undefined')); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 'value-undefined'); + }); + }); + + describe('File extension handling', () => { + it('should load .cjs files as CommonJS', async () => { + const nodeModulesDir = path.join(collectionPath, 'node_modules', 'cjs-ext-module'); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeModulesDir, 'package.json'), + '{"name": "cjs-ext-module", "main": "index.cjs"}' + ); + fs.writeFileSync( + path.join(nodeModulesDir, 'index.cjs'), + 'module.exports = { format: "cjs", value: 100 };' + ); + + const script = ` + const mod = require('cjs-ext-module'); + bru.setVar('result', mod.format + '-' + mod.value); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 'cjs-100'); + }); + + it('should fail when loading .mjs files (ES modules)', async () => { + const nodeModulesDir = path.join(collectionPath, 'node_modules', 'mjs-ext-module'); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeModulesDir, 'package.json'), + '{"name": "mjs-ext-module", "main": "index.mjs"}' + ); + fs.writeFileSync( + path.join(nodeModulesDir, 'index.mjs'), + 'export default { format: "esm" };' + ); + + const script = ` + const mod = require('mjs-ext-module'); + `; + + const context = { console: console }; + + await expect( + runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }) + ).rejects.toThrow(); + }); + + it('should load module with package.json main field', async () => { + const nodeModulesDir = path.join(collectionPath, 'node_modules', 'custom-main'); + fs.mkdirSync(path.join(nodeModulesDir, 'lib'), { recursive: true }); + fs.writeFileSync( + path.join(nodeModulesDir, 'package.json'), + '{"name": "custom-main", "main": "lib/entry.js"}' + ); + fs.writeFileSync( + path.join(nodeModulesDir, 'lib', 'entry.js'), + 'module.exports = { entry: "custom-main-lib" };' + ); + + const script = ` + const mod = require('custom-main'); + bru.setVar('result', mod.entry); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 'custom-main-lib'); + }); + + it('should require relative .cjs files within npm module', async () => { + const nodeModulesDir = path.join(collectionPath, 'node_modules', 'cjs-relative'); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeModulesDir, 'helper.cjs'), + 'module.exports = { helperValue: "from-cjs" };' + ); + fs.writeFileSync( + path.join(nodeModulesDir, 'index.js'), + 'const helper = require("./helper.cjs"); module.exports = helper;' + ); + + const script = ` + const mod = require('cjs-relative'); + bru.setVar('result', mod.helperValue); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 'from-cjs'); + }); + + it('should load .json files directly', async () => { + const nodeModulesDir = path.join(collectionPath, 'node_modules', 'json-direct'); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeModulesDir, 'package.json'), + '{"name": "json-direct", "main": "data.json"}' + ); + fs.writeFileSync( + path.join(nodeModulesDir, 'data.json'), + '{"type": "json-main", "count": 42}' + ); + + const script = ` + const data = require('json-direct'); + bru.setVar('result', data.type + '-' + data.count); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 'json-main-42'); + }); + }); + + describe('JSON file handling', () => { + it('should load JSON files from npm modules', async () => { + const nodeModulesDir = path.join(collectionPath, 'node_modules', 'json-module'); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeModulesDir, 'config.json'), + '{"name": "test-config", "version": "1.0.0", "enabled": true}' + ); + fs.writeFileSync( + path.join(nodeModulesDir, 'index.js'), + 'const config = require("./config.json"); module.exports = config;' + ); + + const script = ` + const config = require('json-module'); + bru.setVar('result', config.name + '-' + config.version + '-' + config.enabled); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 'test-config-1.0.0-true'); + }); + + it('should handle nested JSON requires', async () => { + const nodeModulesDir = path.join(collectionPath, 'node_modules', 'nested-json'); + fs.mkdirSync(path.join(nodeModulesDir, 'data'), { recursive: true }); + fs.writeFileSync( + path.join(nodeModulesDir, 'data', 'schema.json'), + '{"type": "object", "properties": {"id": {"type": "number"}}}' + ); + fs.writeFileSync( + path.join(nodeModulesDir, 'index.js'), + 'const schema = require("./data/schema.json"); module.exports = { schema };' + ); + + const script = ` + const mod = require('nested-json'); + bru.setVar('result', mod.schema.type + '-' + mod.schema.properties.id.type); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 'object-number'); + }); + }); + + describe('Node.js globals in npm modules', () => { + it('should have access to Buffer', async () => { + const nodeModulesDir = path.join(collectionPath, 'node_modules', 'buffer-module'); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeModulesDir, 'index.js'), + `module.exports = { + encode: function(str) { return Buffer.from(str).toString('base64'); }, + decode: function(b64) { return Buffer.from(b64, 'base64').toString('utf8'); } + };` + ); + + const script = ` + const bufMod = require('buffer-module'); + const encoded = bufMod.encode('hello'); + const decoded = bufMod.decode(encoded); + bru.setVar('result', encoded + '-' + decoded); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 'aGVsbG8=-hello'); + }); + + it('should have access to URL and URLSearchParams', async () => { + const nodeModulesDir = path.join(collectionPath, 'node_modules', 'url-module'); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeModulesDir, 'index.js'), + `module.exports = { + parseUrl: function(urlStr) { + const url = new URL(urlStr); + return url.hostname; + }, + buildQuery: function(params) { + const search = new URLSearchParams(params); + return search.toString(); + } + };` + ); + + const script = ` + const urlMod = require('url-module'); + bru.setVar('result', urlMod.parseUrl('https://example.com/path')); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 'example.com'); + }); + + it('should have access to setTimeout/clearTimeout', async () => { + const nodeModulesDir = path.join(collectionPath, 'node_modules', 'timer-module'); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeModulesDir, 'index.js'), + `module.exports = { + hasTimers: function() { + return typeof setTimeout === 'function' && typeof clearTimeout === 'function'; + } + };` + ); + + const script = ` + const timerMod = require('timer-module'); + bru.setVar('result', timerMod.hasTimers()); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', true); + }); + }); + + describe('Error handling', () => { + it('should throw error for non-existent module', async () => { + const script = ` + const mod = require('non-existent-module-xyz'); + `; + + const context = { console: console }; + + await expect( + runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }) + ).rejects.toThrow('Could not resolve module'); + }); + + it('should throw error for module with syntax error', async () => { + const nodeModulesDir = path.join(collectionPath, 'node_modules', 'syntax-error-module'); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeModulesDir, 'index.js'), + 'module.exports = { invalid syntax here' + ); + + const script = ` + const mod = require('syntax-error-module'); + `; + + const context = { console: console }; + + await expect( + runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }) + ).rejects.toThrow(); + }); + + it('should throw error for module with runtime error', async () => { + const nodeModulesDir = path.join(collectionPath, 'node_modules', 'runtime-error-module'); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeModulesDir, 'index.js'), + 'throw new Error("Module initialization failed");' + ); + + const script = ` + const mod = require('runtime-error-module'); + `; + + const context = { console: console }; + + await expect( + runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }) + ).rejects.toThrow('Module initialization failed'); + }); + }); + }); + + describe('context isolation', () => { + it('should have global pointing to isolated context (not host)', async () => { + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + // global exists but points to isolated context, so global.bru should exist + // process is a sanitized object in the isolated context + const script = `bru.setVar('result', typeof global.bru === 'object' && typeof global.process === 'object')`; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', true); + }); + + it('should not have access to host fs module via globalThis', async () => { + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + const script = `bru.setVar('result', typeof globalThis.fs)`; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 'undefined'); + }); + + it('should throw ReferenceError for undeclared variables', async () => { + const context = { console: console }; + + const script = `const x = someUndeclaredVar`; + + await expect( + runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }) + ).rejects.toThrow('someUndeclaredVar is not defined'); + }); + + it('should have access to context objects via globalThis', async () => { + const context = { + bru: { setVar: jest.fn() }, + req: { url: 'http://test.com' }, + console: console + }; + + const script = `bru.setVar('result', typeof globalThis.req)`; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 'object'); + }); + + it('should have access to allowed globals like Buffer', async () => { + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + const script = `bru.setVar('result', typeof globalThis.Buffer)`; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 'function'); + }); + + it('should have access to process object with nextTick', async () => { + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + const script = ` + const hasSafeProps = typeof process.version === 'string' && typeof process.platform === 'string'; + const hasNextTick = typeof process.nextTick === 'function'; + bru.setVar('result', hasSafeProps && hasNextTick); + `; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', true); + }); + + it('should work with Array.isArray across context boundaries', async () => { + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + const script = ` + const arr = [1, 2, 3]; + bru.setVar('result', Array.isArray(arr)); + `; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', true); + }); + + it('should have working Object methods', async () => { + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + const script = ` + const obj = { a: 1, b: 2 }; + bru.setVar('result', Object.keys(obj).join(',')); + `; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 'a,b'); }); }); }); diff --git a/packages/bruno-js/src/sandbox/node-vm/utils.js b/packages/bruno-js/src/sandbox/node-vm/utils.js new file mode 100644 index 000000000..3a8774ef4 --- /dev/null +++ b/packages/bruno-js/src/sandbox/node-vm/utils.js @@ -0,0 +1,42 @@ +const path = require('node:path'); +const nodeModule = require('node:module'); + +/** + * Check if a module is a Node.js builtin + * @param {string} moduleName - Module name to check + * @returns {boolean} True if module is a builtin + */ +function isBuiltinModule(moduleName) { + const normalized = moduleName.startsWith('node:') ? moduleName.slice(5) : moduleName; + return nodeModule.builtinModules.includes(normalized); +} + +/** + * Validate that a path is within allowed context roots + * @param {string} normalizedPath - Normalized file path + * @param {Array} additionalContextRootsAbsolute - Allowed roots + * @returns {boolean} True if path is within allowed roots + */ +function isPathWithinAllowedRoots(normalizedPath, additionalContextRootsAbsolute) { + return additionalContextRootsAbsolute.some((allowedRoot) => { + const normalizedAllowedRoot = path.normalize(allowedRoot); + const relativePath = path.relative(normalizedAllowedRoot, normalizedPath); + return !relativePath.startsWith('..') && !path.isAbsolute(relativePath); + }); +} + +class ScriptError extends Error { + constructor(error, script) { + super(error.message); + this.name = 'ScriptError'; + this.originalError = error; + this.script = script; + this.stack = error.stack; + } +} + +module.exports = { + isBuiltinModule, + isPathWithinAllowedRoots, + ScriptError +}; diff --git a/packages/bruno-tests/additional-context-root-lib/index.js b/packages/bruno-tests/additional-context-root-lib/index.js new file mode 100644 index 000000000..830bd8c58 --- /dev/null +++ b/packages/bruno-tests/additional-context-root-lib/index.js @@ -0,0 +1,45 @@ +/** + * Utility module in additionalContextRoot to test: + * 1. Loading modules from additionalContextRoot + * 2. npm module resolution (@faker-js/faker) from collection's node_modules + * 3. Local module resolution (./lib.js) relative to additionalContextRoot + */ + +const { faker } = require('@faker-js/faker'); +const { formatName, generateGreeting } = require('./lib'); + +/** + * Generate a random user with greeting + * Tests both npm module and local module resolution + */ +function generateUser() { + const firstName = faker.person.firstName(); + const lastName = faker.person.lastName(); + const fullName = formatName(firstName, lastName); + const greeting = generateGreeting(fullName); + + return { + firstName, + lastName, + fullName, + greeting, + email: faker.internet.email({ firstName, lastName }) + }; +} + +/** + * Verify that all dependencies resolved correctly + */ +function verifyDependencies() { + return { + fakerLoaded: typeof faker === 'object' && typeof faker.person === 'object', + localModuleLoaded: typeof formatName === 'function' && typeof generateGreeting === 'function' + }; +} + +module.exports = { + generateUser, + verifyDependencies, + formatName, + generateGreeting +}; diff --git a/packages/bruno-tests/additional-context-root-lib/lib.js b/packages/bruno-tests/additional-context-root-lib/lib.js new file mode 100644 index 000000000..06b724320 --- /dev/null +++ b/packages/bruno-tests/additional-context-root-lib/lib.js @@ -0,0 +1,16 @@ +/** + * Simple local module to test local module resolution from additionalContextRoot + */ + +function formatName(firstName, lastName) { + return `${firstName} ${lastName}`; +} + +function generateGreeting(name) { + return `Hello, ${name}!`; +} + +module.exports = { + formatName, + generateGreeting +}; diff --git a/packages/bruno-tests/collection/bruno.json b/packages/bruno-tests/collection/bruno.json index bf402caf3..09a346b4a 100644 --- a/packages/bruno-tests/collection/bruno.json +++ b/packages/bruno-tests/collection/bruno.json @@ -15,7 +15,8 @@ "bypassProxy": "" }, "scripts": { - "moduleWhitelist": ["crypto", "buffer", "form-data"] + "moduleWhitelist": ["crypto", "buffer", "form-data"], + "additionalContextRoots": ["../additional-context-root-lib"] }, "clientCertificates": { "enabled": true, diff --git a/packages/bruno-tests/collection/package-lock.json b/packages/bruno-tests/collection/package-lock.json index 4d1b9f464..31c2cdd01 100644 --- a/packages/bruno-tests/collection/package-lock.json +++ b/packages/bruno-tests/collection/package-lock.json @@ -9,10 +9,17 @@ "version": "0.0.1", "dependencies": { "@faker-js/faker": "^8.4.0", + "ajv": "~8.17.1", + "external-lib-with-bru-req-res-objects": "file:../external-lib-with-bru-req-res-objects", + "jose": "^5.2.0", "jsonwebtoken": "^9.0.3", "lru-map-cache": "^0.1.0" } }, + "../external-lib-with-bru-req-res-objects": { + "name": "@usebruno/external-lib-with-bru-req-res-objects", + "version": "0.0.1" + }, "node_modules/@faker-js/faker": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.0.tgz", @@ -28,6 +35,22 @@ "npm": ">=6.14.13" } }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -43,6 +66,47 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/external-lib-with-bru-req-res-objects": { + "resolved": "../external-lib-with-bru-req-res-objects", + "link": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -142,6 +206,15 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", diff --git a/packages/bruno-tests/collection/package.json b/packages/bruno-tests/collection/package.json index 756b67fdb..795a3d2e2 100644 --- a/packages/bruno-tests/collection/package.json +++ b/packages/bruno-tests/collection/package.json @@ -3,6 +3,9 @@ "version": "0.0.1", "dependencies": { "@faker-js/faker": "^8.4.0", + "ajv": "~8.17.1", + "external-lib-with-bru-req-res-objects": "file:../external-lib-with-bru-req-res-objects", + "jose": "^5.2.0", "jsonwebtoken": "^9.0.3", "lru-map-cache": "^0.1.0" } diff --git a/packages/bruno-tests/collection/scripting/local modules/additional context root.bru b/packages/bruno-tests/collection/scripting/local modules/additional context root.bru new file mode 100644 index 000000000..a774df614 --- /dev/null +++ b/packages/bruno-tests/collection/scripting/local modules/additional context root.bru @@ -0,0 +1,83 @@ +meta { + name: additional context root + type: http + seq: 4 +} + +post { + url: {{host}}/api/echo/json + body: json + auth: none +} + +body:json { + { + "test": "additionalContextRoot" + } +} + +assert { + res.status: eq 200 +} + +script:pre-request { + // Load module from additionalContextRoot using relative path + // This tests that modules outside the collection can be loaded when configured in bruno.json + // The path "../additional-context-root-lib" is allowed because it's listed in additionalContextRoots + const additionalLib = require('../additional-context-root-lib'); + + // Verify all dependencies loaded correctly + const deps = additionalLib.verifyDependencies(); + bru.setVar('fakerLoaded', deps.fakerLoaded); + bru.setVar('localModuleLoaded', deps.localModuleLoaded); + + // Test the utility functions + const user = additionalLib.generateUser(); + bru.setVar('hasFirstName', typeof user.firstName === 'string' && user.firstName.length > 0); + bru.setVar('hasLastName', typeof user.lastName === 'string' && user.lastName.length > 0); + bru.setVar('hasFullName', typeof user.fullName === 'string' && user.fullName.includes(' ')); + bru.setVar('hasGreeting', typeof user.greeting === 'string' && user.greeting.startsWith('Hello, ')); + bru.setVar('hasEmail', typeof user.email === 'string' && user.email.includes('@')); + + // Test direct functions from local module + const formatted = additionalLib.formatName('John', 'Doe'); + bru.setVar('formatNameResult', formatted); + + const greeting = additionalLib.generateGreeting('Bruno'); + bru.setVar('greetingResult', greeting); + + // Test direct require of a specific file from additionalContextRoot + const libDirect = require('../additional-context-root-lib/lib.js'); + bru.setVar('directRequireWorks', typeof libDirect.formatName === 'function'); + bru.setVar('directFormatName', libDirect.formatName('Direct', 'Test')); + bru.setVar('directGreeting', libDirect.generateGreeting('World')); +} + +tests { + test("should load module from additionalContextRoot", function() { + expect(bru.getVar('fakerLoaded')).to.equal(true); + expect(bru.getVar('localModuleLoaded')).to.equal(true); + }); + + test("should resolve npm module (@faker-js/faker) from collection node_modules", function() { + expect(bru.getVar('hasFirstName')).to.equal(true); + expect(bru.getVar('hasLastName')).to.equal(true); + expect(bru.getVar('hasEmail')).to.equal(true); + }); + + test("should resolve local module (./lib.js) relative to additionalContextRoot", function() { + expect(bru.getVar('hasFullName')).to.equal(true); + expect(bru.getVar('hasGreeting')).to.equal(true); + }); + + test("should correctly execute local module functions", function() { + expect(bru.getVar('formatNameResult')).to.equal('John Doe'); + expect(bru.getVar('greetingResult')).to.equal('Hello, Bruno!'); + }); + + test("should directly require specific file from additionalContextRoot", function() { + expect(bru.getVar('directRequireWorks')).to.equal(true); + expect(bru.getVar('directFormatName')).to.equal('Direct Test'); + expect(bru.getVar('directGreeting')).to.equal('Hello, World!'); + }); +} diff --git a/packages/bruno-tests/collection/scripting/node-builtins/buffer.bru b/packages/bruno-tests/collection/scripting/node-builtins/buffer.bru new file mode 100644 index 000000000..dff87045f --- /dev/null +++ b/packages/bruno-tests/collection/scripting/node-builtins/buffer.bru @@ -0,0 +1,57 @@ +meta { + name: buffer + type: http + seq: 1 +} + +get { + url: {{host}}/ping + body: none + auth: none +} + +script:pre-request { + // Skip in safe mode - these tests require developer sandbox + if (bru.isSafeMode()) { + bru.runner.skipRequest(); + return; + } +} + +tests { + test("Buffer.from and toString", function() { + const buf = Buffer.from('hello bruno', 'utf8'); + expect(buf.toString()).to.equal('hello bruno'); + expect(buf.toString('base64')).to.equal('aGVsbG8gYnJ1bm8='); + expect(buf.toString('hex')).to.equal('68656c6c6f206272756e6f'); + expect(buf.length).to.equal(11); + }); + + test("Buffer.from with base64 and hex", function() { + expect(Buffer.from('aGVsbG8=', 'base64').toString()).to.equal('hello'); + expect(Buffer.from('68656c6c6f', 'hex').toString()).to.equal('hello'); + }); + + test("Buffer.alloc", function() { + const buf = Buffer.alloc(10, 0); + expect(buf.length).to.equal(10); + expect(buf[0]).to.equal(0); + }); + + test("Buffer.concat", function() { + const result = Buffer.concat([Buffer.from('hello '), Buffer.from('world')]); + expect(result.toString()).to.equal('hello world'); + }); + + test("Buffer.isBuffer", function() { + expect(Buffer.isBuffer(Buffer.from('test'))).to.equal(true); + expect(Buffer.isBuffer('string')).to.equal(false); + expect(Buffer.isBuffer(new Uint8Array(4))).to.equal(false); + }); + + test("Buffer.subarray", function() { + const buf = Buffer.from('hello bruno'); + expect(buf.subarray(0, 5).toString()).to.equal('hello'); + expect(buf.subarray(6).toString()).to.equal('bruno'); + }); +} diff --git a/packages/bruno-tests/collection/scripting/node-builtins/encoding.bru b/packages/bruno-tests/collection/scripting/node-builtins/encoding.bru new file mode 100644 index 000000000..96cec72d5 --- /dev/null +++ b/packages/bruno-tests/collection/scripting/node-builtins/encoding.bru @@ -0,0 +1,54 @@ +meta { + name: encoding + type: http + seq: 3 +} + +get { + url: {{host}}/ping + body: none + auth: none +} + +script:pre-request { + // Skip in safe mode - these tests require developer sandbox + if (bru.isSafeMode()) { + bru.runner.skipRequest(); + return; + } +} + +tests { + test("TextEncoder", function() { + const encoder = new TextEncoder(); + const encoded = encoder.encode('hello'); + expect(encoded).to.be.instanceOf(Uint8Array); + expect(encoded.length).to.equal(5); + expect(encoded[0]).to.equal(104); // 'h' + }); + + test("TextDecoder", function() { + const decoder = new TextDecoder('utf-8'); + const decoded = decoder.decode(new Uint8Array([104, 101, 108, 108, 111])); + expect(decoded).to.equal('hello'); + }); + + test("TextDecoder with utf-16le", function() { + const decoder = new TextDecoder('utf-16le'); + const decoded = decoder.decode(new Uint8Array([104, 0, 105, 0])); + expect(decoded).to.equal('hi'); + }); + + test("btoa and atob", function() { + expect(btoa('hello bruno')).to.equal('aGVsbG8gYnJ1bm8='); + expect(atob('aGVsbG8gYnJ1bm8=')).to.equal('hello bruno'); + }); + + test("base64 roundtrip with binary data", function() { + const binary = String.fromCharCode(0, 1, 255, 254); + const encoded = btoa(binary); + const decoded = atob(encoded); + expect(decoded.charCodeAt(0)).to.equal(0); + expect(decoded.charCodeAt(2)).to.equal(255); + }); +} diff --git a/packages/bruno-tests/collection/scripting/node-builtins/events.bru b/packages/bruno-tests/collection/scripting/node-builtins/events.bru new file mode 100644 index 000000000..8c66602bd --- /dev/null +++ b/packages/bruno-tests/collection/scripting/node-builtins/events.bru @@ -0,0 +1,86 @@ +meta { + name: events + type: http + seq: 20 +} + +get { + url: {{host}}/ping + body: none + auth: none +} + +script:pre-request { + // Skip in safe mode - these tests require developer sandbox + if (bru.isSafeMode()) { + bru.runner.skipRequest(); + return; + } +} + +tests { + test("Event, EventTarget, CustomEvent exist", function() { + expect(Event).to.be.a('function'); + expect(EventTarget).to.be.a('function'); + expect(CustomEvent).to.be.a('function'); + }); + + test("Event properties", function() { + const event = new Event('click', { bubbles: true, cancelable: true }); + expect(event.type).to.equal('click'); + expect(event.bubbles).to.equal(true); + expect(event.cancelable).to.equal(true); + }); + + test("CustomEvent with detail", function() { + const event = new CustomEvent('custom', { detail: { foo: 'bar' } }); + expect(event.type).to.equal('custom'); + expect(event.detail).to.deep.equal({ foo: 'bar' }); + }); + + test("EventTarget addEventListener and dispatchEvent", function() { + let eventFired = false; + let eventDetail = null; + + const target = new EventTarget(); + target.addEventListener('test', (e) => { + eventFired = true; + eventDetail = e.detail; + }); + target.dispatchEvent(new CustomEvent('test', { detail: 'hello' })); + + expect(eventFired).to.equal(true); + expect(eventDetail).to.equal('hello'); + }); + + test("Multiple event listeners", function() { + let count = 0; + const target = new EventTarget(); + target.addEventListener('inc', () => count++); + target.addEventListener('inc', () => count++); + target.dispatchEvent(new Event('inc')); + + expect(count).to.equal(2); + }); + + test("removeEventListener", function() { + let removed = true; + const target = new EventTarget(); + const handler = () => { removed = false; }; + target.addEventListener('test', handler); + target.removeEventListener('test', handler); + target.dispatchEvent(new Event('test')); + + expect(removed).to.equal(true); + }); + + test("addEventListener with once option", function() { + let onceCount = 0; + const target = new EventTarget(); + target.addEventListener('test', () => onceCount++, { once: true }); + target.dispatchEvent(new Event('test')); + target.dispatchEvent(new Event('test')); + + expect(onceCount).to.equal(1); + }); +} diff --git a/packages/bruno-tests/collection/scripting/node-builtins/fetch-api.bru b/packages/bruno-tests/collection/scripting/node-builtins/fetch-api.bru new file mode 100644 index 000000000..774d628d3 --- /dev/null +++ b/packages/bruno-tests/collection/scripting/node-builtins/fetch-api.bru @@ -0,0 +1,82 @@ +meta { + name: fetch-api + type: http + seq: 9 +} + +get { + url: {{host}}/ping + body: none + auth: none +} + +script:pre-request { + // Skip in safe mode - these tests require developer sandbox + if (bru.isSafeMode()) { + bru.runner.skipRequest(); + return; + } +} + +tests { + test("Fetch API globals exist", function() { + expect(fetch).to.be.a('function'); + expect(Request).to.be.a('function'); + expect(Response).to.be.a('function'); + expect(Headers).to.be.a('function'); + }); + + test("Headers", function() { + const headers = new Headers(); + headers.set('Content-Type', 'application/json'); + headers.append('X-Custom', 'value'); + expect(headers.get('Content-Type')).to.equal('application/json'); + expect(headers.has('X-Custom')).to.equal(true); + expect(headers.has('Missing')).to.equal(false); + }); + + test("Request", function() { + const req = new Request('https://example.com/api', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + expect(req.url).to.equal('https://example.com/api'); + expect(req.method).to.equal('POST'); + expect(req.headers.get('Content-Type')).to.equal('application/json'); + }); + + test("Response", function() { + const res = new Response('body', { status: 201, statusText: 'Created' }); + expect(res.status).to.equal(201); + expect(res.statusText).to.equal('Created'); + expect(res.ok).to.equal(true); + }); + + test("Response body methods exist", function() { + const res = new Response('test'); + expect(res.json).to.be.a('function'); + expect(res.text).to.be.a('function'); + expect(res.arrayBuffer).to.be.a('function'); + expect(res.blob).to.be.a('function'); + }); + + test("AbortController", function() { + const controller = new AbortController(); + expect(controller.signal.aborted).to.equal(false); + controller.abort(); + expect(controller.signal.aborted).to.equal(true); + }); + + test("FormData", function() { + const fd = new FormData(); + fd.append('field', 'value'); + expect(fd.get('field')).to.equal('value'); + expect(fd.has('field')).to.equal(true); + }); + + test("Blob", function() { + const blob = new Blob(['hello'], { type: 'text/plain' }); + expect(blob.size).to.equal(5); + expect(blob.type).to.equal('text/plain'); + }); +} diff --git a/packages/bruno-tests/collection/scripting/node-builtins/intl.bru b/packages/bruno-tests/collection/scripting/node-builtins/intl.bru new file mode 100644 index 000000000..bf30d7b01 --- /dev/null +++ b/packages/bruno-tests/collection/scripting/node-builtins/intl.bru @@ -0,0 +1,65 @@ +meta { + name: intl + type: http + seq: 10 +} + +get { + url: {{host}}/ping + body: none + auth: none +} + +script:pre-request { + // Skip in safe mode - these tests require developer sandbox + if (bru.isSafeMode()) { + bru.runner.skipRequest(); + return; + } +} + +tests { + test("Intl.DateTimeFormat", function() { + const formatter = new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); + expect(formatter.format(new Date('2024-06-15'))).to.equal('June 15, 2024'); + }); + + test("Intl.NumberFormat", function() { + const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }); + expect(currency.format(1234.56)).to.equal('$1,234.56'); + + const percent = new Intl.NumberFormat('en-US', { style: 'percent', minimumFractionDigits: 1 }); + expect(percent.format(0.456)).to.equal('45.6%'); + }); + + test("Intl.Collator", function() { + const collator = new Intl.Collator('en', { sensitivity: 'base' }); + expect(collator.compare('a', 'A')).to.equal(0); + expect(collator.compare('a', 'b')).to.be.lessThan(0); + }); + + test("Intl.PluralRules", function() { + const rules = new Intl.PluralRules('en-US'); + expect(rules.select(1)).to.equal('one'); + expect(rules.select(5)).to.equal('other'); + }); + + test("Intl.RelativeTimeFormat", function() { + const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); + expect(rtf.format(-1, 'day')).to.equal('yesterday'); + expect(rtf.format(1, 'day')).to.equal('tomorrow'); + }); + + test("Intl.ListFormat", function() { + const formatter = new Intl.ListFormat('en', { style: 'long', type: 'conjunction' }); + expect(formatter.format(['Apple', 'Banana', 'Cherry'])).to.equal('Apple, Banana, and Cherry'); + }); + + test("Intl.DisplayNames", function() { + const regions = new Intl.DisplayNames(['en'], { type: 'region' }); + expect(regions.of('US')).to.equal('United States'); + + const languages = new Intl.DisplayNames(['en'], { type: 'language' }); + expect(languages.of('fr')).to.equal('French'); + }); +} diff --git a/packages/bruno-tests/collection/scripting/node-builtins/json.bru b/packages/bruno-tests/collection/scripting/node-builtins/json.bru new file mode 100644 index 000000000..f5530b3a9 --- /dev/null +++ b/packages/bruno-tests/collection/scripting/node-builtins/json.bru @@ -0,0 +1,71 @@ +meta { + name: json + type: http + seq: 19 +} + +get { + url: {{host}}/ping + body: none + auth: none +} + +script:pre-request { + // Skip in safe mode - these tests require developer sandbox + if (bru.isSafeMode()) { + bru.runner.skipRequest(); + return; + } +} + +tests { + test("JSON.stringify", function() { + expect(JSON.stringify({ a: 1 })).to.equal('{"a":1}'); + expect(JSON.stringify([1, 2, 3])).to.equal('[1,2,3]'); + expect(JSON.stringify('hello')).to.equal('"hello"'); + expect(JSON.stringify(null)).to.equal('null'); + }); + + test("JSON.stringify with replacer", function() { + const obj = { a: 1, b: 2, c: 3 }; + expect(JSON.stringify(obj, ['a', 'c'])).to.equal('{"a":1,"c":3}'); + expect(JSON.stringify(obj, (k, v) => k === 'b' ? undefined : v)).to.equal('{"a":1,"c":3}'); + }); + + test("JSON.stringify with space", function() { + const obj = { a: 1 }; + expect(JSON.stringify(obj, null, 2)).to.equal('{\n "a": 1\n}'); + }); + + test("JSON.parse", function() { + expect(JSON.parse('{"a":1}')).to.deep.equal({ a: 1 }); + expect(JSON.parse('[1,2,3]')).to.deep.equal([1, 2, 3]); + expect(JSON.parse('"hello"')).to.equal('hello'); + expect(JSON.parse('null')).to.equal(null); + }); + + test("JSON.parse with reviver", function() { + const result = JSON.parse('{"a":1,"b":2}', (k, v) => typeof v === 'number' ? v * 2 : v); + expect(result).to.deep.equal({ a: 2, b: 4 }); + }); + + test("JSON roundtrip with complex object", function() { + const obj = { + string: 'hello', + number: 42, + float: 3.14, + boolean: true, + null: null, + array: [1, 2, 3], + nested: { a: { b: { c: 'deep' } } } + }; + expect(JSON.parse(JSON.stringify(obj))).to.deep.equal(obj); + }); + + test("JSON.stringify handles special values", function() { + expect(JSON.stringify({ a: undefined })).to.equal('{}'); + expect(JSON.stringify([undefined])).to.equal('[null]'); + expect(JSON.stringify({ a: NaN })).to.equal('{"a":null}'); + expect(JSON.stringify({ a: Infinity })).to.equal('{"a":null}'); + }); +} diff --git a/packages/bruno-tests/collection/scripting/node-builtins/node-crypto.bru b/packages/bruno-tests/collection/scripting/node-builtins/node-crypto.bru new file mode 100644 index 000000000..6088b191b --- /dev/null +++ b/packages/bruno-tests/collection/scripting/node-builtins/node-crypto.bru @@ -0,0 +1,91 @@ +meta { + name: node-crypto + type: http + seq: 12 +} + +get { + url: {{host}}/ping + body: none + auth: none +} + +script:pre-request { + // Skip in safe mode - these tests require developer sandbox + if (bru.isSafeMode()) { + bru.runner.skipRequest(); + return; + } +} + +tests { + const crypto = require('node:crypto'); + + test("crypto.randomBytes", function() { + const bytes = crypto.randomBytes(16); + expect(Buffer.isBuffer(bytes)).to.equal(true); + expect(bytes.length).to.equal(16); + }); + + test("crypto.randomUUID", function() { + const uuid = crypto.randomUUID(); + expect(uuid).to.match(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + }); + + test("crypto.createHash", function() { + const md5 = crypto.createHash('md5').update('hello').digest('hex'); + expect(md5).to.have.lengthOf(32); + + const sha256 = crypto.createHash('sha256').update('hello').digest('hex'); + expect(sha256).to.have.lengthOf(64); + + const sha512 = crypto.createHash('sha512').update('hello').digest('hex'); + expect(sha512).to.have.lengthOf(128); + }); + + test("crypto.createHmac", function() { + const hmac = crypto.createHmac('sha256', 'secret').update('hello').digest('hex'); + expect(hmac).to.have.lengthOf(64); + }); + + test("crypto.getHashes and crypto.getCiphers", function() { + const hashes = crypto.getHashes(); + expect(hashes).to.be.an('array').that.includes('sha256'); + + const ciphers = crypto.getCiphers(); + expect(ciphers).to.be.an('array'); + expect(ciphers.some(c => c.includes('aes'))).to.equal(true); + }); + + test("crypto.pbkdf2Sync", function() { + const key = crypto.pbkdf2Sync('password', 'salt', 1000, 32, 'sha256'); + expect(key.length).to.equal(32); + }); + + test("crypto.scryptSync", function() { + const key = crypto.scryptSync('password', 'salt', 32); + expect(key.length).to.equal(32); + }); + + test("crypto.timingSafeEqual", function() { + const a = Buffer.from('hello'); + const b = Buffer.from('hello'); + const c = Buffer.from('world'); + expect(crypto.timingSafeEqual(a, b)).to.equal(true); + expect(crypto.timingSafeEqual(a, c)).to.equal(false); + }); + + test("AES encryption/decryption", function() { + const key = crypto.randomBytes(32); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); + let encrypted = cipher.update('secret message', 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); + let decrypted = decipher.update(encrypted, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + expect(decrypted).to.equal('secret message'); + }); +} diff --git a/packages/bruno-tests/collection/scripting/node-builtins/node-fs.bru b/packages/bruno-tests/collection/scripting/node-builtins/node-fs.bru new file mode 100644 index 000000000..06d40730f --- /dev/null +++ b/packages/bruno-tests/collection/scripting/node-builtins/node-fs.bru @@ -0,0 +1,80 @@ +meta { + name: node-fs + type: http + seq: 13 +} + +get { + url: {{host}}/ping + body: none + auth: none +} + +script:pre-request { + // Skip in safe mode - these tests require developer sandbox + if (bru.isSafeMode()) { + bru.runner.skipRequest(); + return; + } +} + +tests { + const fs = require('node:fs'); + const path = require('node:path'); + const os = require('node:os'); + + // Setup - create test directory and file + const testDir = path.join(os.tmpdir(), 'bruno-fs-test-' + Date.now()); + const testFile = path.join(testDir, 'test.txt'); + fs.mkdirSync(testDir, { recursive: true }); + fs.writeFileSync(testFile, 'Hello Bruno!'); + + test("fs.existsSync", function() { + expect(fs.existsSync(testDir)).to.equal(true); + expect(fs.existsSync(testFile)).to.equal(true); + expect(fs.existsSync('/nonexistent')).to.equal(false); + }); + + test("fs.readFileSync", function() { + expect(fs.readFileSync(testFile, 'utf8')).to.equal('Hello Bruno!'); + expect(Buffer.isBuffer(fs.readFileSync(testFile))).to.equal(true); + }); + + test("fs.appendFileSync", function() { + fs.appendFileSync(testFile, ' Appended.'); + expect(fs.readFileSync(testFile, 'utf8')).to.equal('Hello Bruno! Appended.'); + }); + + test("fs.statSync", function() { + const fileStat = fs.statSync(testFile); + expect(fileStat.isFile()).to.equal(true); + expect(fileStat.isDirectory()).to.equal(false); + + const dirStat = fs.statSync(testDir); + expect(dirStat.isDirectory()).to.equal(true); + }); + + test("fs.readdirSync", function() { + const files = fs.readdirSync(testDir); + expect(files).to.include('test.txt'); + }); + + test("fs.copyFileSync and fs.renameSync", function() { + const copyPath = path.join(testDir, 'copy.txt'); + const renamePath = path.join(testDir, 'renamed.txt'); + + fs.copyFileSync(testFile, copyPath); + expect(fs.existsSync(copyPath)).to.equal(true); + + fs.renameSync(copyPath, renamePath); + expect(fs.existsSync(copyPath)).to.equal(false); + expect(fs.existsSync(renamePath)).to.equal(true); + + // Cleanup + fs.unlinkSync(renamePath); + }); + + // Cleanup + fs.unlinkSync(testFile); + fs.rmdirSync(testDir); +} diff --git a/packages/bruno-tests/collection/scripting/node-builtins/node-os.bru b/packages/bruno-tests/collection/scripting/node-builtins/node-os.bru new file mode 100644 index 000000000..0351d1104 --- /dev/null +++ b/packages/bruno-tests/collection/scripting/node-builtins/node-os.bru @@ -0,0 +1,74 @@ +meta { + name: node-os + type: http + seq: 14 +} + +get { + url: {{host}}/ping + body: none + auth: none +} + +script:pre-request { + // Skip in safe mode - these tests require developer sandbox + if (bru.isSafeMode()) { + bru.runner.skipRequest(); + return; + } +} + +tests { + const os = require('node:os'); + + test("os.platform and os.arch", function() { + expect(['darwin', 'linux', 'win32']).to.include(os.platform()); + expect(['x64', 'arm64', 'arm', 'ia32']).to.include(os.arch()); + }); + + test("os.type and os.release", function() { + expect(os.type()).to.be.a('string'); + expect(os.release()).to.be.a('string'); + }); + + test("os.hostname, os.homedir, os.tmpdir", function() { + expect(os.hostname()).to.be.a('string'); + expect(os.homedir()).to.be.a('string').with.length.greaterThan(0); + expect(os.tmpdir()).to.be.a('string').with.length.greaterThan(0); + }); + + test("os.cpus", function() { + const cpus = os.cpus(); + expect(cpus).to.be.an('array').with.length.greaterThan(0); + expect(cpus[0]).to.have.property('model'); + }); + + test("os.totalmem and os.freemem", function() { + expect(os.totalmem()).to.be.a('number').greaterThan(0); + expect(os.freemem()).to.be.a('number').greaterThan(0); + }); + + test("os.uptime", function() { + expect(os.uptime()).to.be.a('number').greaterThan(0); + }); + + test("os.loadavg", function() { + const load = os.loadavg(); + expect(load).to.be.an('array').with.lengthOf(3); + }); + + test("os.networkInterfaces", function() { + expect(os.networkInterfaces()).to.be.an('object'); + }); + + test("os.userInfo", function() { + const info = os.userInfo(); + expect(info.username).to.be.a('string'); + expect(info.homedir).to.be.a('string'); + }); + + test("os.EOL and os.constants", function() { + expect(['\n', '\r\n']).to.include(os.EOL); + expect(os.constants).to.have.property('signals'); + }); +} diff --git a/packages/bruno-tests/collection/scripting/node-builtins/node-path.bru b/packages/bruno-tests/collection/scripting/node-builtins/node-path.bru new file mode 100644 index 000000000..862f77d97 --- /dev/null +++ b/packages/bruno-tests/collection/scripting/node-builtins/node-path.bru @@ -0,0 +1,74 @@ +meta { + name: node-path + type: http + seq: 11 +} + +get { + url: {{host}}/ping + body: none + auth: none +} + +script:pre-request { + // Skip in safe mode - these tests require developer sandbox + if (bru.isSafeMode()) { + bru.runner.skipRequest(); + return; + } +} + +tests { + const path = require('node:path'); + + test("path.join", function() { + expect(path.join('/foo', 'bar', 'baz')).to.equal('/foo/bar/baz'); + expect(path.join('foo', 'bar', 'baz')).to.equal('foo/bar/baz'); + }); + + test("path.resolve", function() { + const resolved = path.resolve('foo', 'bar'); + expect(path.isAbsolute(resolved)).to.equal(true); + }); + + test("path.dirname and path.basename", function() { + expect(path.dirname('/foo/bar/baz.txt')).to.equal('/foo/bar'); + expect(path.basename('/foo/bar/baz.txt')).to.equal('baz.txt'); + expect(path.basename('/foo/bar/baz.txt', '.txt')).to.equal('baz'); + }); + + test("path.extname", function() { + expect(path.extname('file.txt')).to.equal('.txt'); + expect(path.extname('file')).to.equal(''); + expect(path.extname('.gitignore')).to.equal(''); + }); + + test("path.parse and path.format", function() { + const parsed = path.parse('/foo/bar/baz.txt'); + expect(parsed.root).to.equal('/'); + expect(parsed.dir).to.equal('/foo/bar'); + expect(parsed.base).to.equal('baz.txt'); + expect(parsed.name).to.equal('baz'); + expect(parsed.ext).to.equal('.txt'); + + expect(path.format(parsed)).to.equal('/foo/bar/baz.txt'); + }); + + test("path.normalize", function() { + expect(path.normalize('/foo/bar//baz/../qux')).to.equal('/foo/bar/qux'); + }); + + test("path.isAbsolute", function() { + expect(path.isAbsolute('/foo/bar')).to.equal(true); + expect(path.isAbsolute('foo/bar')).to.equal(false); + }); + + test("path.relative", function() { + expect(path.relative('/foo/bar', '/foo/baz')).to.equal('../baz'); + }); + + test("path.sep and path.delimiter", function() { + expect(path.sep).to.be.a('string'); + expect(path.delimiter).to.be.a('string'); + }); +} diff --git a/packages/bruno-tests/collection/scripting/node-builtins/node-querystring.bru b/packages/bruno-tests/collection/scripting/node-builtins/node-querystring.bru new file mode 100644 index 000000000..f9ceb179c --- /dev/null +++ b/packages/bruno-tests/collection/scripting/node-builtins/node-querystring.bru @@ -0,0 +1,57 @@ +meta { + name: node-querystring + type: http + seq: 16 +} + +get { + url: {{host}}/ping + body: none + auth: none +} + +script:pre-request { + // Skip in safe mode - these tests require developer sandbox + if (bru.isSafeMode()) { + bru.runner.skipRequest(); + return; + } +} + +tests { + const qs = require('node:querystring'); + + test("querystring.parse", function() { + const parsed = qs.parse('foo=1&bar=2&foo=3'); + expect(parsed.bar).to.equal('2'); + expect(parsed.foo).to.deep.equal(['1', '3']); + }); + + test("querystring.parse with custom separators", function() { + const parsed = qs.parse('foo:1;bar:2', ';', ':'); + expect(parsed.foo).to.equal('1'); + expect(parsed.bar).to.equal('2'); + }); + + test("querystring.stringify", function() { + expect(qs.stringify({ foo: 'bar', baz: 'qux' })).to.equal('foo=bar&baz=qux'); + expect(qs.stringify({ foo: ['a', 'b'] })).to.equal('foo=a&foo=b'); + }); + + test("querystring.stringify with custom separators", function() { + expect(qs.stringify({ foo: 'bar', baz: 'qux' }, ';', ':')).to.equal('foo:bar;baz:qux'); + }); + + test("querystring.escape and unescape", function() { + expect(qs.escape('hello world')).to.equal('hello%20world'); + expect(qs.unescape('hello%20world')).to.equal('hello world'); + }); + + test("querystring roundtrip", function() { + const obj = { name: 'Bruno', version: '1.0' }; + const encoded = qs.stringify(obj); + const decoded = qs.parse(encoded); + expect(decoded.name).to.equal('Bruno'); + expect(decoded.version).to.equal('1.0'); + }); +} diff --git a/packages/bruno-tests/collection/scripting/node-builtins/node-stream.bru b/packages/bruno-tests/collection/scripting/node-builtins/node-stream.bru new file mode 100644 index 000000000..c24720109 --- /dev/null +++ b/packages/bruno-tests/collection/scripting/node-builtins/node-stream.bru @@ -0,0 +1,72 @@ +meta { + name: node-stream + type: http + seq: 18 +} + +get { + url: {{host}}/ping + body: none + auth: none +} + +script:pre-request { + // Skip in safe mode - these tests require developer sandbox + if (bru.isSafeMode()) { + bru.runner.skipRequest(); + return; + } +} + +tests { + const stream = require('node:stream'); + const { Readable, Writable, Transform, Duplex, pipeline } = stream; + + test("stream module exports", function() { + expect(stream.Readable).to.be.a('function'); + expect(stream.Writable).to.be.a('function'); + expect(stream.Transform).to.be.a('function'); + expect(stream.Duplex).to.be.a('function'); + expect(stream.pipeline).to.be.a('function'); + }); + + test("Readable.from creates readable stream", function() { + const readable = Readable.from(['hello', ' ', 'bruno']); + expect(readable).to.be.an('object'); + expect(readable.read).to.be.a('function'); + expect(readable.on).to.be.a('function'); + }); + + test("Writable stream can be created", function() { + const chunks = []; + const writable = new Writable({ + write(chunk, enc, cb) { chunks.push(chunk); cb(); } + }); + expect(writable).to.be.an('object'); + expect(writable.write).to.be.a('function'); + expect(writable.end).to.be.a('function'); + }); + + test("Transform stream can be created", function() { + const transform = new Transform({ + transform(chunk, enc, cb) { cb(null, chunk.toString().toUpperCase()); } + }); + expect(transform).to.be.an('object'); + expect(transform.write).to.be.a('function'); + expect(transform.read).to.be.a('function'); + }); + + test("Duplex stream can be created", function() { + const duplex = new Duplex({ + read() {}, + write(chunk, enc, cb) { cb(); } + }); + expect(duplex).to.be.an('object'); + expect(duplex.read).to.be.a('function'); + expect(duplex.write).to.be.a('function'); + }); + + test("pipeline is a function", function() { + expect(pipeline).to.be.a('function'); + }); +} diff --git a/packages/bruno-tests/collection/scripting/node-builtins/node-util.bru b/packages/bruno-tests/collection/scripting/node-builtins/node-util.bru new file mode 100644 index 000000000..b5ba3eed8 --- /dev/null +++ b/packages/bruno-tests/collection/scripting/node-builtins/node-util.bru @@ -0,0 +1,65 @@ +meta { + name: node-util + type: http + seq: 15 +} + +get { + url: {{host}}/ping + body: none + auth: none +} + +script:pre-request { + // Skip in safe mode - these tests require developer sandbox + if (bru.isSafeMode()) { + bru.runner.skipRequest(); + return; + } +} + +tests { + const util = require('node:util'); + + test("util.format", function() { + expect(util.format('Hello %s', 'Bruno')).to.equal('Hello Bruno'); + expect(util.format('Count: %d', 42)).to.equal('Count: 42'); + expect(util.format('Data: %j', { a: 1 })).to.equal('Data: {"a":1}'); + }); + + test("util.inspect", function() { + const obj = { name: 'bruno', nested: { value: 42 } }; + const str = util.inspect(obj); + expect(str).to.be.a('string').that.includes('bruno'); + + const deep = { a: { b: { c: { d: 'deep' } } } }; + expect(util.inspect(deep, { depth: 1 })).to.include('[Object]'); + expect(util.inspect(deep, { depth: null })).to.include('deep'); + }); + + test("util.promisify", function() { + const promisified = util.promisify(setTimeout); + expect(promisified).to.be.a('function'); + // Returns a promise when called + const result = promisified(1); + expect(result).to.be.a('promise'); + }); + + test("util.types", function() { + expect(util.types.isDate(new Date())).to.equal(true); + expect(util.types.isMap(new Map())).to.equal(true); + expect(util.types.isSet(new Set())).to.equal(true); + expect(util.types.isRegExp(/test/)).to.equal(true); + expect(util.types.isPromise(Promise.resolve())).to.equal(true); + }); + + test("util.isDeepStrictEqual", function() { + expect(util.isDeepStrictEqual({ a: 1 }, { a: 1 })).to.equal(true); + expect(util.isDeepStrictEqual({ a: 1 }, { a: 2 })).to.equal(false); + }); + + test("util.deprecate and util.callbackify", function() { + expect(util.deprecate(() => {}, 'deprecated')).to.be.a('function'); + expect(util.callbackify(async () => {})).to.be.a('function'); + }); +} diff --git a/packages/bruno-tests/collection/scripting/node-builtins/node-zlib.bru b/packages/bruno-tests/collection/scripting/node-builtins/node-zlib.bru new file mode 100644 index 000000000..48ac1fa13 --- /dev/null +++ b/packages/bruno-tests/collection/scripting/node-builtins/node-zlib.bru @@ -0,0 +1,65 @@ +meta { + name: node-zlib + type: http + seq: 17 +} + +get { + url: {{host}}/ping + body: none + auth: none +} + +script:pre-request { + // Skip in safe mode - these tests require developer sandbox + if (bru.isSafeMode()) { + bru.runner.skipRequest(); + return; + } +} + +tests { + const zlib = require('node:zlib'); + + const testData = Buffer.from('Hello Bruno! '.repeat(100)); + + test("gzip and gunzip", function() { + const compressed = zlib.gzipSync(testData); + expect(compressed.length).to.be.lessThan(testData.length); + + const decompressed = zlib.gunzipSync(compressed); + expect(decompressed.toString()).to.equal(testData.toString()); + }); + + test("deflate and inflate", function() { + const compressed = zlib.deflateSync(testData); + expect(compressed.length).to.be.lessThan(testData.length); + + const decompressed = zlib.inflateSync(compressed); + expect(decompressed.toString()).to.equal(testData.toString()); + }); + + test("deflateRaw and inflateRaw", function() { + const compressed = zlib.deflateRawSync(testData); + const decompressed = zlib.inflateRawSync(compressed); + expect(decompressed.toString()).to.equal(testData.toString()); + }); + + test("brotli compression", function() { + const compressed = zlib.brotliCompressSync(testData); + expect(compressed.length).to.be.lessThan(testData.length); + + const decompressed = zlib.brotliDecompressSync(compressed); + expect(decompressed.toString()).to.equal(testData.toString()); + }); + + test("compression levels", function() { + const high = zlib.gzipSync(testData, { level: 9 }); + const low = zlib.gzipSync(testData, { level: 1 }); + expect(high.length).to.be.at.most(low.length); + }); + + test("zlib.constants", function() { + expect(zlib.constants).to.have.property('Z_BEST_COMPRESSION'); + }); +} diff --git a/packages/bruno-tests/collection/scripting/node-builtins/process.bru b/packages/bruno-tests/collection/scripting/node-builtins/process.bru new file mode 100644 index 000000000..28eb56d67 --- /dev/null +++ b/packages/bruno-tests/collection/scripting/node-builtins/process.bru @@ -0,0 +1,50 @@ +meta { + name: process + type: http + seq: 6 +} + +get { + url: {{host}}/ping + body: none + auth: none +} + +script:pre-request { + // Skip in safe mode - these tests require developer sandbox + if (bru.isSafeMode()) { + bru.runner.skipRequest(); + return; + } +} + +tests { + test("process exists with expected properties", function() { + expect(typeof process).to.equal('object'); + expect(process.version).to.match(/^v\d+\.\d+\.\d+/); + expect(process.versions).to.have.property('node'); + expect(process.versions).to.have.property('v8'); + }); + + test("process.arch and process.platform", function() { + expect(['x64', 'arm64', 'arm', 'ia32']).to.include(process.arch); + expect(['darwin', 'linux', 'win32']).to.include(process.platform); + }); + + test("process.pid and process.title", function() { + expect(process.pid).to.be.a('number').and.to.be.greaterThan(0); + expect(process.title).to.be.a('string'); + }); + + test("process.argv is array", function() { + expect(process.argv).to.be.an('array'); + }); + + test("process.env is available", function() { + expect(process.env).to.be.an('object'); + }); + + test("process.nextTick is available", function() { + expect(process.nextTick).to.be.a('function'); + }); +} diff --git a/packages/bruno-tests/collection/scripting/node-builtins/timers.bru b/packages/bruno-tests/collection/scripting/node-builtins/timers.bru new file mode 100644 index 000000000..006911e27 --- /dev/null +++ b/packages/bruno-tests/collection/scripting/node-builtins/timers.bru @@ -0,0 +1,63 @@ +meta { + name: timers + type: http + seq: 5 +} + +get { + url: {{host}}/ping + body: none + auth: none +} + +script:pre-request { + // Skip in safe mode - these tests require developer sandbox + if (bru.isSafeMode()) { + bru.runner.skipRequest(); + return; + } +} + +tests { + test("setTimeout exists and is callable", function() { + expect(setTimeout).to.be.a('function'); + const id = setTimeout(() => {}, 1000); + expect(id).to.not.be.undefined; + clearTimeout(id); + }); + + test("clearTimeout exists and works", function() { + expect(clearTimeout).to.be.a('function'); + let fired = false; + const id = setTimeout(() => { fired = true; }, 0); + clearTimeout(id); + // Can't fully verify without async, but clearTimeout should not throw + expect(fired).to.equal(false); + }); + + test("setInterval exists and is callable", function() { + expect(setInterval).to.be.a('function'); + const id = setInterval(() => {}, 1000); + expect(id).to.not.be.undefined; + clearInterval(id); + }); + + test("clearInterval exists", function() { + expect(clearInterval).to.be.a('function'); + }); + + test("setImmediate exists and is callable", function() { + expect(setImmediate).to.be.a('function'); + const id = setImmediate(() => {}); + expect(id).to.not.be.undefined; + clearImmediate(id); + }); + + test("clearImmediate exists", function() { + expect(clearImmediate).to.be.a('function'); + }); + + test("queueMicrotask exists", function() { + expect(queueMicrotask).to.be.a('function'); + }); +} diff --git a/packages/bruno-tests/collection/scripting/node-builtins/url.bru b/packages/bruno-tests/collection/scripting/node-builtins/url.bru new file mode 100644 index 000000000..543892725 --- /dev/null +++ b/packages/bruno-tests/collection/scripting/node-builtins/url.bru @@ -0,0 +1,54 @@ +meta { + name: url + type: http + seq: 2 +} + +get { + url: {{host}}/ping + body: none + auth: none +} + +script:pre-request { + // Skip in safe mode - these tests require developer sandbox + if (bru.isSafeMode()) { + bru.runner.skipRequest(); + return; + } +} + +tests { + test("URL parsing", function() { + const url = new URL('https://user:pass@example.com:8080/path?foo=bar#hash'); + expect(url.protocol).to.equal('https:'); + expect(url.hostname).to.equal('example.com'); + expect(url.port).to.equal('8080'); + expect(url.pathname).to.equal('/path'); + expect(url.search).to.equal('?foo=bar'); + expect(url.hash).to.equal('#hash'); + expect(url.username).to.equal('user'); + expect(url.password).to.equal('pass'); + expect(url.origin).to.equal('https://example.com:8080'); + }); + + test("URL modification", function() { + const url = new URL('https://example.com'); + url.pathname = '/api/v1'; + url.searchParams.set('key', 'value'); + expect(url.toString()).to.equal('https://example.com/api/v1?key=value'); + }); + + test("URLSearchParams", function() { + const params = new URLSearchParams('foo=1&bar=2&foo=3'); + expect(params.get('foo')).to.equal('1'); + expect(params.getAll('foo')).to.deep.equal(['1', '3']); + expect(params.has('bar')).to.equal(true); + expect(params.has('missing')).to.equal(false); + + params.set('bar', 'updated'); + params.delete('foo'); + params.append('new', 'value'); + expect(params.toString()).to.equal('bar=updated&new=value'); + }); +} diff --git a/packages/bruno-tests/collection/scripting/node-builtins/web-crypto.bru b/packages/bruno-tests/collection/scripting/node-builtins/web-crypto.bru new file mode 100644 index 000000000..fcca30ed1 --- /dev/null +++ b/packages/bruno-tests/collection/scripting/node-builtins/web-crypto.bru @@ -0,0 +1,63 @@ +meta { + name: web-crypto + type: http + seq: 4 +} + +get { + url: {{host}}/ping + body: none + auth: none +} + +script:pre-request { + // Skip in safe mode - these tests require developer sandbox + if (bru.isSafeMode()) { + bru.runner.skipRequest(); + return; + } +} + +tests { + test("crypto global exists", function() { + expect(typeof crypto).to.equal('object'); + expect(typeof crypto.subtle).to.equal('object'); + }); + + test("crypto.randomUUID", function() { + const uuid = crypto.randomUUID(); + expect(uuid).to.match(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + }); + + test("crypto.getRandomValues", function() { + const array = new Uint8Array(16); + crypto.getRandomValues(array); + expect(array.some(b => b !== 0)).to.equal(true); + }); + + test("crypto.subtle methods exist", function() { + expect(crypto.subtle.digest).to.be.a('function'); + expect(crypto.subtle.generateKey).to.be.a('function'); + expect(crypto.subtle.sign).to.be.a('function'); + expect(crypto.subtle.verify).to.be.a('function'); + expect(crypto.subtle.encrypt).to.be.a('function'); + expect(crypto.subtle.decrypt).to.be.a('function'); + expect(crypto.subtle.importKey).to.be.a('function'); + expect(crypto.subtle.exportKey).to.be.a('function'); + }); + + test("crypto.subtle.digest returns promise", function() { + const data = new TextEncoder().encode('hello'); + const result = crypto.subtle.digest('SHA-256', data); + expect(result).to.be.a('promise'); + }); + + test("crypto.subtle.generateKey returns promise", function() { + const result = crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ); + expect(result).to.be.a('promise'); + }); +} diff --git a/packages/bruno-tests/collection/scripting/npm modules/ajv.bru b/packages/bruno-tests/collection/scripting/npm modules/ajv.bru new file mode 100644 index 000000000..feccbd78c --- /dev/null +++ b/packages/bruno-tests/collection/scripting/npm modules/ajv.bru @@ -0,0 +1,80 @@ +meta { + name: ajv + type: http + seq: 2 +} + +post { + url: {{host}}/api/echo/json + body: json + auth: none +} + +body:json { + { + "hello": "bruno" + } +} + +assert { + res.status: eq 200 +} + +script:pre-request { + const Ajv = require('ajv'); + const ajv = new Ajv(); + + // Define a JSON schema + const schema = { + type: 'object', + properties: { + name: { type: 'string', minLength: 1 }, + age: { type: 'integer', minimum: 0 }, + email: { type: 'string' } + }, + required: ['name', 'age'] + }; + + // Valid data to validate + const validData = { + name: 'Bruno User', + age: 25, + email: 'bruno@example.com' + }; + + // Compile and validate + const validate = ajv.compile(schema); + const isValid = validate(validData); + + // Set validation result in request body + const data = req.getBody(); + data.ajvValidation = { + isValid: isValid, + validatedData: validData + }; + + req.setBody(data); +} + +tests { + test("ajv should validate data correctly", function() { + const data = res.getBody(); + + expect(data.hello).to.equal("bruno"); + expect(data.ajvValidation).to.be.an('object'); + expect(data.ajvValidation.isValid).to.be.true; + expect(data.ajvValidation.validatedData.name).to.equal('Bruno User'); + expect(data.ajvValidation.validatedData.age).to.equal(25); + }); + + test("ajv should be available in tests", function() { + const Ajv = require('ajv'); + const ajv = new Ajv(); + + const schema = { type: 'number' }; + const validate = ajv.compile(schema); + + expect(validate(42)).to.be.true; + expect(validate('not a number')).to.be.false; + }); +} diff --git a/packages/bruno-tests/collection/scripting/npm modules/external-lib-bru-req-res.bru b/packages/bruno-tests/collection/scripting/npm modules/external-lib-bru-req-res.bru new file mode 100644 index 000000000..2245c8e60 --- /dev/null +++ b/packages/bruno-tests/collection/scripting/npm modules/external-lib-bru-req-res.bru @@ -0,0 +1,54 @@ +meta { + name: external-lib-bru-req-res + type: http + seq: 5 +} + +post { + url: {{host}}/api/echo/json + body: json + auth: none +} + +body:json { + { + "name": "Bruno User", + "age": 25 + } +} + +assert { + res.status: eq 200 +} + +tests { + const extLib = require('external-lib-with-bru-req-res-objects'); + + test("should provide bru object to npm modules", function() { + extLib.setVar('ext-lib-test', 'hello'); + expect(extLib.getVar('ext-lib-test')).to.equal('hello'); + }); + + test("should provide req object to npm modules", function() { + const method = extLib.getReqMethod(); + expect(method).to.equal('POST'); + + const headers = extLib.getReqHeaders(); + // expect(headers).to.be.an('object'); + expect(headers['content-type']).to.include('json'); + }); + + test("should provide res object to npm modules", function() { + const status = extLib.getResStatus(); + expect(status).to.equal(200); + + const body = extLib.getResBody(); + // expect(body).to.be.an('object'); + expect(body.name).to.equal('Bruno User'); + expect(body.age).to.equal(25); + + const headers = extLib.getResHeaders(); + // expect(headers).to.be.an('object'); + expect(headers['content-type']).to.include('json'); + }); +} diff --git a/packages/bruno-tests/collection/scripting/npm modules/jose.bru b/packages/bruno-tests/collection/scripting/npm modules/jose.bru new file mode 100644 index 000000000..697310dc7 --- /dev/null +++ b/packages/bruno-tests/collection/scripting/npm modules/jose.bru @@ -0,0 +1,66 @@ +meta { + name: jose + type: http + seq: 3 +} + +post { + url: {{host}}/api/echo/json + body: json + auth: none +} + +body:json { + { + "hello": "bruno" + } +} + +assert { + res.status: eq 200 +} + +script:pre-request { + const jose = require('jose'); + + // Create a symmetric secret for HS256 + const secret = new TextEncoder().encode('my-super-secret-key-for-testing'); + + // Create a JWT with jose + const jwt = await new jose.SignJWT({ sub: 'bruno-user', name: 'Bruno' }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('1h') + .sign(secret); + + // Verify the JWT + const { payload, protectedHeader } = await jose.jwtVerify(jwt, secret); + + const data = req.getBody(); + data.jwt = jwt; + data.verified = { + alg: protectedHeader.alg, + sub: payload.sub, + name: payload.name + }; + + req.setBody(data); +} + +tests { + test("jose should create and verify JWT", function() { + const data = res.getBody(); + + expect(data.hello).to.equal("bruno"); + expect(data.jwt).to.be.a('string'); + + // JWT should have 3 parts separated by dots + const parts = data.jwt.split('.'); + expect(parts.length).to.equal(3); + + // Verify the verification worked + expect(data.verified.alg).to.equal('HS256'); + expect(data.verified.sub).to.equal('bruno-user'); + expect(data.verified.name).to.equal('Bruno'); + }); +} diff --git a/packages/bruno-tests/external-lib-with-bru-req-res-objects/index.js b/packages/bruno-tests/external-lib-with-bru-req-res-objects/index.js new file mode 100644 index 000000000..8e1d3f2fe --- /dev/null +++ b/packages/bruno-tests/external-lib-with-bru-req-res-objects/index.js @@ -0,0 +1,23 @@ +/** + * External library that accesses bru, req, res objects from the VM context. + * These are available as globals inside the Node VM sandbox. + * + * Used to test that npm modules can access bru, req, res context objects. + */ + +module.exports = { + // bru accessors + getVar: function (name) { return bru.getVar(name); }, + setVar: function (name, value) { bru.setVar(name, value); }, + getEnvVar: function (name) { return bru.getEnvVar(name); }, + + // req accessors + getReqBody: function (options) { return req.getBody(options); }, + getReqHeaders: function () { return req.getHeaders(); }, + getReqMethod: function () { return req.getMethod(); }, + + // res accessors + getResBody: function () { return res.getBody(); }, + getResHeaders: function () { return res.getHeaders(); }, + getResStatus: function () { return res.getStatus(); } +}; diff --git a/packages/bruno-tests/external-lib-with-bru-req-res-objects/package.json b/packages/bruno-tests/external-lib-with-bru-req-res-objects/package.json new file mode 100644 index 000000000..928307647 --- /dev/null +++ b/packages/bruno-tests/external-lib-with-bru-req-res-objects/package.json @@ -0,0 +1,4 @@ +{ + "name": "@usebruno/external-lib-with-bru-req-res-objects", + "version": "0.0.1" +}