UNPKG

postman-runtime

Version:

Underlying library of executing Postman Collections

634 lines (525 loc) 30.2 kB
var _ = require('lodash'), uuid = require('uuid'), async = require('async'), util = require('../util'), sdk = require('postman-collection'), sandbox = require('postman-sandbox'), serialisedError = require('serialised-error'), ToughCookie = require('@postman/tough-cookie').Cookie, createItemContext = require('../create-item-context'), ASSERTION_FAILURE = 'AssertionFailure', SAFE_CONTEXT_VARIABLES = ['_variables', 'environment', 'globals', 'collectionVariables', 'cookies', 'data', 'request', 'response'], EXECUTION_REQUEST_EVENT_BASE = 'execution.request.', EXECUTION_RESPONSE_EVENT_BASE = 'execution.response.', EXECUTION_ASSERTION_EVENT_BASE = 'execution.assertion.', EXECUTION_ERROR_EVENT_BASE = 'execution.error.', EXECUTION_COOKIES_EVENT_BASE = 'execution.cookies.', EXECUTION_SKIP_REQUEST_EVENT_BASE = 'execution.skipRequest.', EXECUTION_VAULT_BASE = 'execution.vault.', COOKIES_EVENT_STORE_ACTION = 'store', COOKIE_STORE_PUT_METHOD = 'putCookie', COOKIE_STORE_UPDATE_METHOD = 'updateCookie', FILE = 'file', REQUEST_BODY_MODE_FILE = 'file', REQUEST_BODY_MODE_FORMDATA = 'formdata', getCookieDomain, // fn postProcessContext, // fn sanitizeFiles; // fn postProcessContext = function (execution, failures) { // function determines whether the event needs to abort var error; if (failures && failures.length) { error = new Error(failures.join(', ')); error.name = ASSERTION_FAILURE; } return error ? serialisedError(error, true) : undefined; }; /** * Removes files in Request body if any. * * @private * * @param {Request~definition} request Request JSON representation to be sanitized * @param {Function} callback function invoked with error, request and sanitisedFiles. * sanitisedFiles is the list of files removed from request. * * @note this function mutates the request * @todo remove files path from request.certificate */ sanitizeFiles = function (request, callback) { if (!request) { return callback(new Error('Could not complete pm.sendRequest. Request is empty.')); } var sanitisedFiles = []; // do nothing if request body is empty if (!request.body) { // send request as such return callback(null, request, sanitisedFiles); } // in case of request body mode is file, we strip it out if (request.body.mode === REQUEST_BODY_MODE_FILE) { sanitisedFiles.push(_.get(request, 'body.file.src')); request.body = null; // mutate the request for body } // if body is form-data then we deep dive into the data items and remove the entries that have file data else if (request.body.mode === REQUEST_BODY_MODE_FORMDATA) { // eslint-disable-next-line lodash/prefer-immutable-method _.remove(request.body.formdata, function (param) { // blank param and non-file param is removed if (!param || param.type !== FILE) { return false; } // at this point the param needs to be removed sanitisedFiles.push(param.src); return true; }); } return callback(null, request, sanitisedFiles); }; /** * Fetch domain name from CookieStore event arguments. * * @private * @param {String} fnName - CookieStore method name * @param {Array} args - CookieStore method arguments * @returns {String|Undefined} - Domain name */ getCookieDomain = function (fnName, args) { if (!(fnName && args)) { return; } var domain; switch (fnName) { case 'findCookie': case 'findCookies': case 'removeCookie': case 'removeCookies': domain = args[0]; break; case 'putCookie': case 'updateCookie': domain = args[0] && args[0].domain; break; default: return; } return domain; }; // TODO: Find a better way to track skipped executions const skippedExecutions = new Set(), isExecutionSkipped = (executionId) => { return skippedExecutions.has(executionId); }; /** * Script execution extension of the runner. * This module exposes processors for executing scripts before and after requests. Essentially, the processors are * itself not aware of other processors and simply allow running of a script and then queue a procesor as defined in * payload. * * Adds options * - stopOnScriptError:Boolean [false] * - host:Object [undefined] */ module.exports = { init: function (done) { var run = this; // if this run object already has a host, we do not need to create one. if (run.host) { return done(); } // @todo - remove this when chrome app and electron host creation is offloaded to runner // @todo - can this be removed now in runtime v4? if (run.options.host && run.options.host.external === true) { run.host = run.options.host.instance; return done(); } // reset previously skipped executions skippedExecutions.clear(); sandbox.createContext(_.merge({ timeout: _(run.options.timeout).pick(['script', 'global']).values().min(), serializeLogs: _.get(run, 'options.script.serializeLogs') // debug: true }, run.options.host), function (err, context) { if (err) { return done(err); } // store the host in run object for future use and move on run.host = context; const triggerHandler = function (eventName) { return function () { const executionId = arguments && arguments[0] && arguments[0].execution; if (isExecutionSkipped(executionId)) { return; } run.triggers[eventName](...arguments); }; }; context.on('console', triggerHandler('console')); context.on('error', triggerHandler('error')); context.on('execution.error', triggerHandler('exception')); context.on('execution.assertion', triggerHandler('assertion')); done(); }); }, /** * This lists the name of the events that the script processors are likely to trigger * * @type {Array} */ triggers: ['beforeScript', 'script', 'assertion', 'exception', 'console'], process: { /** * This processors job is to do the following: * - trigger event by its name * - execute all scripts that the event listens to and return execution results * * @param {Object} payload - * @param {String} payload.name - * @param {Item} payload.item - * @param {Object} [payload.context] - * @param {Cursor} [payload.coords] - * @param {Number} [payload.scriptTimeout] - The millisecond timeout for the current running script. * @param {Array.<String>} [payload.trackContext] - * @param {Boolean} [payload.stopOnScriptError] - if set to true, then a synchronous error encountered during * execution of a script will stop executing any further scripts * @param {Boolean} [payload.abortOnFailure] - * @param {Boolean} [payload.stopOnFailure] - * @param {Function} next - * * @note - in order to raise trigger for the entire event, ensure your extension has registered the triggers */ event (payload, next) { var item = payload.item, eventName = payload.name, cursor = payload.coords, // the payload can have a list of variables to track from the context post execution, ensure that // those are accurately set track = _.isArray(payload.trackContext) && _.isObject(payload.context) && // ensure that only those variables that are defined in the context are synced payload.trackContext.filter(function (variable) { return _.isObject(payload.context[variable]); }), stopOnScriptError = (_.has(payload, 'stopOnScriptError') ? payload.stopOnScriptError : this.options.stopOnScriptError), abortOnError = (_.has(payload, 'abortOnError') ? payload.abortOnError : this.options.abortOnError), // @todo: find a better home for this option processing abortOnFailure = payload.abortOnFailure, stopOnFailure = payload.stopOnFailure, packageResolver = _.get(this, 'options.script.packageResolver'), vaultSecrets = payload.context.vaultSecrets, // Do not assign any initial value here as it will be used // to determine if the vault access check was done or not hasVaultAccess, events; // @todo: find a better place to code this so that event is not aware of such options if (abortOnFailure) { abortOnError = true; } // validate the payload if (!eventName) { return next(new Error('runner.extension~events: event payload is missing the event name.')); } if (!item) { return next(new Error('runner.extension~events: event payload is missing the triggered item.')); } // get the list of events to be executed // includes events in parent as well events = item.events.listeners(eventName, { excludeDisabled: true }); // call the "before" event trigger by its event name. // at this point, the one who queued this event, must ensure that the trigger for it is defined in its // 'trigger' interface this.triggers[_.camelCase('before-' + eventName)](null, cursor, events, item); let shouldSkipExecution = false; // with all the event listeners in place, we now iterate on them and execute its scripts. post execution, // we accumulate the results in order to be passed on to the event callback trigger. async.mapSeries(events, function (event, next) { // In case the event has no script or execution was skipped // in some previous script we bail out early if (shouldSkipExecution || !event.script || event.script.isEmpty()) { return next(null, { event }); } // get access to the script from the event. var script = event.script, executionId = uuid.v4(), assertionFailed = [], asyncScriptError, // create copy of cursor so we don't leak script ids outside `event.command` // and across scripts scriptCursor = _.clone(cursor); // store the execution id in script script._lastExecutionId = executionId; // please don't use it anywhere else! // if we can find an id on script or event we add them to the cursor // so logs and errors can be traced back to the script they came from event.id && (scriptCursor.eventId = event.id); event.script.id && (scriptCursor.scriptId = event.script.id); // trigger the "beforeScript" callback this.triggers.beforeScript(null, scriptCursor, script, event, item); // add event listener to trap all assertion events, but only if needed. to avoid needlessly accumulate // stuff in memory. (abortOnFailure || stopOnFailure) && this.host.on(EXECUTION_ASSERTION_EVENT_BASE + executionId, function (scriptCursor, assertions) { _.forEach(assertions, function (assertion) { assertion && !assertion.passed && assertionFailed.push(assertion.name); }); }); // To store error event, but only if needed. Because error in callback of host.execute() // don't show execution errors for async scripts (abortOnError || stopOnScriptError) && // only store first async error in case of multiple errors this.host.once(EXECUTION_ERROR_EVENT_BASE + executionId, function (scriptCursor, error) { if (error && !(error instanceof Error)) { error = new Error(error.message || error); } asyncScriptError = error; // @todo: Figure out a way to abort the script execution here as soon as we get an error. // We can send `execution.abort.` event to sandbox for this, but currently it silently // terminates the script execution without triggering the callback. }); this.host.on(EXECUTION_COOKIES_EVENT_BASE + executionId, function (eventId, action, fnName, args) { // only store action is supported, might need to support // more cookie actions in next 2 years ¯\_(ツ)_/¯ if (action !== COOKIES_EVENT_STORE_ACTION) { return; } var self = this, dispatchEvent = EXECUTION_COOKIES_EVENT_BASE + executionId, cookieJar = _.get(self, 'requester.options.cookieJar'), cookieStore = cookieJar && cookieJar.store, cookieDomain; if (!cookieStore) { return self.host.dispatch(dispatchEvent, eventId, 'CookieStore: no store found'); } if (typeof cookieStore[fnName] !== 'function') { return self.host.dispatch(dispatchEvent, eventId, `CookieStore: invalid method name '${fnName}'`); } !Array.isArray(args) && (args = []); // there's no way cookie store can identify the difference // between regular and programmatic access. So, for now // we check for programmatic access using the cookieJar // helper method and emit the default empty value for that // method. // @note we don't emit access denied error here because // that might blocks users use-case while accessing // cookies for a sub-domain. cookieDomain = getCookieDomain(fnName, args); if (cookieJar && typeof cookieJar.allowProgrammaticAccess === 'function' && !cookieJar.allowProgrammaticAccess(cookieDomain)) { return self.host.dispatch(dispatchEvent, eventId, `CookieStore: programmatic access to "${cookieDomain}" is denied`); } // serialize cookie object if (fnName === COOKIE_STORE_PUT_METHOD && args[0]) { args[0] = ToughCookie.fromJSON(args[0]); } if (fnName === COOKIE_STORE_UPDATE_METHOD && args[0] && args[1]) { args[0] = ToughCookie.fromJSON(args[0]); args[1] = ToughCookie.fromJSON(args[1]); } // add store method's callback argument args.push(function (err, res) { // serialize error message if (err && err instanceof Error) { err = err.message || String(err); } self.host.dispatch(dispatchEvent, eventId, err, res); }); try { cookieStore[fnName](...args); } catch (error) { self.host.dispatch(dispatchEvent, eventId, `runtime~CookieStore: error executing "${fnName}"`); } }.bind(this)); this.host.on(EXECUTION_VAULT_BASE + executionId, async function (id, cmd, ...args) { if (hasVaultAccess === undefined) { try { // eslint-disable-next-line require-atomic-updates hasVaultAccess = Boolean(await vaultSecrets?._?.allowScriptAccess(item.id)); } catch (_) { // eslint-disable-next-line require-atomic-updates hasVaultAccess = false; } } // Ensure error is string // TODO identify why error objects are not being serialized correctly const dispatch = (e, r) => { this.host.dispatch(EXECUTION_VAULT_BASE + executionId, id, e, r); }; if (!hasVaultAccess) { return dispatch('Vault access denied'); } if (!['get', 'set', 'unset'].includes(cmd)) { return dispatch(`Invalid vault command: ${cmd}`); } // Explicitly enable tracking for vault secrets here as this will // not be sent to sandbox who otherwise takes care of mutation tracking vaultSecrets.enableTracking({ autoCompact: true }); dispatch(null, vaultSecrets[cmd](...args)); }.bind(this)); this.host.on(EXECUTION_REQUEST_EVENT_BASE + executionId, function (scriptCursor, id, requestId, request) { // remove files in request body if any sanitizeFiles(request, function (err, request, sanitisedFiles) { if (err) { return this.host.dispatch(EXECUTION_RESPONSE_EVENT_BASE + id, requestId, err); } var nextPayload; // if request is sanitized send a warning if (!_.isEmpty(sanitisedFiles)) { this.triggers.console(scriptCursor, 'warn', 'uploading files from scripts is not allowed'); } nextPayload = { item: new sdk.Item({ request: request, // Allow request body in methods like GET, HEAD, etc. // when explicitly provided via script // @todo make protocolProfileBehavior configurable via scripts protocolProfileBehavior: { disableBodyPruning: true } }), coords: scriptCursor, // @todo - get script type from the sandbox source: 'script', // abortOnError makes sure request command bubbles errors // so we can pass it on to the callback abortOnError: true }; // create context for executing this request nextPayload.context = createItemContext(nextPayload); this.immediate('httprequest', nextPayload).done(function (result) { this.host.dispatch(EXECUTION_RESPONSE_EVENT_BASE + id, requestId, null, result && result.response, // @todo get cookies from result.history or pass PostmanHistory // instance once it is fully supported result && { cookies: result.cookies }); }).catch(function (err) { const error = serialisedError(err); delete error.stack; // remove stack to avoid leaking runtime internals this.host.dispatch(EXECUTION_RESPONSE_EVENT_BASE + id, requestId, error); }); }.bind(this)); }.bind(this)); this.host.on(EXECUTION_SKIP_REQUEST_EVENT_BASE + executionId, function () { skippedExecutions.add(executionId); shouldSkipExecution = true; }); const currentEventItem = event.parent && event.parent(), executeScript = ({ resolvedPackages }, done) => { // finally execute the script this.host.execute(event, { id: executionId, // debug: true, // @todo: Expose this as a property in Collection SDK's Script timeout: payload.scriptTimeout, cursor: scriptCursor, context: _.pick(payload.context, SAFE_CONTEXT_VARIABLES), resolvedPackages: resolvedPackages, // legacy options legacy: { _itemId: item.id, _itemName: item.name, _itemPath: item.getPath && item.getPath(), _eventItemName: currentEventItem && currentEventItem.name } }, function (err, result) { this.host.removeAllListeners(EXECUTION_REQUEST_EVENT_BASE + executionId); this.host.removeAllListeners(EXECUTION_ASSERTION_EVENT_BASE + executionId); this.host.removeAllListeners(EXECUTION_RESPONSE_EVENT_BASE + executionId); this.host.removeAllListeners(EXECUTION_COOKIES_EVENT_BASE + executionId); this.host.removeAllListeners(EXECUTION_ERROR_EVENT_BASE + executionId); this.host.removeAllListeners(EXECUTION_SKIP_REQUEST_EVENT_BASE + executionId); this.host.removeAllListeners(EXECUTION_VAULT_BASE + executionId); // Handle async errors as well. // If there was an error running the script itself, that takes precedence if (!err && asyncScriptError) { err = asyncScriptError; } // electron IPC does not bubble errors to the browser process, so we serialize it here. err && (err = serialisedError(err, true)); // if it is defined that certain variables are to be synced back to result, we do the same track && result && track.forEach(function (variable) { if (!(_.isObject(result[variable]) && payload.context[variable])) { return; } var contextVariable = payload.context[variable], mutations = result[variable].mutations; // bail out if there are no mutations if (!mutations) { return; } // ensure that variable scope is treated accordingly if (_.isFunction(contextVariable.applyMutation)) { mutations = new sdk.MutationTracker(result[variable].mutations); mutations.applyOn(contextVariable); } // @todo: unify the non variable scope flows and consume diff always // and drop sending the full variable scope from sandbox else { util.syncObject(contextVariable, result[variable]); } }); // Get the failures. If there was an error running the script itself, that takes precedence if (!err && (abortOnFailure || stopOnFailure)) { err = postProcessContext(result, assertionFailed); // also use async assertions } // Ensure that we have SDK instances, not serialized plain objects. // @todo - should this be handled by the sandbox? result && result._variables && (result._variables = new sdk.VariableScope(result._variables)); result && result.environment && (result.environment = new sdk.VariableScope(result.environment)); result && result.globals && (result.globals = new sdk.VariableScope(result.globals)); result && result.collectionVariables && (result.collectionVariables = new sdk.VariableScope(result.collectionVariables)); result && result.request && (result.request = new sdk.Request(result.request)); // vault secrets are not sent to sandbox, thus using the scope from run context. if (hasVaultAccess && vaultSecrets) { result.vaultSecrets = vaultSecrets; // Prevent mutations from being carry-forwarded to subsequent events vaultSecrets.disableTracking(); } // @note Since postman-sandbox@3.5.2, response object is not included in the execution // result. // Refer: https://github.com/postmanlabs/postman-sandbox/pull/512 // Adding back here to avoid breaking change in `script` callback. // @todo revisit script callback args in runtime v8. result && payload.context && payload.context.response && (result.response = new sdk.Response(payload.context.response)); // persist the pm.variables for the next script result && result._variables && (payload.context._variables = new sdk.VariableScope(result._variables)); // persist the pm.variables for the next request result && result._variables && (this.state._variables = new sdk.VariableScope(result._variables)); // persist the mutated request in payload context, // @note this will be used for the next prerequest script or // upcoming commands(request, httprequest). result && result.request && (payload.context.request = result.request); // now that this script is done executing, we trigger the event and move to the next script this.triggers.script(err || null, scriptCursor, result, script, event, item); // move to next script and pass on the results for accumulation done(((stopOnScriptError || abortOnError || stopOnFailure) && err) ? err : null, _.assign({ event, script, result }, err && { error: err })); // we use assign here to avoid needless error property }.bind(this)); }; if (!packageResolver) { // If packageResolver is not present, make sure that // resolvedPackages is undefined. This ensures that // pm.require command will not be present in the sandbox. return executeScript({}, next); } return packageResolver({ packages: script.packages }, (err, resolvedPackages) => { if (err) { return next(err); } // Note that we don't check if resolvedPackages is valid // here but let sandbox handle it. return executeScript({ resolvedPackages }, next); }); }.bind(this), function (err, results) { // trigger the event completion callback this.triggers[eventName](null, cursor, results, item); next((abortOnError && err) ? err : null, results, err, shouldSkipExecution); }.bind(this)); } } };