postman-sandbox
Version:
Sandbox for Postman Scripts to run in Node.js or browser
399 lines (338 loc) • 16.8 kB
JavaScript
const _ = require('lodash'),
chai = require('chai'),
Ajv = require('ajv'),
Scope = require('uniscope'),
sdk = require('postman-collection'),
PostmanEvent = sdk.Event,
Execution = require('./execution'),
PostmanConsole = require('./console'),
PostmanTimers = require('./timers'),
PostmanAPI = require('./pmapi'),
PostmanCookieStore = require('./cookie-store'),
createPostmanRequire = require('./pm-require'),
{ Vault, getVaultInterface } = require('./vault'),
{ Datasets, getDatasetsInterface } = require('./datasets'),
{ isNonLegacySandbox, getNonLegacyCodeMarker } = require('./non-legacy-codemarkers'),
EXECUTION_RESULT_EVENT_BASE = 'execution.result.',
EXECUTION_REQUEST_EVENT_BASE = 'execution.request.',
EXECUTION_RUN_REQUEST_EVENT_BASE = 'execution.run_collection_request.',
EXECUTION_ERROR_EVENT = 'execution.error',
EXECUTION_ERROR_EVENT_BASE = 'execution.error.',
EXECUTION_ABORT_EVENT_BASE = 'execution.abort.',
EXECUTION_RESPONSE_EVENT_BASE = 'execution.response.',
EXECUTION_RUN_REQUEST_RESPONSE_EVENT_BASE = 'execution.run_collection_request_response.',
EXECUTION_COOKIES_EVENT_BASE = 'execution.cookies.',
EXECUTION_ASSERTION_EVENT = 'execution.assertion',
EXECUTION_ASSERTION_EVENT_BASE = 'execution.assertion.',
EXECUTION_SKIP_REQUEST_EVENT_BASE = 'execution.skipRequest.',
CONTEXT_VARIABLE_SCOPES = ['_variables', 'environment', 'collectionVariables', 'globals'],
executeContext = require('./execute-context');
function execTemplate (template) {
return new Promise((resolve, reject) => {
const _module = { exports: {} },
scope = Scope.create({
eval: true,
ignore: ['require'],
block: ['bridge']
});
scope.import({
Buffer: require('buffer').Buffer,
module: _module
});
scope.exec(template, (err) => {
if (err) {
return reject(err);
}
return resolve(_module?.exports || {});
});
});
}
module.exports = function (bridge, glob) {
// @note we use a common scope for all executions. this causes issues when scripts are run inside the sandbox
// in parallel, but we still use this way for the legacy "persistent" behaviour needed in environment
const scope = Scope.create({
eval: true,
ignore: ['require'],
block: ['bridge']
});
// For caching required information provided during
// initialization which will be used during execution
let initializationOptions = {},
templatesRegistry = {},
getProtocolMetadata = (protocolName) => {
return templatesRegistry[protocolName] || templatesRegistry._default || null;
};
/**
* @param {Object} options
* @param {String} [options.template] - Template string - To be deprecated, use `templates` instead
* @param {Record<String, String>} [options.templates] - Map of template names to template strings
* @param {String} [options.chaiPlugin]
* @param {Boolean} [options.disableLegacyAPIs]
* @param {Array.<String>} [options.disabledAPIs]
*/
bridge.once('initialize', async (initOptions) => {
initializationOptions = initOptions || {};
const { template, templates, chaiPlugin } = initializationOptions,
availableTemplates = templates || (template ? { _default: template } : {}),
templateNames = Object.keys(availableTemplates),
promises = templateNames.map((name) => {
return execTemplate(availableTemplates[name]);
});
// If no custom template or chai plugin is provided, go ahead with the default steps
if (!templates && !template && !chaiPlugin) {
chai.use(require('chai-postman')(sdk, _, Ajv));
return bridge.dispatch('initialize');
}
// Templates can pass a chai plugin as a string or a function
// or a chaiPlugin arg is supported that works with all templates and receives the registry as an argument
promises.push(chaiPlugin ? execTemplate(chaiPlugin) : Promise.resolve(null));
try {
const results = await Promise.all(promises),
templateChaiPlugins = [],
chaiPluginResult = results.pop();
for (let i = 0; i < results.length; i++) {
const result = results[i];
templatesRegistry[templateNames[i]] = result;
if (_.isFunction(result.chaiPlugin)) {
templateChaiPlugins.push(result.chaiPlugin);
}
}
if (_.isFunction(chaiPluginResult)) {
chai.use(chaiPluginResult(templatesRegistry));
}
else if (templateChaiPlugins.length === 1) {
chai.use(templateChaiPlugins[0]);
}
else if (templateChaiPlugins.length > 1) {
throw new Error('sandbox: multiple chai plugins are not supported');
}
bridge.dispatch('initialize');
}
catch (error) {
return bridge.dispatch('initialize', error);
}
});
/**
* @param {String} id
* @param {Event} event
* @param {Object} context
* @param {Object} options
* @param {Boolean=} [options.debug]
* @param {Object=} [options.cursor]
* @param {Number=} [options.timeout]
*
* @note
* options also take in legacy properties: _itemId, _itemName
*/
bridge.on('execute', function (id, event, context, options) {
if (!(id && _.isString(id))) {
return bridge.dispatch('error', new Error('sandbox: execution identifier parameter(s) missing'));
}
if (initializationOptions.templates && !(options.templateName && _.isString(options.templateName))) {
return bridge.dispatch('error', new Error('sandbox: template name parameter is missing from options'));
}
!options && (options = {});
!context && (context = {});
event = (new PostmanEvent(event));
const executionEventName = EXECUTION_RESULT_EVENT_BASE + id,
executionRequestEventName = EXECUTION_REQUEST_EVENT_BASE + id,
executionRunCollectionRequestEventName = EXECUTION_RUN_REQUEST_EVENT_BASE + id,
errorEventName = EXECUTION_ERROR_EVENT_BASE + id,
abortEventName = EXECUTION_ABORT_EVENT_BASE + id,
responseEventName = EXECUTION_RESPONSE_EVENT_BASE + id,
runRequestResponseEventName = EXECUTION_RUN_REQUEST_RESPONSE_EVENT_BASE + id,
cookiesEventName = EXECUTION_COOKIES_EVENT_BASE + id,
assertionEventName = EXECUTION_ASSERTION_EVENT_BASE + id,
skipRequestEventName = EXECUTION_SKIP_REQUEST_EVENT_BASE + id,
// extract the code from event
code = ((code) => {
if (typeof code !== 'string') {
return;
}
// wrap it in an async function to support top-level await
const asyncCode = `;(async()=>{;
${code}
;})().then(__exitscope).catch(__exitscope);`;
return isNonLegacySandbox(code) ? `${getNonLegacyCodeMarker()}${asyncCode}` : asyncCode;
})(event.script?.toSource()),
protocolMetadata = getProtocolMetadata(options.templateName),
// create the execution object
execution = new Execution(id, event, context, { ...options, protocolMetadata }),
disabledAPIs = [
...(initializationOptions.disabledAPIs || []),
...(options.disabledAPIs || [])
],
/**
* Dispatch assertions from `pm.test` or legacy `test` API.
*
* @private
* @param {Object[]} assertions -
* @param {String} assertions[].name -
* @param {Number} assertions[].index -
* @param {Object} assertions[].error -
* @param {Boolean} assertions[].async -
* @param {Boolean} assertions[].passed -
* @param {Boolean} assertions[].skipped -
*/
dispatchAssertions = function (assertions) {
// Legacy `test` API accumulates all the assertions and dispatches at once
// whereas, `pm.test` dispatch on every single assertion.
// For compatibility, dispatch the single assertion as an array.
!Array.isArray(assertions) && (assertions = [assertions]);
bridge.dispatch(assertionEventName, options.cursor, assertions);
bridge.dispatch(EXECUTION_ASSERTION_EVENT, options.cursor, assertions);
},
onError = function (err) {
bridge.dispatch(errorEventName, options.cursor, err);
bridge.dispatch(EXECUTION_ERROR_EVENT, options.cursor, err);
};
let waiting,
timers,
vault,
datasets,
skippedExecution = false;
execution.return.async = false;
// create the controlled timers
timers = new PostmanTimers(null, function (err) {
if (err) { // propagate the error out of sandbox
onError(err);
}
}, function () {
execution.return.async = true;
}, function (err, dnd) {
// clear timeout tracking timer
waiting && (waiting = timers.wrapped.clearTimeout(waiting));
// do not allow any more timers
if (timers) {
timers.seal();
timers.clearAll();
}
// fire extra execution error event
if (err) { onError(err); }
function complete () {
vault.dispose();
datasets.dispose();
bridge.off(abortEventName);
bridge.off(responseEventName);
bridge.off(runRequestResponseEventName);
bridge.off(cookiesEventName);
bridge.off('uncaughtException', onError);
// @note delete response from the execution object to avoid dispatching
// the large response payload back due to performance reasons.
execution.response && (delete execution.response);
bridge.dispatch(executionEventName, err || null, execution);
}
if (dnd !== true) {
// if execution is skipped, we dispatch execution completion
// event immediately to avoid any further processing else we
// dispatch it in next tick to allow any other pending events
// to be dispatched.
skippedExecution ? complete() : timers.wrapped.setImmediate(complete);
}
});
// if a timeout is set, we must ensure that all pending timers are cleared and an execution timeout event is
// triggered.
_.isFinite(options.timeout) && (waiting = timers.wrapped.setTimeout(function () {
timers.terminate(new Error('sandbox: ' +
(execution.return.async ? 'asynchronous' : 'synchronous') + ' script execution timeout'));
}, options.timeout));
// if an abort event is sent, compute cleanup and complete
bridge.on(abortEventName, function () {
timers.terminate(null, true);
});
// handle response event from outside sandbox
bridge.on(responseEventName, function (id, err, res, history) {
timers.clearEvent(id, err, res, history);
});
bridge.on(runRequestResponseEventName, function (id, err, response, results) {
// Apply variable mutations from the scripts executed via pm.execution.runRequest
// to the current execution scope
const { variableMutations = {}, responseType } = results || {};
CONTEXT_VARIABLE_SCOPES.forEach(function (variableType) {
let mutableVariableScope = execution[variableType],
mutationsMade = variableMutations[variableType];
if (
!mutableVariableScope ||
!mutationsMade ||
!mutationsMade.length ||
!_.isFunction(mutableVariableScope.applyMutation)
) {
return;
}
mutationsMade.forEach(function (mutationSet) {
let applicableMutations = new sdk.MutationTracker(mutationSet);
applicableMutations.applyOn(mutableVariableScope);
});
});
timers.clearEvent(id, err, response, { responseType });
});
// handle cookies event from outside sandbox
bridge.on(cookiesEventName, function (id, err, res) {
timers.clearEvent(id, err, res);
});
// Listen for uncaught exceptions from both sync and async contexts.
// Since sync and async errors are handled by timers, this would only
// fire for unhandled promise rejections.
// @note: This is a global handler and will be triggered for any
// uncaught exception irrespective of the current execution. For
// example, if there are multiple executions happening in parallel,
// and one of them throws an error, this handler will be triggered
// for all of them. This is a limitation of uvm as there is no way
// to isolate the uncaught exception handling for each execution.
bridge.on('uncaughtException', onError);
if (!options.resolvedPackages) {
disabledAPIs.push('require');
}
vault = new Vault(id, bridge, timers);
datasets = new Datasets(id, bridge, timers);
// send control to the function that executes the context and prepares the scope
executeContext(scope, code, execution,
// if a console is sent, we use it. otherwise this also prevents erroneous referencing to any console
// inside this closure.
(new PostmanConsole(bridge, options.cursor, options.debug && glob.console)),
timers,
(
new PostmanAPI(execution,
function onRequest (request, callback) {
const eventId = timers.setEvent(callback);
bridge.dispatch(executionRequestEventName, options.cursor, id, eventId, request);
},
function onSkipRequest () {
if (options.allowSkipRequest !== undefined && !options.allowSkipRequest) {
throw new TypeError('pm.execution.skipRequest is not a function');
}
if (
// Backwards compatibility with the legacy behavior
options.allowSkipRequest === undefined && !(execution && execution.target === 'prerequest')
) {
throw new TypeError('pm.execution.skipRequest is not a function');
}
skippedExecution = true;
bridge.dispatch(skipRequestEventName, options.cursor);
timers.terminate(null);
},
dispatchAssertions,
new PostmanCookieStore(id, bridge, timers),
getVaultInterface(vault.exec.bind(vault)),
getDatasetsInterface(datasets.exec.bind(datasets)),
function onRunCollectionRequest (requestToRunId, requestOptions, callback) {
const eventId = timers.setEvent(callback),
context = {};
CONTEXT_VARIABLE_SCOPES.forEach(function (variableType) {
context[variableType] = execution[variableType].values;
});
bridge.dispatch(executionRunCollectionRequestEventName,
options.cursor,
id,
eventId,
requestToRunId,
requestOptions,
context);
},
createPostmanRequire(options.resolvedPackages, scope),
{ disabledAPIs, getProtocolMetadata })
),
dispatchAssertions,
{ disableLegacyAPIs: options.disableLegacyAPIs ?? initializationOptions.disableLegacyAPIs });
});
};