fix(node-vm): scripting context and module resolution (#7033)

* fix(node-vm): scripting context and module resolution issues

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(node-vm): use vm.createContext for true isolation and fix prototype mismatches

- Replace vm.compileFunction with vm.createContext + runInContext for true isolation
- Remove ECMAScript built-ins from safeGlobals (VM provides its own versions)
- This fixes prototype chain mismatches that broke libraries like @faker-js/faker
- Add sanitized process object (allows env, blocks exit/kill)
- Add global/globalThis pointing to isolated context (not host)
- Extract safe globals to constants.js for maintainability
- Remove typed-arrays mixin (VM provides TypedArrays)
- Add comprehensive isolation tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(node-vm): remove process, add Error types and TypedArrays mixin, add jose test

- Remove process object from script context (security hardening)
- Remove createSanitizedProcess function from constants.js
- Add Error types to safeGlobals for instanceof checks with host errors
- Add TypedArrays mixin for host API compatibility (TextEncoder, crypto, Buffer)
- Add jose library and test for JWT sign/verify functionality
- Update tests to reflect process removal

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(node-vm): handle circular dependencies and failed module caching

- Pre-populate module cache before execution to support circular requires
- Cache moduleObj instead of moduleObj.exports to handle module.exports reassignment
- Remove failed modules from cache to allow retry
- Add test for circular dependency handling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(node-vm): spread all context properties in buildScriptContext

Instead of explicitly listing each context property, spread all
properties from the context input to support future additions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(node-vm): add filtered process object to script context

Expose a sanitized process object with only safe read-only properties
(argv, version, arch, platform, pid, features) while keeping env empty
for security.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test(node-vm): add comprehensive tests for Node.js builtins

Add 18 test files for Node.js builtin APIs in developer sandbox mode:
- Buffer, URL, TextEncoder/TextDecoder, btoa/atob
- Web Crypto API and node:crypto module
- Timers (setTimeout, setInterval, setImmediate, queueMicrotask)
- Fetch API (Request, Response, Headers, FormData, Blob)
- Intl formatters, JSON, Events (Event, EventTarget, CustomEvent)
- Node modules: fs, path, os, util, stream, zlib, querystring

All tests skip in safe mode using bru.runner.skipRequest().

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(node-vm): address CodeRabbit review feedback

- Block absolute paths from bypassing security by routing through loadLocalModule
- Fix process tests to expect sanitized object instead of undefined
- Fix cache test to verify module executes only once
- Add tests for absolute path handling (block outside, allow within roots)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: lint issues

* fix(node-vm): recontextualize host objects for cross-context deep equality

Objects passed from the host context into the Node VM have different
Object/Array constructors than objects created inside the VM. This breaks
deep equality checks in libraries like AJV, where fast-deep-equal fails
on `a.constructor !== b.constructor` for structurally identical objects.

Add recontextualizeScript to utils.js that wraps getter methods (res.getBody,
res.getHeaders, req.getBody, req.getHeaders, req.getPathParams, req.getTags,
bru.getVar) to JSON round-trip returned objects inside the VM, giving them
VM-native prototypes.

Add external-lib-with-bru-req-res-objects package and tests to verify
bru/req/res accessibility from npm modules. Update ajv.bru tests to
validate res.getBody() against AJV schemas with enum on nested objects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(node-vm): update spec to use saved mock refs after recontextualize

The recontextualizeScript wraps res.getBody with a JSON round-trip
function, replacing the jest mock on the context object. Save mock
references before calling runScriptInNodeVm so assertions work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(node-vm): shallow-copy mutable process properties in sandbox

process.argv, process.versions, and process.features were passed by
reference, allowing sandboxed scripts to mutate the host process.
Shallow-copy these properties to prevent leaking mutable references.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(node-vm): use recursive clone in toVMNative instead of JSON round-trip

JSON.stringify converts undefined to null in arrays, breaking tests like
res.setBody([..., undefined, ...]). Replace with recursive clone that
creates new VM-native objects/arrays while preserving undefined values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(node-vm): generalize recontextualize to wrap all bru/req/res methods

Instead of hardcoding specific method names, walk the prototype chain
with Object.getOwnPropertyNames to discover and wrap all methods that
return Objects/Arrays. Async methods (sendRequest, runRequest) get their
resolved values wrapped. The res callable and res.body/res.headers are
also recontextualized for direct access and query usage.

Adds integration tests for VM-native prototype checks across res, req,
bru APIs, res() callable queries, and bru.sendRequest patterns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* revert(node-vm): remove recontextualizeScript and related tests

The recontextualize approach of wrapping all bru/req/res methods
to return VM-native objects is being reverted in favor of a
different solution to the cross-context prototype mismatch issue.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(node-vm): expose full process object in developer sandbox via safeGlobals

* test(node-vm): update process tests for full process object in developer sandbox

* test(node-vm): update spec to verify process.nextTick availability

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
lohit
2026-02-11 19:33:35 +00:00
committed by GitHub
parent 7460078fd6
commit ff87eb23ee
34 changed files with 3179 additions and 190 deletions

View File

@@ -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
};

View File

@@ -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
};

View File

@@ -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<Object>} Execution results including variables and test results
* @returns {Promise<void>}
* @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<string>} 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<string>} 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 = {

View File

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

View File

@@ -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<string>} 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
};

View File

@@ -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
};

View File

@@ -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
};

View File

@@ -15,7 +15,8 @@
"bypassProxy": ""
},
"scripts": {
"moduleWhitelist": ["crypto", "buffer", "form-data"]
"moduleWhitelist": ["crypto", "buffer", "form-data"],
"additionalContextRoots": ["../additional-context-root-lib"]
},
"clientCertificates": {
"enabled": true,

View File

@@ -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",

View File

@@ -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"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(); }
};

View File

@@ -0,0 +1,4 @@
{
"name": "@usebruno/external-lib-with-bru-req-res-objects",
"version": "0.0.1"
}