mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
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:
369
packages/bruno-js/src/sandbox/node-vm/cjs-loader.js
Normal file
369
packages/bruno-js/src/sandbox/node-vm/cjs-loader.js
Normal 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
|
||||
};
|
||||
99
packages/bruno-js/src/sandbox/node-vm/constants.js
Normal file
99
packages/bruno-js/src/sandbox/node-vm/constants.js
Normal 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
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
42
packages/bruno-js/src/sandbox/node-vm/utils.js
Normal file
42
packages/bruno-js/src/sandbox/node-vm/utils.js
Normal 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
|
||||
};
|
||||
45
packages/bruno-tests/additional-context-root-lib/index.js
Normal file
45
packages/bruno-tests/additional-context-root-lib/index.js
Normal 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
|
||||
};
|
||||
16
packages/bruno-tests/additional-context-root-lib/lib.js
Normal file
16
packages/bruno-tests/additional-context-root-lib/lib.js
Normal 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
|
||||
};
|
||||
@@ -15,7 +15,8 @@
|
||||
"bypassProxy": ""
|
||||
},
|
||||
"scripts": {
|
||||
"moduleWhitelist": ["crypto", "buffer", "form-data"]
|
||||
"moduleWhitelist": ["crypto", "buffer", "form-data"],
|
||||
"additionalContextRoots": ["../additional-context-root-lib"]
|
||||
},
|
||||
"clientCertificates": {
|
||||
"enabled": true,
|
||||
|
||||
73
packages/bruno-tests/collection/package-lock.json
generated
73
packages/bruno-tests/collection/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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!');
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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}');
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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(); }
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "@usebruno/external-lib-with-bru-req-res-objects",
|
||||
"version": "0.0.1"
|
||||
}
|
||||
Reference in New Issue
Block a user