diff --git a/CHANGELOG.yaml b/CHANGELOG.yaml index a28d202bb..f8871bf44 100644 --- a/CHANGELOG.yaml +++ b/CHANGELOG.yaml @@ -1,3 +1,7 @@ +unreleased: + new features: + - Add support for secret variable resolution + 7.51.1: date: 2026-01-27 chores: diff --git a/README.md b/README.md index 39311ee18..a396d2350 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,17 @@ runner.run(collection, { // Local variables (a "VariableScope" from the SDK) localVariables: new sdk.VariableScope(), + // Function to resolve secret variables (variables with secret: true) before request execution. + // Receives { secrets, url }; url is request URL without query params. Callback(err, result). + // On fatal error: callback(err) — execution stops. On success: callback(null, result) where result + // is Array<{ resolvedValue?, error?, allowedInScript? }>; result[i] maps to secrets[i]. + // allowedInScript: true exposes value to scripts; false/undefined masks it from pm.environment/pm.variables. + secretResolver: function ({ secrets, url }, callback) { + callback(null, secrets.map(function (s) { + return { resolvedValue: 'resolved-string', error: undefined, allowedInScript: true }; + })); + }, + // Execute a folder/request using id/name or path entrypoint: { // execute a folder/request using id or name diff --git a/lib/runner/extensions/event.command.js b/lib/runner/extensions/event.command.js index 9cb47f859..794e5d1b0 100644 --- a/lib/runner/extensions/event.command.js +++ b/lib/runner/extensions/event.command.js @@ -38,7 +38,46 @@ var _ = require('lodash'), getCookieDomain, // fn postProcessContext, // fn - sanitizeFiles; // fn + sanitizeFiles, // fn + maskForbiddenSecrets; // fn + +/** + * Clones variable scopes and masks variables in forbiddenSecretKeys (Set of "scopeName:variableKey"). + * Used to hide secrets not allowed in scripts while keeping them available for request substitution. + * + * @param {Object} context - Context with environment, globals, collectionVariables + * @param {Set} forbiddenSecretKeys - Set of "scopeName:variableKey" for secrets with allowedInScript=false + * @returns {Object} - Context with cloned, masked scopes + */ +maskForbiddenSecrets = function (context, forbiddenSecretKeys) { + if (!forbiddenSecretKeys || forbiddenSecretKeys.size === 0) { + return context; + } + + var scopeNames = ['environment', 'globals', 'collectionVariables'], + maskedContext = _.assign({}, context); + + scopeNames.forEach(function (scopeName) { + var scope = context[scopeName], + maskedScope; + + if (!scope || !sdk.VariableScope.isVariableScope(scope)) { + return; + } + + maskedScope = new sdk.VariableScope(scope.toJSON ? scope.toJSON() : scope); + + maskedScope.values.each(function (variable) { + if (variable && forbiddenSecretKeys.has(scopeName + ':' + variable.key)) { + variable.set(undefined); + } + }); + + maskedContext[scopeName] = maskedScope; + }); + + return maskedContext; +}; postProcessContext = function (execution, failures) { // function determines whether the event needs to abort var error; @@ -514,6 +553,12 @@ module.exports = { const currentEventItem = event.parent && event.parent(), executeScript = ({ resolvedPackages }, done) => { + var contextToUse = _.pick(payload.context, SAFE_CONTEXT_VARIABLES); + + if (payload.context._forbiddenSecretKeys) { + contextToUse = maskForbiddenSecrets(contextToUse, payload.context._forbiddenSecretKeys); + } + // finally execute the script this.host.execute(event, { id: executionId, @@ -522,7 +567,7 @@ module.exports = { // @todo: Expose this as a property in Collection SDK's Script timeout: payload.scriptTimeout, cursor: scriptCursor, - context: _.pick(payload.context, SAFE_CONTEXT_VARIABLES), + context: contextToUse, resolvedPackages: resolvedPackages, disabledAPIs: !_.get(this, 'options.script.requestResolver') ? diff --git a/lib/runner/extensions/item.command.js b/lib/runner/extensions/item.command.js index ebfa3cc70..9d45c331e 100644 --- a/lib/runner/extensions/item.command.js +++ b/lib/runner/extensions/item.command.js @@ -2,6 +2,8 @@ var _ = require('lodash'), uuid = require('uuid'), Response = require('postman-collection').Response, visualizer = require('../../visualizer'), + resolveSecrets = require('../resolve-secrets').resolveSecrets, + { resolveUrlString } = require('../util'), /** * List of request properties which can be mutated via pre-request @@ -121,7 +123,11 @@ module.exports = { stopOnFailure = this.options.stopOnFailure, delay = _.get(this.options, 'delay.item'), - ctxTemplate; + ctxTemplate, + self = this, + secretResolver = this.state.secretResolver, + secretResolutionPayload, + executeItemFlow; // validate minimum parameters required for the command to work if (!(item && coords)) { @@ -134,159 +140,271 @@ module.exports = { // here we code to queue prerequest script, then make a request and then execute test script this.triggers.beforeItem(null, coords, item); - this.queueDelay(function () { + // Build payload for secret resolution + secretResolutionPayload = { + item, + environment, + globals, + collectionVariables, + vaultSecrets, + _variables, + data + }; + + /** + * Execute the main item flow (delay, prerequest, request, test) + * + * @param {Array} [secretResolutionErrors] - Errors from partial secret resolution + * @param {Set} [forbiddenSecretKeys] - Set of scopeName:variableKey for secrets with allowedInScript=false + */ + executeItemFlow = function (secretResolutionErrors, forbiddenSecretKeys) { + var baselineUrl = secretResolver ? + resolveUrlString(item, secretResolutionPayload) : ''; + + self.queueDelay(function () { // create the context object for scripts to run - ctxTemplate = { - collectionVariables: collectionVariables, - vaultSecrets: vaultSecrets, - _variables: _variables, - globals: globals, - environment: environment, - data: data, - request: item.request - }; - - // @todo make it less nested by coding Instruction.thenQueue - this.queue('event', { - name: 'prerequest', - item: item, - coords: coords, - context: ctxTemplate, - // No need to include vaultSecrets here as runtime takes care of tracking internally - trackContext: ['globals', 'environment', 'collectionVariables'], - stopOnScriptError: stopOnError, - stopOnFailure: stopOnFailure - }).done(function (prereqExecutions, prereqExecutionError, shouldSkipExecution) { + ctxTemplate = { + collectionVariables: collectionVariables, + vaultSecrets: vaultSecrets, + _variables: _variables, + globals: globals, + environment: environment, + data: data, + request: item.request, + _forbiddenSecretKeys: forbiddenSecretKeys + }; + + // @todo make it less nested by coding Instruction.thenQueue + this.queue('event', { + name: 'prerequest', + item: item, + coords: coords, + context: ctxTemplate, + // No need to include vaultSecrets here as runtime takes care of tracking internally + trackContext: ['globals', 'environment', 'collectionVariables'], + stopOnScriptError: stopOnError, + stopOnFailure: stopOnFailure + }).done(function (prereqExecutions, prereqExecutionError, shouldSkipExecution) { // if stop on error is marked and script executions had an error, // do not proceed with more commands, instead we bail out - if ((stopOnError || stopOnFailure) && prereqExecutionError) { - this.triggers.item(null, coords, item); // @todo - should this trigger receive error? - - return callback && callback.call(this, prereqExecutionError, { - prerequest: prereqExecutions - }); - } - - if (shouldSkipExecution) { - this.triggers.item(prereqExecutionError, coords, item, null, { isSkipped: true }); - - return callback && callback.call(this, prereqExecutionError, { - prerequest: prereqExecutions - }); - } - - // update allowed request mutation properties with the mutated context - // @note from this point forward, make sure this mutated - // request instance is used for upcoming commands. - ALLOWED_REQUEST_MUTATIONS.forEach(function (property) { - if (_.has(ctxTemplate, ['request', property])) { - item.request[property] = ctxTemplate.request[property]; - } + if ((stopOnError || stopOnFailure) && prereqExecutionError) { + this.triggers.item(null, coords, item); // @todo - should this trigger receive error? - // update property's parent reference - if (item.request[property] && typeof item.request[property].setParent === 'function') { - item.request[property].setParent(item.request); + return callback && callback.call(this, prereqExecutionError, { + prerequest: prereqExecutions + }); } - }); - - this.queue('request', { - item: item, - vaultSecrets: ctxTemplate.vaultSecrets, - globals: ctxTemplate.globals, - environment: ctxTemplate.environment, - collectionVariables: ctxTemplate.collectionVariables, - _variables: ctxTemplate._variables, - data: ctxTemplate.data, - coords: coords, - source: 'collection' - }).done(function (result, requestError) { - !result && (result = {}); - - var request = result.request, - response = result.response, - cookies = result.cookies; - if ((stopOnError || stopOnFailure) && requestError) { - this.triggers.item(null, coords, item); // @todo - should this trigger receive error? + if (shouldSkipExecution) { + this.triggers.item(prereqExecutionError, coords, item, null, { isSkipped: true }); - return callback && callback.call(this, requestError, { - request + return callback && callback.call(this, prereqExecutionError, { + prerequest: prereqExecutions }); } - // also the test object requires the updated request object (since auth helpers may modify it) - request && (ctxTemplate.request = request); - - // @note convert response instance to plain object. - // we want to avoid calling Response.toJSON() which triggers toJSON on Response.stream buffer. - // Because that increases the size of stringified object by 3 times. - // Also, that increases the total number of tokens (buffer.data) whereas Buffer.toString - // generates a single string that is easier to stringify and sent over the UVM bridge. - response && (ctxTemplate.response = getResponseJSON(response)); - - // set cookies for this transaction - cookies && (ctxTemplate.cookies = cookies); - - // the context template also has a test object to store assertions - ctxTemplate.tests = {}; // @todo remove - - this.queue('event', { - name: 'test', - item: item, - coords: coords, - context: ctxTemplate, - // No need to include vaultSecrets here as runtime takes care of tracking internally - trackContext: ['tests', 'globals', 'environment', 'collectionVariables'], - stopOnScriptError: stopOnError, - abortOnFailure: abortOnFailure, - stopOnFailure: stopOnFailure - }).done(function (testExecutions, testExecutionError) { - var visualizerData = extractVisualizerData(prereqExecutions, testExecutions), - visualizerResult; - - if (visualizerData) { - visualizer.processTemplate(visualizerData.template, - visualizerData.data, - visualizerData.options, - function (err, processedTemplate) { - visualizerResult = { - // bubble up the errors while processing template through visualizer result - error: err, - - // add processed template and data to visualizer result - processedTemplate: processedTemplate, - data: visualizerData.data - }; - - // trigger an event saying that item has been processed - this.triggers.item(null, coords, item, visualizerResult); - }.bind(this)); + // update allowed request mutation properties with the mutated context + // @note from this point forward, make sure this mutated + // request instance is used for upcoming commands. + ALLOWED_REQUEST_MUTATIONS.forEach(function (property) { + if (_.has(ctxTemplate, ['request', property])) { + item.request[property] = ctxTemplate.request[property]; } - else { - // trigger an event saying that item has been processed - // @todo - should this trigger receive error? - this.triggers.item(null, coords, item, null); + + // update property's parent reference + if (item.request[property] && typeof item.request[property].setParent === 'function') { + item.request[property].setParent(item.request); } + }); - // reset mutated request with original request instance - // @note request mutations are not persisted across iterations - item.request = originalRequest; + var postPreReqPayload, + postPreReqUrl, + proceedWithRequest = function () { + this.queue('request', { + item: item, + vaultSecrets: ctxTemplate.vaultSecrets, + globals: ctxTemplate.globals, + environment: ctxTemplate.environment, + collectionVariables: ctxTemplate.collectionVariables, + _variables: ctxTemplate._variables, + data: ctxTemplate.data, + coords: coords, + source: 'collection' + }).done(function (result, requestError) { + !result && (result = {}); + + var request = result.request, + response = result.response, + cookies = result.cookies; + + if ((stopOnError || stopOnFailure) && requestError) { + // @todo - should this trigger receive error? + this.triggers.item(null, coords, item); + + return callback && callback.call(this, requestError, { + request + }); + } + + // also the test object requires the updated request object + // (since auth helpers may modify it) + request && (ctxTemplate.request = request); + + // @note convert response instance to plain object. + // we want to avoid calling Response.toJSON() which triggers + // toJSON on Response.stream buffer. + // Because that increases the size of stringified object by 3 times. + // Also, that increases the total number of tokens + // (buffer.data) whereas Buffer.toString generates a single + // string that is easier to stringify and sent over the + // UVM bridge. + response && (ctxTemplate.response = getResponseJSON(response)); + + // set cookies for this transaction + cookies && (ctxTemplate.cookies = cookies); + + // the context template also has a test object to store assertions + ctxTemplate.tests = {}; // @todo remove + + this.queue('event', { + name: 'test', + item: item, + coords: coords, + context: ctxTemplate, + // No need to include vaultSecrets here as runtime + // takes care of tracking internally + trackContext: ['tests', 'globals', 'environment', 'collectionVariables'], + stopOnScriptError: stopOnError, + abortOnFailure: abortOnFailure, + stopOnFailure: stopOnFailure + }).done(function (testExecutions, testExecutionError) { + var visualizerData = extractVisualizerData(prereqExecutions, testExecutions), + visualizerResult; + + if (visualizerData) { + visualizer.processTemplate(visualizerData.template, + visualizerData.data, + visualizerData.options, + function (err, processedTemplate) { + visualizerResult = { + // bubble up the errors while processing + // template through visualizer result + error: err, + + // add processed template and data to visualizer result + processedTemplate: processedTemplate, + data: visualizerData.data + }; + + // trigger an event saying that item has been processed + this.triggers.item(null, coords, item, visualizerResult, + secretResolutionErrors && secretResolutionErrors.length ? + { secretResolutionErrors } : undefined); + }.bind(this)); + } + else { + // trigger an event saying that item has been processed + // @todo - should this trigger receive error? + this.triggers.item(null, coords, item, null, + secretResolutionErrors && secretResolutionErrors.length ? + { secretResolutionErrors } : undefined); + } + + // reset mutated request with original request instance + // @note request mutations are not persisted across iterations + item.request = originalRequest; + + callback && callback.call(this, ((stopOnError || stopOnFailure) && + testExecutionError) ? + testExecutionError : null, { + prerequest: prereqExecutions, + request: request, + response: response, + test: testExecutions + }); + }); + }); + }.bind(this); + + if (secretResolver) { + postPreReqPayload = { + item: item, + environment: ctxTemplate.environment, + globals: ctxTemplate.globals, + collectionVariables: ctxTemplate.collectionVariables, + vaultSecrets: ctxTemplate.vaultSecrets, + _variables: ctxTemplate._variables, + data: ctxTemplate.data + }; + postPreReqUrl = resolveUrlString(item, postPreReqPayload); + + if (postPreReqUrl !== baselineUrl) { + return resolveSecrets(postPreReqPayload, secretResolver, + function (err, secErrors, newForbiddenKeys) { + if (err) { + self.triggers.item(err, coords, item, null, + { hasSecretResolutionFailed: true }); + + callback && callback.call(self, err, { + request: null + }); + + return; + } + + if (secErrors && secErrors.length) { + secretResolutionErrors = (secretResolutionErrors || []) + .concat(secErrors); + } + + if (newForbiddenKeys && newForbiddenKeys.size > 0) { + if (!forbiddenSecretKeys) { + forbiddenSecretKeys = newForbiddenKeys; + } + else { + newForbiddenKeys.forEach(function (key) { + forbiddenSecretKeys.add(key); + }); + } + + ctxTemplate._forbiddenSecretKeys = forbiddenSecretKeys; + } + + proceedWithRequest(); + }); + } + } - callback && callback.call(this, ((stopOnError || stopOnFailure) && testExecutionError) ? - testExecutionError : null, { - prerequest: prereqExecutions, - request: request, - response: response, - test: testExecutions + proceedWithRequest(); + }); + }.bind(self), { + time: delay, + source: 'item', + cursor: coords + }, next); + }; + + // Resolve secrets before pre-request scripts so scripts can access resolved values via related pm APIs + if (secretResolver) { + resolveSecrets(secretResolutionPayload, secretResolver, + function (err, secErrors, forbiddenSecretKeys) { + if (err) { + // Fatal: stop immediately, request is never executed + self.triggers.item(err, coords, item, null, { hasSecretResolutionFailed: true }); + + callback && callback.call(self, err, { + request: null }); - }); + + return next(); + } + + executeItemFlow(secErrors, forbiddenSecretKeys); }); - }); - }.bind(this), { - time: delay, - source: 'item', - cursor: coords - }, next); + } + else { + executeItemFlow(); + } } } }; diff --git a/lib/runner/index.js b/lib/runner/index.js index 6fcf7c77e..6b19154e0 100644 --- a/lib/runner/index.js +++ b/lib/runner/index.js @@ -97,6 +97,17 @@ _.assign(Runner.prototype, { * @param {String} [options.entrypoint.lookupStrategy=idOrName] strategy to lookup the entrypoint [idOrName, path] * @param {Array} [options.entrypoint.path] path to lookup * @param {Object} [options.run] Run-specific options, such as options related to the host + * @param {Function} [options.secretResolver] - Function({ secrets, url }, callback) that resolves secrets. + * Receives: secrets (array of { scopeName, scope, variable, context }), url (request URL without query). + * Callback is (err, result). + * On fatal error: callback(err) — request execution stops. + * On success: callback(null, result) where result is Array<{ resolvedValue?: string, + * error?: Error, allowedInScript?: boolean }>; + * result[i] corresponds to secrets[i]. + * resolvedValue: resolved string (undefined if failed/skipped). + * error: Error when resolution failed for particular secret. + * allowedInScript: if true, value is exposed to scripts via pm.environment/pm.variables; + * if false/undefined, masked from scripts. Runtime applies values. * * @param {Function} callback - */ @@ -147,7 +158,8 @@ _.assign(Runner.prototype, { collectionVariables: collection.variables, localVariables: options.localVariables, certificates: options.certificates, - proxies: options.proxies + proxies: options.proxies, + secretResolver: options.secretResolver }, runOptions))); }); } diff --git a/lib/runner/resolve-secrets.js b/lib/runner/resolve-secrets.js new file mode 100644 index 000000000..87e324a17 --- /dev/null +++ b/lib/runner/resolve-secrets.js @@ -0,0 +1,130 @@ +var _ = require('lodash'), + { resolveUrlString } = require('./util'); + +/** + * Extract all variables with secret === true from a variable scope. + * + * @param {VariableScope|Object} scope - scope + * @returns {Array} - variables + */ +function extractSecretVariables (scope) { + if (!scope) { + return []; + } + + var values = _.get(scope, 'values'), + secretVariables = []; + + if (!values || typeof values.each !== 'function') { + return []; + } + + values.each(function (variable) { + if (variable && variable.secret === true) { + secretVariables.push(variable); + } + }); + + return secretVariables; +} + +/** + * Build context object for resolver function + * + * @param {Object} payload - Request payload + * @param {String} variableKey - The key of the variable being resolved + * @returns {Object} - Context object + */ +function buildResolverContext (payload, variableKey) { + return { + item: payload.item, + variableKey: variableKey + }; +} + +/** + * Scans variable scopes for secrets and invokes the secretResolver with them. + * Consumer returns array of { resolvedValue, error, allowedInScript }; runtime applies values. + * + * @param {Object} payload - Request payload + * @param {Object} payload.item - The request item + * @param {Object} payload.environment - Environment scope + * @param {Object} payload.globals - Globals scope + * @param {Object} payload.collectionVariables - Collection variables scope + * @param {Object} payload.vaultSecrets - Vault secrets scope + * @param {Object} payload._variables - Local variables scope + * @param {Object} payload.data - Iteration data + * @param {Function} secretResolver - Function({ secrets, urlString }, callback) that resolves secrets. + * Callback is (err, result) where result is Array<{ resolvedValue?: string, error?: Error, + * allowedInScript?: boolean }>. + * @param {Function} callback - callback(err, secretResolutionErrors, forbiddenSecretKeys) + */ +function resolveSecrets (payload, secretResolver, callback) { + if (!secretResolver || typeof secretResolver !== 'function') { + return callback(); + } + + var scopesToScan = [ + { name: 'environment', scope: payload.environment }, + { name: 'globals', scope: payload.globals }, + { name: 'collectionVariables', scope: payload.collectionVariables } + ], + secrets = [], + url = resolveUrlString(payload.item, payload), + urlString = typeof url.toString === 'function' ? url.toString() : String(url); + + scopesToScan.forEach(function (scopeInfo) { + var secretVars = extractSecretVariables(scopeInfo.scope); + + secretVars.forEach(function (variable) { + secrets.push({ + scopeName: scopeInfo.name, + scope: scopeInfo.scope, + variable: variable, + context: buildResolverContext(payload, variable.key) + }); + }); + }); + + if (secrets.length === 0) { + return callback(); + } + + secretResolver({ secrets, urlString }, function (err, result) { + if (err) { + return callback(err); + } + + if (result && Array.isArray(result)) { + result.forEach(function (entry, i) { + var hasResolvedValue = entry && entry.resolvedValue !== undefined && + typeof entry.resolvedValue === 'string'; + + if (i < secrets.length && hasResolvedValue) { + secrets[i].variable.set(entry.resolvedValue); + } + }); + } + + var secretResolutionErrors = (result && Array.isArray(result)) ? + result.filter(function (entry) { return entry && entry.error; }) + .map(function (entry) { return entry.error; }) : + [], + forbiddenSecretKeys = new Set(); + + if (result && Array.isArray(result)) { + result.forEach(function (entry, i) { + if (i < secrets.length && entry && entry.allowedInScript === false) { + forbiddenSecretKeys.add(secrets[i].scopeName + ':' + secrets[i].variable.key); + } + }); + } + + callback(null, secretResolutionErrors, forbiddenSecretKeys); + }); +} + +module.exports = { + resolveSecrets, + extractSecretVariables +}; diff --git a/lib/runner/util.js b/lib/runner/util.js index e195a1e5a..2b7130c60 100644 --- a/lib/runner/util.js +++ b/lib/runner/util.js @@ -121,6 +121,65 @@ prepareLookupHash = function (items) { return hash; }; +/** + * Resolve URL string from an item and payload by substituting all variables (including vault). + * Returns intermediate products needed by callers that do further resolution. + * + * @private + * @param {Item} item - The request item + * @param {Object} payload - Payload containing variable scopes + * @returns {{ urlString: string, variableDefinitions: Array, vaultVariables: * }} + */ +function resolveUrl (item, payload) { + if (!(item && item.request && item.request.url)) { + return { urlString: '', variableDefinitions: [], vaultVariables: null }; + } + + // @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 + _.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 + ], + vaultValues = _.get(payload, 'vaultSecrets.values'), + hasVaultSecrets = vaultValues ? vaultValues.count() > 0 : false, + urlObj = item.request.url, + urlString = urlObj.toString(), + unresolvedUrlString = urlString, + vaultVariables = null, + vaultUrl; + + if (hasVaultSecrets) { + // get the vault variables that match the unresolved URL string + vaultUrl = urlObj.protocol ? urlString : 'http://' + urlString; // force protocol + vaultVariables = payload.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 + vaultUrl = new sdk.Url(urlString).toString(true); + vaultVariables = payload.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]); + } + } + else if (urlString) { + urlString = sdk.Property.replaceSubstitutions(urlString, variableDefinitions); + } + + return { urlString, variableDefinitions, vaultVariables }; +} + /** * Utility functions that are required to be re-used throughout the runner * @@ -397,6 +456,23 @@ module.exports = { }; }, + /** + * Resolve the URL string from an item by substituting all variables (including vault). + * + * @param {Item} item - The request item + * @param {Object} payload - Payload containing variable scopes + * @param {VariableScope} payload._variables - + * @param {Object} payload.data - + * @param {VariableScope} payload.environment - + * @param {VariableScope} payload.collectionVariables - + * @param {VariableScope} payload.globals - + * @param {VariableScope} payload.vaultSecrets - + * @returns {String} - The fully resolved URL string + */ + resolveUrlString (item, payload) { + return resolveUrl(item, payload).urlString; + }, + /** * Resolve variables in item and auth in context. * @@ -414,55 +490,16 @@ module.exports = { resolveVariables (context, payload) { if (!(context.item && context.item.request)) { return; } - // @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 - _.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 - ], - vaultValues = _.get(payload, 'vaultSecrets.values'), - - hasVaultSecrets = vaultValues ? vaultValues.count() > 0 : false, - + var resolved = resolveUrl(context.item, payload), + urlString = resolved.urlString, + variableDefinitions = resolved.variableDefinitions, 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(), - unresolvedUrlString = urlString, - - vaultVariables, - vaultUrl, item, auth; - if (hasVaultSecrets) { - // get the vault variables that match the unresolved URL string - vaultUrl = urlObj.protocol ? urlString : `http://${urlString}`; // force protocol - vaultVariables = payload.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 - vaultUrl = new sdk.Url(urlString).toString(true); - vaultVariables = payload.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); - } - else if (urlString) { - urlString = sdk.Property.replaceSubstitutions(urlString, variableDefinitions); + // add vault variables to the list of variable definitions + if (resolved.vaultVariables) { + variableDefinitions.push(resolved.vaultVariables); } // @todo - no need to sync variables when SDK starts supporting resolution from scope directly @@ -474,6 +511,7 @@ module.exports = { item.setParent(itemParent); // re-parse and update the URL from the resolved string + // @note URL string is used to resolve nested variables as URL parser doesn't support them well. urlString && (item.request.url = new sdk.Url(urlString)); auth = context.auth; diff --git a/package-lock.json b/package-lock.json index 7a2f5f6ef..ef31e3516 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,9 +21,9 @@ "node-forge": "1.3.3", "node-oauth1": "1.3.0", "performance-now": "2.1.0", - "postman-collection": "5.2.0", + "postman-collection": "5.3.0", "postman-request": "2.88.1-postman.48", - "postman-sandbox": "6.4.0", + "postman-sandbox": "6.6.1", "postman-url-encoder": "3.0.8", "serialised-error": "1.1.3", "strip-json-comments": "3.1.1", @@ -7839,16 +7839,17 @@ } }, "node_modules/postman-collection": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/postman-collection/-/postman-collection-5.2.0.tgz", - "integrity": "sha512-ktjlchtpoCw+FZRg+WwnGWH1w9oQDNUBLSRh+9ETPqFAz3SupqHqRuMh74xjQ+PvTWY/WH2JR4ZW+1sH58Ul1g==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/postman-collection/-/postman-collection-5.3.0.tgz", + "integrity": "sha512-PMa5vRheqDFfS1bkRg8WBidWxunRA80sT5YNLP27YC5+ycyfiLMCwPnqQd1zfvxkGk04Pr9UronWmmgsbpsVyQ==", + "license": "Apache-2.0", "dependencies": { "@faker-js/faker": "5.5.3", "file-type": "3.9.0", "http-reasons": "0.1.0", "iconv-lite": "0.6.3", "liquid-json": "0.3.1", - "lodash": "4.17.21", + "lodash": "4.17.23", "mime": "3.0.0", "mime-format": "2.0.2", "postman-url-encoder": "3.0.8", @@ -7859,12 +7860,6 @@ "node": ">=18" } }, - "node_modules/postman-collection/node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" - }, "node_modules/postman-collection/node_modules/mime": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", @@ -7908,13 +7903,13 @@ } }, "node_modules/postman-sandbox": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/postman-sandbox/-/postman-sandbox-6.4.0.tgz", - "integrity": "sha512-sOGlTsrLbTF+Clt2G6cKyduupMvTk6lZbnMFxcuy+18pOPnHji8Zd4GeZayifItvnrAGEWpFiK9pDyJCHHgYRw==", + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/postman-sandbox/-/postman-sandbox-6.6.1.tgz", + "integrity": "sha512-w5MNCiEi0ramE5PFDqi1JeYEUjED2ZDf9FE9XzX5AHga+uFOZAWnsQRVcogZU8ZP4Jy/0M3VtwVMr+FcVzDdqA==", "license": "Apache-2.0", "dependencies": { - "lodash": "4.17.21", - "postman-collection": "5.2.0", + "lodash": "4.17.23", + "postman-collection": "5.3.0", "teleport-javascript": "1.0.0", "uvm": "4.0.1" }, @@ -7922,12 +7917,6 @@ "node": ">=18" } }, - "node_modules/postman-sandbox/node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" - }, "node_modules/postman-url-encoder": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/postman-url-encoder/-/postman-url-encoder-3.0.8.tgz", @@ -16326,16 +16315,16 @@ } }, "postman-collection": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/postman-collection/-/postman-collection-5.2.0.tgz", - "integrity": "sha512-ktjlchtpoCw+FZRg+WwnGWH1w9oQDNUBLSRh+9ETPqFAz3SupqHqRuMh74xjQ+PvTWY/WH2JR4ZW+1sH58Ul1g==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/postman-collection/-/postman-collection-5.3.0.tgz", + "integrity": "sha512-PMa5vRheqDFfS1bkRg8WBidWxunRA80sT5YNLP27YC5+ycyfiLMCwPnqQd1zfvxkGk04Pr9UronWmmgsbpsVyQ==", "requires": { "@faker-js/faker": "5.5.3", "file-type": "3.9.0", "http-reasons": "0.1.0", "iconv-lite": "0.6.3", "liquid-json": "0.3.1", - "lodash": "4.17.21", + "lodash": "4.17.23", "mime": "3.0.0", "mime-format": "2.0.2", "postman-url-encoder": "3.0.8", @@ -16343,11 +16332,6 @@ "uuid": "8.3.2" }, "dependencies": { - "lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, "mime": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", @@ -16383,21 +16367,14 @@ } }, "postman-sandbox": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/postman-sandbox/-/postman-sandbox-6.4.0.tgz", - "integrity": "sha512-sOGlTsrLbTF+Clt2G6cKyduupMvTk6lZbnMFxcuy+18pOPnHji8Zd4GeZayifItvnrAGEWpFiK9pDyJCHHgYRw==", + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/postman-sandbox/-/postman-sandbox-6.6.1.tgz", + "integrity": "sha512-w5MNCiEi0ramE5PFDqi1JeYEUjED2ZDf9FE9XzX5AHga+uFOZAWnsQRVcogZU8ZP4Jy/0M3VtwVMr+FcVzDdqA==", "requires": { - "lodash": "4.17.21", - "postman-collection": "5.2.0", + "lodash": "4.17.23", + "postman-collection": "5.3.0", "teleport-javascript": "1.0.0", "uvm": "4.0.1" - }, - "dependencies": { - "lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - } } }, "postman-url-encoder": { diff --git a/package.json b/package.json index 5f77343d0..904c9affa 100644 --- a/package.json +++ b/package.json @@ -54,9 +54,9 @@ "node-forge": "1.3.3", "node-oauth1": "1.3.0", "performance-now": "2.1.0", - "postman-collection": "5.2.0", + "postman-collection": "5.3.0", "postman-request": "2.88.1-postman.48", - "postman-sandbox": "6.4.0", + "postman-sandbox": "6.6.1", "postman-url-encoder": "3.0.8", "serialised-error": "1.1.3", "strip-json-comments": "3.1.1", diff --git a/test/integration/inherited-entities/events.test.js b/test/integration/inherited-entities/events.test.js index a462d84dd..ef94034e3 100644 --- a/test/integration/inherited-entities/events.test.js +++ b/test/integration/inherited-entities/events.test.js @@ -427,4 +427,336 @@ describe('Events', function () { }); }); }); + + describe('secret resolution with events', function () { + describe('secret allowed in script should be accessible in collection-level prerequest', function () { + before(function (done) { + var runOptions = { + collection: { + event: [{ + listen: 'prerequest', + script: { + exec: [ + 'var v = pm.environment.get("apiKey");', + 'pm.environment.set("scriptSawSecret", v || "undefined");' + ] + } + }], + item: { + request: global.servers.http + '?apiKey={{apiKey}}' + } + }, + environment: { + values: [{ + key: 'apiKey', + value: '', + secret: true, + source: { provider: 'postman', postman: { type: 'local', secretId: 'allowed-secret' } } + }] + }, + secretResolver: function ({ secrets }, callback) { + callback(null, secrets.map(function () { + return { resolvedValue: 'allowed-secret-value', allowedInScript: true }; + })); + } + }; + + this.run(runOptions, 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 expose allowed secret to collection-level prerequest script', function () { + var scriptResult = testRun.script.getCall(0).args[2]; + + expect(testRun.script.called).to.be.true; + expect(scriptResult.environment.get('scriptSawSecret')).to.equal('allowed-secret-value'); + }); + + it('should use resolved value in request URL', function () { + var request = testRun.request.getCall(0).args[3]; + + expect(request.url.toString()).to.include('apiKey=allowed-secret-value'); + }); + }); + + describe('secret allowed in script should be accessible in collection-level test script', function () { + before(function (done) { + var runOptions = { + collection: { + event: [ + { + listen: 'prerequest', + script: { exec: 'console.log("prerequest");' } + }, + { + listen: 'test', + script: { + exec: [ + 'var v = pm.environment.get("apiKey");', + 'pm.environment.set("testSawSecret", v || "undefined");' + ] + } + } + ], + item: { + request: global.servers.http + '?apiKey={{apiKey}}' + } + }, + environment: { + values: [{ + key: 'apiKey', + value: '', + secret: true, + source: { provider: 'postman', postman: { type: 'local', secretId: 'allowed-secret' } } + }] + }, + secretResolver: function ({ secrets }, callback) { + callback(null, secrets.map(function () { + return { resolvedValue: 'allowed-secret-value', allowedInScript: true }; + })); + } + }; + + this.run(runOptions, 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 expose allowed secret to collection-level test script', function () { + var testResult = testRun.script.getCall(1).args[2]; + + expect(testRun.script.callCount).to.equal(2); + expect(testResult.environment.get('testSawSecret')).to.equal('allowed-secret-value'); + }); + }); + + describe('secret allowed in script should be accessible in collection and request-level test scripts', + function () { + before(function (done) { + var runOptions = { + collection: { + event: [ + { + listen: 'prerequest', + script: { exec: 'console.log("coll prerequest");' } + }, + { + listen: 'test', + script: { + exec: [ + 'var v = pm.environment.get("apiKey");', + 'pm.environment.set("collTestSawSecret", v || "undefined");' + ] + } + } + ], + item: { + event: [{ + listen: 'test', + script: { + exec: [ + 'var v = pm.environment.get("apiKey");', + 'pm.environment.set("reqTestSawSecret", v || "undefined");' + ] + } + }], + request: global.servers.http + '?apiKey={{apiKey}}' + } + }, + environment: { + values: [{ + key: 'apiKey', + value: '', + secret: true, + source: { provider: 'postman', postman: { type: 'local', secretId: 'allowed-secret' } } + }] + }, + secretResolver: function ({ secrets }, callback) { + callback(null, secrets.map(function () { + return { resolvedValue: 'allowed-secret-value', allowedInScript: true }; + })); + } + }; + + this.run(runOptions, 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 expose allowed secret to collection-level test script', function () { + var collTestResult = testRun.script.getCall(1).args[2]; + + expect(collTestResult.environment.get('collTestSawSecret')).to.equal('allowed-secret-value'); + }); + + it('should expose allowed secret to request-level test script', function () { + var reqTestResult = testRun.script.getCall(2).args[2]; + + expect(reqTestResult.environment.get('reqTestSawSecret')).to.equal('allowed-secret-value'); + }); + }); + + describe('secret not allowed in script should be masked in collection-level prerequest and test', function () { + before(function (done) { + var runOptions = { + collection: { + event: [ + { + listen: 'prerequest', + script: { + exec: [ + 'var v = pm.environment.get("apiKey");', + 'pm.environment.set("preReqSawSecret", v === undefined ? "masked" : "leaked");' + ] + } + }, + { + listen: 'test', + script: { + exec: [ + 'var v = pm.environment.get("apiKey");', + 'pm.environment.set("testSawSecret", v === undefined ? "masked" : "leaked");' + ] + } + } + ], + item: { + request: global.servers.http + '?apiKey={{apiKey}}' + } + }, + environment: { + values: [{ + key: 'apiKey', + value: '', + secret: true, + source: { provider: 'postman', postman: { type: 'local', secretId: 'forbidden-secret' } } + }] + }, + secretResolver: function ({ secrets }, callback) { + callback(null, secrets.map(function () { + return { resolvedValue: 'forbidden-secret-value', allowedInScript: false }; + })); + } + }; + + this.run(runOptions, 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 mask secrets not allowed in script from collection-level prerequest script', function () { + var prereqResult = testRun.script.getCall(0).args[2]; + + expect(prereqResult.environment.get('preReqSawSecret')).to.equal('masked'); + }); + + it('should mask secrets not allowed in script from collection-level test script', function () { + var testResult = testRun.script.getCall(1).args[2]; + + expect(testResult.environment.get('testSawSecret')).to.equal('masked'); + }); + + it('should still use resolved value for request URL substitution', function () { + var request = testRun.request.getCall(0).args[3]; + + expect(request.url.toString()).to.include('apiKey=forbidden-secret-value'); + }); + }); + + describe('secrets not allowed in script should be masked in request-level event (inherited from collection)', + function () { + before(function (done) { + var runOptions = { + collection: { + event: [{ + listen: 'prerequest', + script: { + exec: [ + 'var v = pm.environment.get("apiKey");', + 'pm.environment.set("collPreReqSawSecret",' + + ' v === undefined ? "masked" : "leaked");' + ] + } + }], + item: { + event: [{ + listen: 'prerequest', + script: { + exec: [ + 'var v = pm.environment.get("apiKey");', + 'var m = v === undefined ? "masked" : "leaked";', + 'pm.environment.set("reqPreReqSawSecret", m);' + ] + } + }], + request: global.servers.http + '?apiKey={{apiKey}}' + } + }, + environment: { + values: [{ + key: 'apiKey', + value: '', + secret: true, + source: { + provider: 'postman', + postman: { type: 'local', secretId: 'forbidden-secret' } + } + }] + }, + secretResolver: function ({ secrets }, callback) { + callback(null, secrets.map(function () { + return { resolvedValue: 'forbidden-secret-value', allowedInScript: false }; + })); + } + }; + + this.run(runOptions, 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 mask secrets not allowed in script in collection-level prerequest', function () { + var collResult = testRun.script.getCall(0).args[2]; + + expect(collResult.environment.get('collPreReqSawSecret')).to.equal('masked'); + }); + + it('should mask secrets not allowed in script in request-level prerequest', function () { + var reqResult = testRun.script.getCall(1).args[2]; + + expect(reqResult.environment.get('reqPreReqSawSecret')).to.equal('masked'); + }); + }); + }); }); diff --git a/test/integration/runner-spec/run-collection-request.test.js b/test/integration/runner-spec/run-collection-request.test.js index 64ae4d5b3..a532232a0 100644 --- a/test/integration/runner-spec/run-collection-request.test.js +++ b/test/integration/runner-spec/run-collection-request.test.js @@ -773,7 +773,7 @@ describe('pm.execution.runRequest handling', function () { expect(result.vaultSecrets.get('secretKey')).to.eql('secretValue'); expect(result.vaultSecrets.mutations).to.be.ok; expect(result.vaultSecrets.mutations.compacted) - .to.deep.include({ secretKey: ['secretKey', 'secretValue'] }); + .to.deep.include({ secretKey: ['secretKey', 'secretValue', {}] }); } }, assertion (_cursor, assertions) { diff --git a/test/integration/sanity/secret-resolution.test.js b/test/integration/sanity/secret-resolution.test.js new file mode 100644 index 000000000..ac992f8f0 --- /dev/null +++ b/test/integration/sanity/secret-resolution.test.js @@ -0,0 +1,939 @@ +var expect = require('chai').expect, + sinon = require('sinon'); + +describe('secret resolution', function () { + describe('basic secret resolution with source', function () { + var testrun; + + before(function (done) { + this.run({ + collection: { + item: [{ + request: { + // Use base URL - local http server doesn't have /get endpoint + url: global.servers.http + '?apiKey={{apiKey}}&normalVar={{normalVar}}' + } + }] + }, + environment: { + values: [ + { + key: 'apiKey', + value: 'placeholder-will-be-replaced', + secret: true, + source: { + provider: 'postman', + postman: { type: 'local', secretId: 'my-api-key-secret' } + } + }, + { + key: 'normalVar', + value: 'normal-value' + } + ] + }, + secretResolver: function ({ secrets }, callback) { + var result = secrets.map(function (s) { + return { + resolvedValue: s.variable.key === 'apiKey' ? 'resolved-secret-value-123' : undefined + }; + }); + + callback(null, result); + } + }, function (err, results) { + testrun = results; + done(err); + }); + }); + + it('should complete the run', function () { + expect(testrun).to.be.ok; + sinon.assert.calledOnce(testrun.start); + sinon.assert.calledOnce(testrun.done); + sinon.assert.calledWith(testrun.done.getCall(0), null); + }); + + it('should resolve secret variable using secretResolver', function () { + var request = testrun.request.getCall(0).args[3]; + + expect(request.url.toString()).to.include('apiKey=resolved-secret-value-123'); + }); + + it('should also resolve normal variables', function () { + var request = testrun.request.getCall(0).args[3]; + + expect(request.url.toString()).to.include('normalVar=normal-value'); + }); + }); + + describe('nested secret resolution is not supported', function () { + var testrun; + + before(function (done) { + this.run({ + collection: { + item: [{ + request: { + // Use base URL - local http server doesn't have /get endpoint + url: global.servers.http + '?apiKey={{{{env}}_apiKey}}&normalVar={{normalVar}}' + } + }] + }, + environment: { + values: [ + { + key: 'prod_apiKey', + value: 'placeholder-will-be-replaced', + secret: true, + source: { + provider: 'postman', + postman: { type: 'local', secretId: 'my-api-key-secret' } + } + }, + { + key: 'env', + value: 'prod' + }, + { + key: 'normalVar', + value: 'normal-value' + } + ] + }, + secretResolver: function ({ secrets }, callback) { + // Nested vars not in secrets or keep placeholder + var result = secrets.map(function () { return { resolvedValue: undefined }; }); + + callback(null, result); + } + }, function (err, results) { + testrun = results; + done(err); + }); + }); + + it('should complete the run', function () { + expect(testrun).to.be.ok; + sinon.assert.calledOnce(testrun.start); + sinon.assert.calledOnce(testrun.done); + sinon.assert.calledWith(testrun.done.getCall(0), null); + }); + + it('should NOT resolve nested secret variable (placeholder remains)', function () { + var request = testrun.request.getCall(0).args[3]; + + // Nested variables like {{{{env}}_apiKey}} are not supported + // The placeholder value should remain + expect(request.url.toString()).to.include('apiKey=placeholder-will-be-replaced'); + }); + + it('should still resolve normal variables', function () { + var request = testrun.request.getCall(0).args[3]; + + expect(request.url.toString()).to.include('normalVar=normal-value'); + }); + }); + + describe('secret resolution with Promise-based resolver', function () { + var testrun; + + before(function (done) { + this.run({ + collection: { + item: [{ + request: { + url: global.servers.http + '?secret={{mySecret}}' + } + }] + }, + environment: { + values: [ + { + key: 'mySecret', + value: '', + secret: true, + source: { + provider: 'hashicorp', + hashicorp: { + engine: 'secret', + path: '/secret/data/myapp', + key: 'value' + } + } + } + ] + }, + secretResolver: function ({ secrets }, callback) { + if (secrets.length === 0) { + return callback(null, []); + } + + Promise.resolve('promise-resolved-secret') + .then(function (v) { + callback(null, [{ resolvedValue: v }]); + }) + .catch(callback); + } + }, function (err, results) { + testrun = results; + done(err); + }); + }); + + it('should complete the run', function () { + expect(testrun).to.be.ok; + sinon.assert.calledOnce(testrun.done); + }); + + it('should resolve secret using Promise-based resolver', function () { + var request = testrun.request.getCall(0).args[3]; + + expect(request.url.toString()).to.include('secret=promise-resolved-secret'); + }); + }); + + describe('secret resolution failure handling', function () { + var testrun; + + before(function (done) { + this.run({ + collection: { + item: [{ + request: { + url: global.servers.http + '?secret={{failingSecret}}' + } + }] + }, + environment: { + values: [ + { + key: 'failingSecret', + value: 'fallback-value', + secret: true, + source: { + provider: 'postman', + postman: { type: 'local', secretId: 'will-fail' } + } + } + ] + }, + secretResolver: function (input, callback) { + callback(new Error('Failed to fetch secret')); + } + }, function (err, results) { + testrun = results; + done(err); + }); + }); + + it('should complete the run (failure is item-level, not run-level)', function () { + expect(testrun).to.be.ok; + sinon.assert.calledOnce(testrun.done); + sinon.assert.calledWith(testrun.done.getCall(0), null); + }); + + it('should emit error via item trigger on resolution failure', function () { + sinon.assert.calledOnce(testrun.item); + + var err = testrun.item.getCall(0).args[0]; + + expect(err).to.be.an('error'); + expect(err.message).to.include('Failed to fetch secret'); + }); + + it('should include hasSecretResolutionFailed flag in item trigger', function () { + var options = testrun.item.getCall(0).args[4]; + + expect(options).to.have.property('hasSecretResolutionFailed', true); + }); + + it('should NOT make the HTTP request when secret resolution fails', function () { + sinon.assert.notCalled(testrun.request); + }); + }); + + describe('multiple secrets resolution', function () { + var testrun, + resolverCallCount = 0; + + before(function (done) { + resolverCallCount = 0; + + this.run({ + collection: { + item: [{ + request: { + url: global.servers.http + '?key1={{secret1}}&key2={{secret2}}&key3={{secret3}}' + } + }] + }, + environment: { + values: [ + { + key: 'secret1', + value: '', + secret: true, + source: { provider: 'postman', postman: { type: 'local', secretId: '1' } } + }, + { + key: 'secret2', + value: '', + secret: true, + source: { provider: 'postman', postman: { type: 'local', secretId: '2' } } + }, + { + key: 'secret3', + value: '', + secret: true, + source: { provider: 'postman', postman: { type: 'local', secretId: '3' } } + } + ] + }, + secretResolver: function ({ secrets }, callback) { + var result = secrets.map(function (item) { + resolverCallCount++; + + return { + resolvedValue: 'secret-value-' + item.variable.source.postman.secretId + }; + }); + + callback(null, result); + } + }, function (err, results) { + testrun = results; + done(err); + }); + }); + + it('should complete the run', function () { + expect(testrun).to.be.ok; + sinon.assert.calledOnce(testrun.done); + }); + + it('should resolve all secrets', function () { + var request = testrun.request.getCall(0).args[3]; + + expect(request.url.toString()).to.include('key1=secret-value-1'); + expect(request.url.toString()).to.include('key2=secret-value-2'); + expect(request.url.toString()).to.include('key3=secret-value-3'); + }); + + it('should call resolver for each secret', function () { + expect(resolverCallCount).to.equal(3); + }); + }); + + describe('resolves all secrets including those only referenced in scripts', function () { + var testrun, + resolverSpy; + + before(function (done) { + resolverSpy = sinon.spy(function (secret) { + return new Promise(function (resolve) { + setTimeout(function () { + resolve('resolved-' + secret.secretId); + }, 10); + }); + }); + + this.run({ + collection: { + item: [{ + request: { + url: global.servers.http + '?key1={{secret1}}' + } + }] + }, + environment: { + values: [ + { + key: 'secret1', + value: '', + secret: true, + source: { provider: 'postman', postman: { type: 'local', secretId: '1' } } + }, + { + key: 'secret2', + value: '', + secret: true, + source: { provider: 'postman', postman: { type: 'local', secretId: '2' } } + }, + { + key: 'secret3', + value: '', + secret: true, + source: { provider: 'postman', postman: { type: 'local', secretId: '3' } } + } + ] + }, + secretResolver: function ({ secrets }, callback) { + if (secrets.length === 0) { + return callback(null, []); + } + + Promise.all(secrets.map(function (s) { + return resolverSpy(s.variable.source.postman); + })) + .then(function (values) { + callback(null, values.map(function (v) { return { resolvedValue: v }; })); + }) + .catch(callback); + } + }, function (err, results) { + testrun = results; + done(err); + }); + }); + + it('should complete the run', function () { + expect(testrun).to.be.ok; + sinon.assert.calledOnce(testrun.done); + }); + + it('should call resolver for all secrets', function () { + // All secrets are resolved (including those only referenced in scripts) + sinon.assert.calledThrice(resolverSpy); + }); + + it('should resolve all secrets with correct sources', function () { + expect(resolverSpy.getCall(0).args[0].secretId).to.equal('1'); + expect(resolverSpy.getCall(1).args[0].secretId).to.equal('2'); + expect(resolverSpy.getCall(2).args[0].secretId).to.equal('3'); + }); + + it('should resolve the used secret correctly', function () { + var request = testrun.request.getCall(0).args[3]; + + expect(request.url.toString()).to.include('key1=resolved-1'); + }); + }); + + describe('no secretResolver provided', function () { + var testrun; + + before(function (done) { + this.run({ + collection: { + item: [{ + request: { + url: global.servers.http + '?secret={{secretVar}}' + } + }] + }, + environment: { + values: [ + { + key: 'secretVar', + value: 'original-value', + secret: true, + source: { + provider: 'postman', + postman: { type: 'local', secretId: 'some-secret' } + } + } + ] + } + // Note: No secretResolver provided + }, function (err, results) { + testrun = results; + done(err); + }); + }); + + it('should complete the run', function () { + expect(testrun).to.be.ok; + sinon.assert.calledOnce(testrun.done); + }); + + it('should use original value when no resolver is provided', function () { + var request = testrun.request.getCall(0).args[3]; + + expect(request.url.toString()).to.include('secret=original-value'); + }); + }); + + describe('secrets in request body and headers', function () { + var testrun; + + before(function (done) { + this.run({ + collection: { + item: [{ + request: { + url: global.servers.http, + method: 'POST', + header: [ + { + key: 'Authorization', + value: 'Bearer {{authToken}}' + } + ], + body: { + mode: 'raw', + raw: '{"apiKey": "{{apiKey}}"}' + } + } + }] + }, + environment: { + values: [ + { + key: 'authToken', + value: '', + secret: true, + source: { provider: 'postman', postman: { type: 'local', secretId: 'auth-token-secret' } } + }, + { + key: 'apiKey', + value: '', + secret: true, + source: { provider: 'postman', postman: { type: 'local', secretId: 'api-key-secret' } } + } + ] + }, + secretResolver: function ({ secrets }, callback) { + var result = secrets.map(function (item) { + var secretId = item.variable.source.postman.secretId; + + return { + resolvedValue: secretId === 'auth-token-secret' ? 'resolved-auth-token-xyz' : + secretId === 'api-key-secret' ? 'resolved-api-key-abc' : undefined + }; + }); + + callback(null, result); + } + }, function (err, results) { + testrun = results; + done(err); + }); + }); + + it('should complete the run', function () { + expect(testrun).to.be.ok; + sinon.assert.calledOnce(testrun.done); + }); + + it('should resolve secret in header', function () { + var request = testrun.request.getCall(0).args[3]; + + expect(request.headers.get('Authorization')).to.equal('Bearer resolved-auth-token-xyz'); + }); + + it('should resolve secret in body', function () { + var request = testrun.request.getCall(0).args[3]; + + expect(request.body.raw).to.include('resolved-api-key-abc'); + }); + }); + + describe('timeout handling', function () { + var testrun; + + before(function (done) { + this.run({ + collection: { + item: [{ + request: { + url: global.servers.http + '?secret={{slowSecret}}' + } + }] + }, + environment: { + values: [ + { + key: 'slowSecret', + value: 'fallback-value', + secret: true, + source: { + provider: 'postman', + postman: { type: 'local', secretId: 'slow-secret' } + } + } + ] + }, + secretResolver: function (input, callback) { + var timeout = 50, + slowResolve = new Promise(function (resolve) { + setTimeout(function () { + resolve('should-not-resolve'); + }, 200); + }), + timeoutReject = new Promise(function (_, reject) { + setTimeout(function () { + var err = new Error('Secret resolution timed out after ' + timeout + 'ms'); + + err.code = 'SECRET_RESOLUTION_TIMEOUT'; + reject(err); + }, timeout); + }); + + Promise.race([slowResolve, timeoutReject]) + .then(function () { + callback(null, []); + }) + .catch(callback); + } + }, function (err, results) { + testrun = results; + done(err); + }); + }); + + it('should complete the run (timeout is item-level failure)', function () { + expect(testrun).to.be.ok; + sinon.assert.calledOnce(testrun.done); + sinon.assert.calledWith(testrun.done.getCall(0), null); + }); + + it('should emit timeout error via item trigger', function () { + sinon.assert.calledOnce(testrun.item); + + var err = testrun.item.getCall(0).args[0]; + + expect(err).to.be.an('error'); + expect(err.message).to.include('timed out'); + expect(err.code).to.equal('SECRET_RESOLUTION_TIMEOUT'); + }); + + it('should include hasSecretResolutionFailed flag on timeout', function () { + var options = testrun.item.getCall(0).args[4]; + + expect(options).to.have.property('hasSecretResolutionFailed', true); + }); + + it('should NOT make the HTTP request when timeout occurs', function () { + sinon.assert.notCalled(testrun.request); + }); + }); + + describe('retry on failure', function () { + var testrun, + attemptCount = 0; + + before(function (done) { + attemptCount = 0; + + this.run({ + collection: { + item: [{ + request: { + url: global.servers.http + '?secret={{retrySecret}}' + } + }] + }, + environment: { + values: [ + { + key: 'retrySecret', + value: '', + secret: true, + source: { + provider: 'postman', + postman: { type: 'local', secretId: 'retry-secret' } + } + } + ] + }, + secretResolver: function (input, callback) { + var maxAttempts = 3; + + function attempt () { + attemptCount++; + + if (attemptCount < maxAttempts) { + return Promise.reject(new Error('Temporary failure')); + } + + return Promise.resolve('success-after-retry'); + } + + function retry (attemptsLeft) { + return attempt() + .then(function (value) { + callback(null, [{ resolvedValue: value }]); + }) + .catch(function (err) { + if (attemptsLeft > 1) { + return retry(attemptsLeft - 1); + } + callback(err); + }); + } + + retry(maxAttempts); + } + }, function (err, results) { + testrun = results; + done(err); + }); + }); + + it('should complete the run', function () { + expect(testrun).to.be.ok; + sinon.assert.calledOnce(testrun.done); + }); + + it('should retry the specified number of times', function () { + // Initial attempt + 2 retries = 3 total attempts + expect(attemptCount).to.equal(3); + }); + + it('should resolve after successful retry', function () { + var request = testrun.request.getCall(0).args[3]; + + expect(request.url.toString()).to.include('secret=success-after-retry'); + }); + }); + + describe('no matching resolver for source type', function () { + var testrun; + + before(function (done) { + this.run({ + collection: { + item: [{ + request: { + url: global.servers.http + '?secret={{unmatchedSecret}}' + } + }] + }, + environment: { + values: [ + { + key: 'unmatchedSecret', + value: 'placeholder-value', + secret: true, + source: { + provider: 'azure', + azure: { secretId: 'some-secret' } + } + } + ] + }, + secretResolver: function ({ secrets }, callback) { + // No resolver for azure; do not mutate + var result = secrets.map(function () { return { resolvedValue: undefined }; }); + + callback(null, result); + } + }, function (err, results) { + testrun = results; + done(err); + }); + }); + + it('should complete the run', function () { + expect(testrun).to.be.ok; + sinon.assert.calledOnce(testrun.done); + }); + + it('should keep placeholder value when no matching resolver', function () { + var request = testrun.request.getCall(0).args[3]; + + expect(request.url.toString()).to.include('secret=placeholder-value'); + }); + }); + + describe('secretResolver receives resolved URL string', function () { + var testrun, + receivedUrlString; + + before(function (done) { + this.run({ + collection: { + item: [{ + request: { + url: global.servers.http + + '?tag={{tagName}}&apiKey={{apiKey}}' + } + }] + }, + environment: { + values: [ + { + key: 'tagName', + value: 'resolved-tag' + }, + { + key: 'apiKey', + value: '', + secret: true, + source: { + provider: 'postman', + postman: { + type: 'local', + secretId: 'key-1' + } + } + } + ] + }, + secretResolver: function ({ secrets, urlString }, callback) { + receivedUrlString = urlString; + callback(null, secrets.map(function () { + return { resolvedValue: 'resolved-key' }; + })); + } + }, function (err, results) { + testrun = results; + done(err); + }); + }); + + it('should complete the run', function () { + expect(testrun).to.be.ok; + sinon.assert.calledOnce(testrun.done); + sinon.assert.calledWith(testrun.done.getCall(0), null); + }); + + it('should pass resolved URL (not template) to secretResolver', function () { + expect(receivedUrlString).to.be.a('string'); + expect(receivedUrlString).to.not.include('{{tagName}}'); + expect(receivedUrlString).to.include('resolved-tag'); + }); + }); + + describe('2nd secretResolver call after prerequest script changes URL', function () { + var testrun, + resolverCalls = []; + + before(function (done) { + resolverCalls = []; + + this.run({ + collection: { + event: [{ + listen: 'prerequest', + script: { + exec: [ + 'var url = pm.request.url.toString();', + 'url = url.replace("original-host.com",' + + ' "changed-host.com");', + 'pm.request.url = url;' + ] + } + }], + item: [{ + request: { + url: 'https://original-host.com/api' + + '?key={{apiKey}}' + } + }] + }, + environment: { + values: [{ + key: 'apiKey', + value: '', + secret: true, + source: { + provider: 'postman', + postman: { + type: 'local', + secretId: 'key-1' + } + } + }] + }, + secretResolver: function ({ secrets, urlString }, callback) { + resolverCalls.push({ urlString }); + callback(null, secrets.map(function () { + return { resolvedValue: 'resolved-key' }; + })); + } + }, function (err, results) { + testrun = results; + done(err); + }); + }); + + it('should complete the run', function () { + expect(testrun).to.be.ok; + sinon.assert.calledOnce(testrun.done); + sinon.assert.calledWith(testrun.done.getCall(0), null); + }); + + it('should call secretResolver twice when URL changes', function () { + expect(resolverCalls).to.have.lengthOf(2); + }); + + it('should pass original URL in 1st call', function () { + expect(resolverCalls[0].urlString) + .to.include('original-host.com'); + }); + + it('should pass changed URL in 2nd call', function () { + expect(resolverCalls[1].urlString) + .to.include('changed-host.com'); + }); + }); + + describe('2nd secretResolver call is skipped when URL does not change', function () { + var testrun, + resolverCallCount = 0; + + before(function (done) { + resolverCallCount = 0; + + this.run({ + collection: { + event: [{ + listen: 'prerequest', + script: { + exec: [ + 'pm.environment.set("marker", "done");' + ] + } + }], + item: [{ + request: { + url: global.servers.http + + '?key={{apiKey}}' + } + }] + }, + environment: { + values: [{ + key: 'apiKey', + value: '', + secret: true, + source: { + provider: 'postman', + postman: { + type: 'local', + secretId: 'key-1' + } + } + }] + }, + secretResolver: function ({ secrets }, callback) { + resolverCallCount++; + callback(null, secrets.map(function () { + return { resolvedValue: 'resolved-key' }; + })); + } + }, function (err, results) { + testrun = results; + done(err); + }); + }); + + it('should complete the run', function () { + expect(testrun).to.be.ok; + sinon.assert.calledOnce(testrun.done); + sinon.assert.calledWith(testrun.done.getCall(0), null); + }); + + it('should call secretResolver only once when URL unchanged', function () { + expect(resolverCallCount).to.equal(1); + }); + + it('should still resolve the secret correctly', function () { + var request = testrun.request.getCall(0).args[3]; + + expect(request.url.toString()).to.include('key=resolved-key'); + }); + }); +});