diff --git a/lib/runner/extensions/event.command.js b/lib/runner/extensions/event.command.js index 9cb47f859..d30336c87 100644 --- a/lib/runner/extensions/event.command.js +++ b/lib/runner/extensions/event.command.js @@ -408,20 +408,28 @@ module.exports = { } }.bind(this)); - // Explicitly enable tracking for vault secrets here as this will - // not be sent to sandbox who otherwise takes care of mutation tracking - // This is important especially when dealing with nested requests, without this, the parent req might - // not have a pm.vault. call and thus no mutations would bubble up and apply to the parent + // enable tracking for vault secrets when using old format (VariableScope) if (vaultSecrets && vaultSecrets.enableTracking) { vaultSecrets.enableTracking({ autoCompact: true }); } this.host.on(EXECUTION_VAULT_BASE + executionId, async function (id, cmd, ...args) { + // check if using old format (VariableScope) or new format (with resolver) + var isOldFormat = vaultSecrets && typeof vaultSecrets.get === 'function', + vaultResolver = vaultSecrets && vaultSecrets.resolver, + vaultPrefix = (vaultSecrets && vaultSecrets.prefix) || 'vault:', + vaultCache = (vaultSecrets && vaultSecrets._cache) || {}, + allowScriptAccess = isOldFormat ? + (vaultSecrets._ && vaultSecrets._.allowScriptAccess) : + (vaultSecrets && vaultSecrets.allowScriptAccess); + + // check vault access on first call if (hasVaultAccess === undefined) { try { // eslint-disable-next-line require-atomic-updates hasVaultAccess = Boolean(this.state.nestedRequest?.hasVaultAccess || - await vaultSecrets?._?.allowScriptAccess(rootItemId)); + (typeof allowScriptAccess === 'function' && + await allowScriptAccess(rootItemId))); } catch (_) { // eslint-disable-next-line require-atomic-updates @@ -433,8 +441,6 @@ module.exports = { this.state.nestedRequest.hasVaultAccess = hasVaultAccess; } - // 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) { @@ -445,7 +451,73 @@ module.exports = { return dispatch(`Invalid vault command: ${cmd}`); } - dispatch(null, vaultSecrets[cmd](...args)); + // handle OLD format: use VariableScope methods directly + if (isOldFormat) { + return dispatch(null, vaultSecrets[cmd](...args)); + } + + // handle NEW format: use resolver for on-demand fetching + if (cmd === 'get') { + var key = args[0]; + + // check if value is in cache + if (Object.hasOwn(vaultCache, key)) { + return dispatch(null, vaultCache[key]); + } + + // if no resolver, return undefined + if (typeof vaultResolver !== 'function') { + return dispatch(null, undefined); + } + + // call resolver for on-demand fetch + try { + var resolverContext = { + keys: [key], + url: payload.context && payload.context.item && + payload.context.item.request && + payload.context.item.request.url && + payload.context.item.request.url.toString(), + itemId: rootItemId, + executionId: executionId, + source: 'script' + }; + + vaultResolver(resolverContext, function (err, resolved) { + if (err || !Array.isArray(resolved) || resolved.length === 0) { + return dispatch(null, undefined); + } + + // cache the resolved value + var variable = resolved[0]; + + if (variable && variable.key) { + var keyWithoutPrefix = variable.key.startsWith(vaultPrefix) ? + variable.key.substring(vaultPrefix.length) : variable.key; + + vaultCache[keyWithoutPrefix] = variable.value; + vaultSecrets._cache = vaultCache; + } + + dispatch(null, variable && variable.value); + }); + } + catch (resolverErr) { + dispatch(null, undefined); + } + } + else if (cmd === 'set') { + // set value in cache + vaultCache[args[0]] = args[1]; + vaultSecrets._cache = vaultCache; + dispatch(null, undefined); + } + else if (cmd === 'unset') { + // remove from cache + delete vaultCache[args[0]]; + vaultSecrets._cache = vaultCache; + dispatch(null, undefined); + } }.bind(this)); this.host.on(EXECUTION_REQUEST_EVENT_BASE + executionId, @@ -596,11 +668,14 @@ module.exports = { (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. + // vault secrets handling in result if (vaultSecrets) { - // Prevent mutations from being carry-forwarded to subsequent events - vaultSecrets.disableTracking(); + // disable tracking for old format to prevent mutations carry-forward + if (typeof vaultSecrets.disableTracking === 'function') { + vaultSecrets.disableTracking(); + } + // pass vault secrets to result if access was granted if (hasVaultAccess || this.state.nestedRequest?.hasVaultAccess) { result.vaultSecrets = vaultSecrets; } diff --git a/lib/runner/extensions/request.command.js b/lib/runner/extensions/request.command.js index 324d750f5..388593ff2 100644 --- a/lib/runner/extensions/request.command.js +++ b/lib/runner/extensions/request.command.js @@ -1,6 +1,6 @@ var _ = require('lodash'), createItemContext = require('../create-item-context'), - { resolveVariables } = require('../util'); + util = require('../util'); module.exports = { @@ -12,38 +12,43 @@ module.exports = { process: { request (payload, next) { - var abortOnError = _.has(payload, 'abortOnError') ? payload.abortOnError : this.options.abortOnError, + var self = this, + abortOnError = _.has(payload, 'abortOnError') ? payload.abortOnError : this.options.abortOnError, - // helper function to trigger `response` callback anc complete the command + // helper function to trigger `response` callback and complete the command complete = function (err, nextPayload) { // nextPayload will be empty for unhandled errors // trigger `response` callback // nextPayload.response will be empty for error flows // the `item` argument is resolved and mutated here - nextPayload && this.triggers.response(err, nextPayload.coords, nextPayload.response, + nextPayload && self.triggers.response(err, nextPayload.coords, nextPayload.response, nextPayload.request, nextPayload.item, nextPayload.cookies, nextPayload.history); // the error is passed twice to allow control between aborting the error vs just // bubbling it up return next(err && abortOnError ? err : null, nextPayload, err); - }.bind(this), + }, context = createItemContext(payload); - // resolve variables in item and auth - resolveVariables(context, payload); - - // add context for use, after resolution - payload.context = context; - - // we do not queue `httprequest` instruction here, - // queueing will unblock the item command to prepare for the next `event` instruction - // at this moment request is not fulfilled, and we want to block it - this.immediate('httprequest', payload) - .done(function (nextPayload, err) { - // change signature to error first - complete(err, nextPayload); - }) - .catch(complete); + // resolve variables in item and auth (supports async vault resolution) + util.resolveVariables(context, payload, function (err) { + if (err) { + return complete(err); + } + + // add context for use, after resolution + payload.context = context; + + // we do not queue `httprequest` instruction here, + // queueing will unblock the item command to prepare for the next `event` instruction + // at this moment request is not fulfilled, and we want to block it + self.immediate('httprequest', payload) + .done(function (nextPayload, httpErr) { + // change signature to error first + complete(httpErr, nextPayload); + }) + .catch(complete); + }); } } }; diff --git a/lib/runner/extensions/waterfall.command.js b/lib/runner/extensions/waterfall.command.js index 261e1f13d..49ea63760 100644 --- a/lib/runner/extensions/waterfall.command.js +++ b/lib/runner/extensions/waterfall.command.js @@ -3,8 +3,8 @@ var _ = require('lodash'), { getIterationData, prepareVariablesScope, - processExecutionResult, - prepareVaultVariableScope + prepareVaultVariableScope, + processExecutionResult } = require('../util'); /** @@ -17,9 +17,14 @@ module.exports = { init: function (done) { var state = this.state; - // prepare the vault variable scope and other variables + // prepare variable scopes prepareVariablesScope(state); - prepareVaultVariableScope(state.vaultSecrets); + + // prepare vault scope for old format (with values) + // new format (with resolver) doesn't need this + if (state.vaultSecrets && state.vaultSecrets.values && !state.vaultSecrets.resolver) { + prepareVaultVariableScope(state.vaultSecrets); + } // ensure that the items and iteration data set is in place !_.isArray(state.items) && (state.items = []); diff --git a/lib/runner/nested-request.js b/lib/runner/nested-request.js index 90d2e771f..16545759e 100644 --- a/lib/runner/nested-request.js +++ b/lib/runner/nested-request.js @@ -97,18 +97,30 @@ function runNestedRequest ({ executionId, isExecutionSkipped, vaultSecrets, item // Merge local variables from parent requests & scope + nestedRequest.options.variables localVariables.values = [...localVariables.values, ...variableOverrides]; - // Why clone? Each runner execution needs to track and mutate its vault variables separately and propagate - // it back up and further down. We don't want to accidentally reset mutations between executions by sharing - // this scope + // clone vault secrets for nested requests + // each runner execution needs its own scope to track mutations separately let clonedVaultSecrets; if (vaultSecrets) { - clonedVaultSecrets = new sdk.VariableScope({ - values: vaultSecrets.values, - prefix: vaultSecrets.prefix - }); + // check if using old format (VariableScope) or new format (with resolver) + if (typeof vaultSecrets.get === 'function') { + // old format: clone the VariableScope + clonedVaultSecrets = new sdk.VariableScope({ + values: vaultSecrets.values, + prefix: vaultSecrets.prefix + }); - clonedVaultSecrets._ = vaultSecrets._; + clonedVaultSecrets._ = vaultSecrets._; + } + else { + // new format: share cache with parent so resolved secrets propagate + clonedVaultSecrets = { + prefix: vaultSecrets.prefix, + resolver: vaultSecrets.resolver, + allowScriptAccess: vaultSecrets.allowScriptAccess, + _cache: vaultSecrets._cache || {} + }; + } } new runner().run(runnableRefRequestCollection, @@ -143,13 +155,13 @@ function runNestedRequest ({ executionId, isExecutionSkipped, vaultSecrets, item } run.start({ script (_err, _cursor, result) { - // This is to sync changes to pm.variables, pm.environment & pm.globals + // sync changes to pm.variables, pm.environment & pm.globals // that happened inside the nested request's script - // back to parent request's scripts still currently executing. + // back to parent request's scripts still currently executing // collectionVariables don't need to be synced between parent and nested - // All other global variables defined by syntax like 'a=1' + // all other global variables defined by syntax like 'a=1' // are anyway synced as the sandbox's common scope is shared across runs if (result) { SYNCABLE_CONTEXT_VARIABLE_SCOPES.forEach(function (type) { @@ -163,11 +175,13 @@ function runNestedRequest ({ executionId, isExecutionSkipped, vaultSecrets, item ]; }); + // sync vault mutations for old format (VariableScope) if (clonedVaultSecrets && clonedVaultSecrets.mutations) { const mutations = new sdk.MutationTracker(clonedVaultSecrets.mutations); mutations.applyOn(vaultSecrets); } + // new format: cache is shared by reference, no explicit sync needed } }, request (err, _cursor, ...rest) { diff --git a/lib/runner/replay-controller.js b/lib/runner/replay-controller.js index 93982a1e6..87ac16e2b 100644 --- a/lib/runner/replay-controller.js +++ b/lib/runner/replay-controller.js @@ -1,6 +1,6 @@ var _ = require('lodash'), createItemContext = require('./create-item-context'), - { resolveVariables } = require('./util'), + util = require('./util'), // total number of replays allowed MAX_REPLAY_COUNT = 3, @@ -33,6 +33,8 @@ _.assign(ReplayController.prototype, /** @lends ReplayController.prototype */{ * @param {Function} failure this callback is invoked when replay controller decided not to send the request */ requestReplay (context, item, desiredPayload, success, failure) { + var self = this; + // max retries exceeded if (this.count >= MAX_REPLAY_COUNT) { return failure(new Error('runtime: maximum intermediate request limit exceeded')); @@ -55,13 +57,18 @@ _.assign(ReplayController.prototype, /** @lends ReplayController.prototype */{ // create item context from the new item payload.context = createItemContext(payload, context); - resolveVariables(payload.context, payload); + // resolve variables asynchronously (supports on-demand vault resolution) + util.resolveVariables(payload.context, payload, function (err) { + if (err) { + return success(err); + } - this.run.immediate('httprequest', payload) - .done(function (response) { - success(null, response); - }) - .catch(success); + self.run.immediate('httprequest', payload) + .done(function (response) { + success(null, response); + }) + .catch(success); + }); }, /** diff --git a/lib/runner/util.js b/lib/runner/util.js index e195a1e5a..9611ce6bf 100644 --- a/lib/runner/util.js +++ b/lib/runner/util.js @@ -15,9 +15,38 @@ var { Url, UrlMatchPatternList, VariableList } = require('postman-collection'), */ STRING = 'string', + // regex to match vault variable patterns like {{vault:key_name}} + VAULT_VARIABLE_REGEX = /\{\{([^{}]+)\}\}/g, + createReadStream, // function extractSNR, // function - prepareLookupHash; // function + prepareLookupHash, // function + extractVaultKeysFromString; // function + +/** + * Extracts vault variable keys from a string. + * + * @private + * @param {String} str - String to extract vault keys from + * @param {String} prefix - Vault prefix (e.g., 'vault:') + * @param {Set} keysSet - Set to add found keys to + */ +extractVaultKeysFromString = function (str, prefix, keysSet) { + if (typeof str !== STRING) { + return; + } + + var match, key; + + while ((match = VAULT_VARIABLE_REGEX.exec(str)) !== null) { + key = match[1]; + + if (key.startsWith(prefix)) { + // store the key without the prefix for the resolver + keysSet.add(key.substring(prefix.length)); + } + } +}; /** * Create readable stream for given file as well as detect possible file @@ -216,6 +245,75 @@ module.exports = { return createReadStream(resolver, fileSrc, callback); }, + /** + * Extracts all vault variable keys referenced in an item's request and auth. + * + * @param {Item} item - The SDK Item to scan + * @param {RequestAuth} auth - The auth configuration + * @param {String} prefix - Vault prefix (e.g., 'vault:') + * @returns {Array} - Array of vault keys without prefix + */ + extractVaultVariableKeys (item, auth, prefix) { + if (!prefix) { + return []; + } + + var keysSet = new Set(), + request = item && item.request, + authParams, + body; + + if (!request) { + return []; + } + + // extract from URL + extractVaultKeysFromString(request.url && request.url.toString(), prefix, keysSet); + + // extract from headers + request.headers && request.headers.each(function (header) { + extractVaultKeysFromString(header.key, prefix, keysSet); + extractVaultKeysFromString(header.value, prefix, keysSet); + }); + + // extract from body + if (request.body) { + body = request.body; + + if (body.mode === 'raw' && body.raw) { + extractVaultKeysFromString(body.raw, prefix, keysSet); + } + else if (body.mode === 'urlencoded' && body.urlencoded) { + body.urlencoded.each(function (param) { + extractVaultKeysFromString(param.key, prefix, keysSet); + extractVaultKeysFromString(param.value, prefix, keysSet); + }); + } + else if (body.mode === 'formdata' && body.formdata) { + body.formdata.each(function (param) { + extractVaultKeysFromString(param.key, prefix, keysSet); + extractVaultKeysFromString(param.value, prefix, keysSet); + }); + } + else if (body.mode === 'graphql' && body.graphql) { + extractVaultKeysFromString(body.graphql.query, prefix, keysSet); + extractVaultKeysFromString(body.graphql.variables, prefix, keysSet); + } + } + + // extract from auth + if (auth) { + authParams = auth.parameters && auth.parameters(); + + authParams && authParams.each(function (param) { + extractVaultKeysFromString(param.key, prefix, keysSet); + extractVaultKeysFromString(param.value, prefix, keysSet); + }); + } + + return Array.from(keysSet); + }, + /** * Mutates the given variable scope to be a vault variable scope by * converting the domains to UrlMatchPattern and adding a helper function @@ -287,20 +385,30 @@ module.exports = { }, /** - * ensure that the environment, globals and collectionVariables are in VariableScope instance format - * @param {*} state application state object. + * Ensure that the environment, globals and collectionVariables are in VariableScope instance format. + * + * @param {Object} state - Application state object. */ prepareVariablesScope (state) { state.environment = VariableScope.isVariableScope(state.environment) ? state.environment : new VariableScope(state.environment); state.globals = VariableScope.isVariableScope(state.globals) ? state.globals : new VariableScope(state.globals); - state.vaultSecrets = VariableScope.isVariableScope(state.vaultSecrets) ? state.vaultSecrets : - new VariableScope(state.vaultSecrets); state.collectionVariables = VariableScope.isVariableScope(state.collectionVariables) ? state.collectionVariables : new VariableScope(state.collectionVariables); state._variables = VariableScope.isVariableScope(state.localVariables) ? state.localVariables : new VariableScope(state.localVariables); + + // handle vault secrets - supports both old format (with values) and new format (with resolver) + if (!state.vaultSecrets) { + state.vaultSecrets = {}; + } + else if (!state.vaultSecrets.resolver) { + // old format: vaultSecrets is a VariableScope-like object with values + state.vaultSecrets = VariableScope.isVariableScope(state.vaultSecrets) ? state.vaultSecrets : + new VariableScope(state.vaultSecrets); + } + // new format: vaultSecrets has resolver function - keep as is }, prepareLookupHash, @@ -399,6 +507,7 @@ module.exports = { /** * Resolve variables in item and auth in context. + * Supports both old format (VariableScope with values) and new format (with resolver). * * @param {ItemContext} context - * @param {Item} [context.item] - @@ -409,77 +518,176 @@ module.exports = { * @param {VariableScope} payload.environment - * @param {VariableScope} payload.collectionVariables - * @param {VariableScope} payload.globals - - * @param {VariableScope} payload.vaultSecrets - + * @param {Object|VariableScope} payload.vaultSecrets - Vault secrets (old or new format) + * @param {Function} callback - Callback function (err) */ - resolveVariables (context, payload) { - if (!(context.item && context.item.request)) { return; } + resolveVariables (context, payload, callback) { + if (!(context.item && context.item.request)) { + return callback(); + } - // @todo - resolve variables in a more graceful way - var variableDefinitions = [ - // extract the variable list from variable scopes - // @note: this is the order of precedence for variable resolution - don't change it + var self = this, + // @note: order of precedence for variable resolution - don't change it + variableDefinitions = [ _.get(payload, '_variables.values', []), _.get(payload, 'data', []), _.get(payload, 'environment.values', []), _.get(payload, 'collectionVariables.values', []), _.get(payload, 'globals.values', []) - // @note vault variables are added later + // @note vault variables are added later ], - vaultValues = _.get(payload, 'vaultSecrets.values'), - + vaultSecrets = payload.vaultSecrets, + vaultResolver = vaultSecrets && vaultSecrets.resolver, + vaultPrefix = (vaultSecrets && vaultSecrets.prefix) || 'vault:', + vaultCache = (vaultSecrets && vaultSecrets._cache) || {}, + + // check if using old format (VariableScope with values) + hasOldFormatVault = vaultSecrets && vaultSecrets.values && !vaultResolver, + vaultValues = hasOldFormatVault ? vaultSecrets.values : null, hasVaultSecrets = vaultValues ? vaultValues.count() > 0 : false, itemParent = context.item.parent(), urlObj = context.item.request.url, // @note URL string is used to resolve nested variables as URL parser doesn't support them well. - urlString = urlObj.toString(), + urlString = urlObj && urlObj.toString(), unresolvedUrlString = urlString, vaultVariables, vaultUrl, item, - auth; + auth, + vaultKeys, + keysToResolve, + resolvedUrl, + resolverContext; + + // helper to finalize variable resolution + function finalizeResolution (vaultVars) { + // add vault variables to definitions if we have any + // vaultVars can be a VariableList or null + if (vaultVars && (vaultVars.count ? vaultVars.count() > 0 : vaultVars.length > 0)) { + variableDefinitions.push(vaultVars); + } + + // resolve URL string with all variable definitions + if (urlString) { + urlString = sdk.Property.replaceSubstitutions(urlString, variableDefinitions); + } + + // @todo - no need to sync variables when SDK starts supporting resolution from scope directly + // @todo - avoid resolving the entire item as this unnecessarily resolves URL + item = context.item = new sdk.Item(context.item.toObjectResolved(null, + variableDefinitions, { ignoreOwnVariables: true })); + + // restore the parent reference + item.setParent(itemParent); + + // re-parse and update the URL from the resolved string + urlString && (item.request.url = new sdk.Url(urlString)); + + auth = context.auth; + + // resolve variables in auth + auth && (context.auth = new sdk.RequestAuth(auth.toObjectResolved(null, + variableDefinitions, { ignoreOwnVariables: true }))); + callback(); + } + + // handle OLD format: VariableScope with pre-loaded values and domain matching if (hasVaultSecrets) { - // get the vault variables that match the unresolved URL string + // get the vault variables that match the unresolved URL string vaultUrl = urlObj.protocol ? urlString : `http://${urlString}`; // force protocol - vaultVariables = payload.vaultSecrets.__getMatchingVariables(vaultUrl); + vaultVariables = vaultSecrets.__getMatchingVariables(vaultUrl); // resolve variables in URL string with initial vault variables urlString = sdk.Property.replaceSubstitutions(urlString, [...variableDefinitions, vaultVariables]); if (urlString !== unresolvedUrlString) { - // get the final list of vault variables that match the resolved URL string + // get the final list of vault variables that match the resolved URL string vaultUrl = new sdk.Url(urlString).toString(true); - vaultVariables = payload.vaultSecrets.__getMatchingVariables(vaultUrl); + vaultVariables = vaultSecrets.__getMatchingVariables(vaultUrl); // resolve vault variables in URL string // @note other variable scopes are skipped as they are already resolved urlString = sdk.Property.replaceSubstitutions(urlString, [vaultVariables]); } - // add vault variables to the list of variable definitions - variableDefinitions.push(vaultVariables); + return finalizeResolution(vaultVariables); } - else if (urlString) { - urlString = sdk.Property.replaceSubstitutions(urlString, variableDefinitions); + + // handle NEW format: on-demand resolution via resolver function + if (typeof vaultResolver !== FUNCTION) { + // no resolver and no pre-loaded values, proceed without vault resolution + return finalizeResolution(null); } - // @todo - no need to sync variables when SDK starts supporting resolution from scope directly - // @todo - avoid resolving the entire item as this unnecessarily resolves URL - item = context.item = new sdk.Item(context.item.toObjectResolved(null, - variableDefinitions, { ignoreOwnVariables: true })); + // extract vault keys referenced in the request + vaultKeys = self.extractVaultVariableKeys(context.item, context.auth, vaultPrefix); - // restore the parent reference - item.setParent(itemParent); + // if no vault keys found, skip vault resolution + if (vaultKeys.length === 0) { + return finalizeResolution(null); + } - // re-parse and update the URL from the resolved string - urlString && (item.request.url = new sdk.Url(urlString)); + // check which keys need resolution (not already in cache) + keysToResolve = vaultKeys.filter(function (key) { + return !Object.hasOwn(vaultCache, key); + }); - auth = context.auth; + // helper to build VariableList from cache + function buildVaultVariableList (keys) { + var variables = keys.map(function (key) { + return { key: vaultPrefix + key, value: vaultCache[key] }; + }).filter(function (v) { return v.value !== undefined; }); + + return new sdk.VariableList(null, variables); + } + + // if all keys are cached, use cached values + if (keysToResolve.length === 0) { + return finalizeResolution(buildVaultVariableList(vaultKeys)); + } - // resolve variables in auth - auth && (context.auth = new sdk.RequestAuth(auth.toObjectResolved(null, - variableDefinitions, { ignoreOwnVariables: true }))); + // resolve URL first with non-vault variables for context + resolvedUrl = urlString ? + sdk.Property.replaceSubstitutions(urlString, variableDefinitions) : ''; + + // build context for resolver + resolverContext = { + keys: keysToResolve, + url: resolvedUrl, + itemId: context.originalItem && context.originalItem.id, + executionId: context.coords && context.coords.ref, + source: 'request' + }; + + // call the resolver + vaultResolver(resolverContext, function (err, resolved) { + // on error, log warning and continue without resolution + if (err) { + // vault resolution failed, placeholders will remain unresolved + return finalizeResolution(null); + } + + // populate cache with resolved values + if (Array.isArray(resolved)) { + resolved.forEach(function (variable) { + if (variable && variable.key) { + // store in cache without prefix + var keyWithoutPrefix = variable.key.startsWith(vaultPrefix) ? + variable.key.substring(vaultPrefix.length) : variable.key; + + vaultCache[keyWithoutPrefix] = variable.value; + } + }); + } + + // update cache reference + vaultSecrets._cache = vaultCache; + + // build variable list from cache for all requested keys + finalizeResolution(buildVaultVariableList(vaultKeys)); + }); } }; diff --git a/test/integration/sanity/vault-on-demand-resolution.test.js b/test/integration/sanity/vault-on-demand-resolution.test.js new file mode 100644 index 000000000..ef1aa2cdc --- /dev/null +++ b/test/integration/sanity/vault-on-demand-resolution.test.js @@ -0,0 +1,496 @@ +var expect = require('chai').expect; + +describe('vault on-demand resolution', function () { + describe('request resolution', function () { + describe('should resolve secrets using resolver function', function () { + var testrun, + resolverCalls = []; + + before(function (done) { + resolverCalls = []; + + this.run({ + vaultSecrets: { + prefix: 'vault:', + resolver: function (context, callback) { + resolverCalls.push(context); + + // simulate async resolution + setTimeout(function () { + callback(null, [ + { key: 'vault:var1', value: 'https://postman-echo.com' }, + { key: 'vault:var2', value: 'postman' }, + { key: 'vault:var3', value: 'password' } + ]); + }, 10); + }, + allowScriptAccess: function () { return true; } + }, + collection: { + item: { + request: { + url: '{{vault:var1}}/basic-auth', + method: 'GET', + auth: { + type: 'basic', + basic: [ + { key: 'username', value: '{{vault:var2}}' }, + { key: 'password', value: '{{vault:var3}}' } + ] + } + } + } + } + }, function (err, results) { + testrun = results; + done(err); + }); + }); + + it('should have completed the run', function () { + expect(testrun).to.be.ok; + expect(testrun.done.getCall(0).args[0]).to.be.null; + expect(testrun).to.nested.include({ + 'done.calledOnce': true, + 'start.calledOnce': true + }); + }); + + it('should have called the resolver', function () { + expect(resolverCalls.length).to.be.at.least(1); + expect(resolverCalls[0]).to.have.property('keys'); + expect(resolverCalls[0]).to.have.property('source', 'request'); + expect(resolverCalls[0].keys).to.include.members(['var1', 'var2', 'var3']); + }); + + it('should resolve vault variables in URL', function () { + var url = testrun.request.getCall(0).args[3].url.toString(); + + expect(url).to.equal('https://postman-echo.com/basic-auth'); + }); + + it('should resolve vault variables in auth', function () { + var request = testrun.response.getCall(0).args[3], + auth = request.auth.parameters().toObject(); + + expect(auth).to.deep.include({ + username: 'postman', + password: 'password' + }); + }); + }); + + describe('should keep placeholder when resolver fails', function () { + var testrun; + + before(function (done) { + this.run({ + vaultSecrets: { + prefix: 'vault:', + resolver: function (context, callback) { + callback(new Error('Vault service unavailable')); + } + }, + collection: { + item: { + request: { + url: 'https://postman-echo.com/get?secret={{vault:secret_key}}', + method: 'GET' + } + } + } + }, function (err, results) { + testrun = results; + done(err); + }); + }); + + it('should have completed the run', function () { + expect(testrun).to.be.ok; + expect(testrun.done.getCall(0).args[0]).to.be.null; + }); + + it('should keep unresolved placeholder in URL', function () { + var url = testrun.request.getCall(0).args[3].url.toString(); + + // URL gets encoded, so check for the encoded version + expect(url).to.include('%7B%7Bvault:secret_key%7D%7D'); + }); + }); + + describe('should cache resolved secrets across requests', function () { + var testrun, + resolverCallCount = 0; + + before(function (done) { + resolverCallCount = 0; + + this.run({ + vaultSecrets: { + prefix: 'vault:', + resolver: function (context, callback) { + resolverCallCount++; + callback(null, [ + { key: 'vault:base_url', value: 'https://postman-echo.com' } + ]); + } + }, + collection: { + item: [ + { + request: { + url: '{{vault:base_url}}/get', + method: 'GET' + } + }, + { + request: { + url: '{{vault:base_url}}/post', + method: 'POST' + } + } + ] + } + }, function (err, results) { + testrun = results; + done(err); + }); + }); + + it('should have completed the run', function () { + expect(testrun).to.be.ok; + expect(testrun.done.getCall(0).args[0]).to.be.null; + }); + + it('should resolve URLs for both requests', function () { + var url1 = testrun.request.getCall(0).args[3].url.toString(), + url2 = testrun.request.getCall(1).args[3].url.toString(); + + expect(url1).to.equal('https://postman-echo.com/get'); + expect(url2).to.equal('https://postman-echo.com/post'); + }); + + it('should use cache for second request', function () { + // resolver should only be called once, second request uses cache + expect(resolverCallCount).to.equal(1); + }); + }); + }); + + describe('script resolution', function () { + describe('should resolve secrets via pm.vault.get using resolver', function () { + var testrun, + resolverCalls = []; + + before(function (done) { + resolverCalls = []; + + this.run({ + vaultSecrets: { + prefix: 'vault:', + resolver: function (context, callback) { + resolverCalls.push(context); + callback(null, [ + { key: 'vault:secret_value', value: 'resolved-from-vault' } + ]); + }, + allowScriptAccess: function () { return true; } + }, + collection: { + item: { + event: [{ + listen: 'prerequest', + script: { + exec: ` + const v = await pm.vault.get('secret_value'); + console.log(v); + ` + } + }], + request: 'https://postman-echo.com/get' + } + } + }, function (err, results) { + testrun = results; + done(err); + }); + }); + + it('should have sent the request successfully', function () { + expect(testrun).to.be.ok; + expect(testrun).to.nested.include({ + 'request.calledOnce': true + }); + }); + + it('should call resolver with script source', function () { + var scriptCalls = resolverCalls.filter(function (c) { return c.source === 'script'; }); + + expect(scriptCalls.length).to.be.at.least(1); + expect(scriptCalls[0].keys).to.deep.equal(['secret_value']); + }); + + it('should get vault secret value correctly', function () { + var consoleArgs = testrun.console.getCall(0).args.slice(2); + + expect(consoleArgs).to.deep.equal(['resolved-from-vault']); + }); + }); + + describe('should deny access when allowScriptAccess returns false', function () { + var testrun; + + before(function (done) { + this.run({ + vaultSecrets: { + prefix: 'vault:', + resolver: function (context, callback) { + callback(null, [ + { key: 'vault:secret', value: 'secret-value' } + ]); + }, + allowScriptAccess: function () { return false; } + }, + collection: { + item: { + event: [{ + listen: 'prerequest', + script: { + exec: ` + try { + await pm.vault.get('secret'); + } catch (e) { + console.error(e.message); + } + ` + } + }], + request: 'https://postman-echo.com/get' + } + } + }, function (err, results) { + testrun = results; + done(err); + }); + }); + + it('should have completed the run', function () { + expect(testrun).to.be.ok; + expect(testrun.done.getCall(0).args[0]).to.be.null; + }); + + it('should log vault access denied error', function () { + var consoleArgs = testrun.console.getCall(0).args.slice(2); + + expect(consoleArgs[0]).to.equal('Vault access denied'); + }); + }); + + describe('should allow pm.vault.set and pm.vault.get together', function () { + var testrun; + + before(function (done) { + this.run({ + vaultSecrets: { + prefix: 'vault:', + resolver: function (context, callback) { + callback(null, []); + }, + allowScriptAccess: function () { return true; } + }, + collection: { + item: { + event: [{ + listen: 'prerequest', + script: { + exec: ` + await pm.vault.set('dynamic_key', 'dynamic_value'); + const v = await pm.vault.get('dynamic_key'); + console.log(v); + ` + } + }], + request: 'https://postman-echo.com/get' + } + } + }, function (err, results) { + testrun = results; + done(err); + }); + }); + + it('should have completed the run', function () { + expect(testrun).to.be.ok; + expect(testrun.done.getCall(0).args[0]).to.be.null; + }); + + it('should get the value that was set', function () { + var consoleArgs = testrun.console.getCall(0).args.slice(2); + + expect(consoleArgs).to.deep.equal(['dynamic_value']); + }); + }); + + describe('should allow pm.vault.unset', function () { + var testrun, + resolverCallCount = 0; + + before(function (done) { + resolverCallCount = 0; + + this.run({ + vaultSecrets: { + prefix: 'vault:', + resolver: function (context, callback) { + resolverCallCount++; + + // only return value on first call + if (resolverCallCount === 1) { + return callback(null, [ + { key: 'vault:to_remove', value: 'initial_value' } + ]); + } + + return callback(null, []); + }, + allowScriptAccess: function () { return true; } + }, + collection: { + item: { + event: [{ + listen: 'prerequest', + script: { + exec: ` + const before = await pm.vault.get('to_remove'); + console.log('before:', before); + await pm.vault.unset('to_remove'); + const after = await pm.vault.get('to_remove'); + console.log('after:', after); + ` + } + }], + request: 'https://postman-echo.com/get' + } + } + }, function (err, results) { + testrun = results; + done(err); + }); + }); + + it('should have completed the run', function () { + expect(testrun).to.be.ok; + expect(testrun.done.getCall(0).args[0]).to.be.null; + }); + + it('should have value before unset', function () { + var consoleArgs = testrun.console.getCall(0).args.slice(2); + + expect(consoleArgs).to.deep.equal(['before:', 'initial_value']); + }); + + it('should not have value after unset', function () { + var consoleArgs = testrun.console.getCall(1).args.slice(2); + + // resolver returns empty array, so value is undefined/null + expect(consoleArgs[0]).to.equal('after:'); + expect(consoleArgs[1]).to.be.oneOf([undefined, null]); + }); + }); + }); + + describe('resolver context', function () { + describe('should provide URL context to resolver', function () { + var testrun, + resolverContext; + + before(function (done) { + resolverContext = null; + + this.run({ + environment: { + values: [ + { key: 'host', value: 'postman-echo.com' } + ] + }, + vaultSecrets: { + prefix: 'vault:', + resolver: function (context, callback) { + resolverContext = context; + callback(null, [ + { key: 'vault:api_key', value: 'test-api-key' } + ]); + } + }, + collection: { + item: { + request: { + url: 'https://{{host}}/get?key={{vault:api_key}}', + method: 'GET' + } + } + } + }, function (err, results) { + testrun = results; + done(err); + }); + }); + + it('should have completed the run', function () { + expect(testrun).to.be.ok; + expect(testrun.done.getCall(0).args[0]).to.be.null; + }); + + it('should provide resolved URL in context', function () { + // URL should have environment variables resolved but not vault variables + expect(resolverContext.url).to.equal('https://postman-echo.com/get?key={{vault:api_key}}'); + }); + + it('should provide keys array', function () { + expect(resolverContext.keys).to.deep.equal(['api_key']); + }); + + it('should provide source as request', function () { + expect(resolverContext.source).to.equal('request'); + }); + }); + }); + + describe('without resolver', function () { + describe('should work without resolver and not resolve vault variables', function () { + var testrun; + + before(function (done) { + this.run({ + vaultSecrets: { + prefix: 'vault:' + }, + collection: { + item: { + request: { + url: 'https://postman-echo.com/get?secret={{vault:missing}}', + method: 'GET' + } + } + } + }, function (err, results) { + testrun = results; + done(err); + }); + }); + + it('should have completed the run', function () { + expect(testrun).to.be.ok; + expect(testrun.done.getCall(0).args[0]).to.be.null; + }); + + it('should keep vault placeholder unresolved', function () { + var url = testrun.request.getCall(0).args[3].url.toString(); + + // URL gets encoded, so check for the encoded version + expect(url).to.include('%7B%7Bvault:missing%7D%7D'); + }); + }); + }); +}); +