UNPKG

@luminati-io/mountebank

Version:

Over the wire test doubles

554 lines (495 loc) 18.5 kB
'use strict'; /** * The functionality behind the behaviors field in the API, supporting post-processing responses * @module */ const os = require('os'), fsExtra = require('fs-extra'), childProcess = require('child_process'), safeRegex = require('safe-regex'), csvParse = require('csv-parse'), buffer = require('buffer'), prometheus = require('prom-client'), xPath = require('./xpath'), jsonPath = require('./jsonpath'), helpers = require('../util/helpers.js'), exceptions = require('../util/errors.js'), behaviorsValidator = require('./behaviorsValidator.js'), compatibility = require('./compatibility.js'); const metrics = { behaviorDuration: new prometheus.Histogram({ name: 'mb_behavior_duration_seconds', help: 'Time it takes to run all the behaviors', buckets: [0.05, 0.1, 0.2, 0.5, 1, 3], labelNames: ['imposter'] }) }; // The following schemas are used by both the lookup and copy behaviors and should be kept consistent const fromSchema = { _required: true, _allowedTypes: { string: {}, object: { singleKeyOnly: true } }, _additionalContext: 'the request field to select from' }, intoSchema = { _required: true, _allowedTypes: { string: {} }, _additionalContext: 'the token to replace in response fields' }, usingSchema = { _required: true, _allowedTypes: { object: {} }, method: { _required: true, _allowedTypes: { string: { enum: ['regex', 'xpath', 'jsonpath'] } } }, selector: { _required: true, _allowedTypes: { string: {} } } }, validations = { wait: { _required: true, _allowedTypes: { string: {}, number: { nonNegativeInteger: true } } }, copy: { from: fromSchema, into: intoSchema, using: usingSchema }, lookup: { key: { _required: true, _allowedTypes: { object: {} }, from: fromSchema, using: usingSchema }, fromDataSource: { _required: true, _allowedTypes: { object: { singleKeyOnly: true, enum: ['csv'] } }, csv: { _required: false, _allowedTypes: { object: {} }, path: { _required: true, _allowedTypes: { string: {} }, _additionalContext: 'the path to the CSV file' }, delimiter: { _required: false, _allowedTypes: { string: {} }, _additionalContext: 'the delimiter separator values' }, keyColumn: { _required: true, _allowedTypes: { string: {} }, _additionalContext: 'the column header to select against the "key" field' } } }, into: intoSchema }, shellTransform: { _required: true, _allowedTypes: { string: {} }, _additionalContext: 'the path to a command line application' }, decorate: { _required: true, _allowedTypes: { string: {} }, _additionalContext: 'a JavaScript function' } }; /** * Validates the behavior configuration and returns all errors * @param {Object} config - The behavior configuration * @returns {Object} The array of errors */ function validate (config) { const validator = behaviorsValidator.create(); return validator.validate(config, validations); } /** * Waits a specified number of milliseconds before sending the response. Due to the approximate * nature of the timer, there is no guarantee that it will wait the given amount, but it will be close. * @param {Object} request - The request object * @param {Object} response - The response * @param {number} millisecondsOrFn - The number of milliseconds to wait before returning, or a function returning milliseconds * @param {Object} logger - The mountebank logger, useful for debugging * @returns {Object} A promise resolving to the response */ async function wait (request, response, millisecondsOrFn, logger) { const fn = `(${millisecondsOrFn})()`; let milliseconds = parseInt(millisecondsOrFn); if (isNaN(milliseconds)) { try { milliseconds = eval(fn); } catch (error) { logger.error('injection X=> ' + error); logger.error(' full source: ' + JSON.stringify(fn)); return Promise.reject(exceptions.InjectionError('invalid wait injection', { source: millisecondsOrFn, data: error.message })); } } logger.debug('Waiting %s ms...', milliseconds); return new Promise(resolve => { setTimeout(() => resolve(response), milliseconds); }); } function quoteForShell (obj) { const json = JSON.stringify(obj), isWindows = os.platform().indexOf('win') === 0; if (isWindows) { // Confused? Me too. All other approaches I tried were spectacular failures // in both 1) keeping the JSON as a single CLI arg, and 2) maintaining the inner quotes return `"${json.replace(/"/g, '\\"')}"`; } else { return `'${json}'`; } } function execShell (command, request, response, logger) { const exec = childProcess.exec, env = helpers.clone(process.env), maxBuffer = buffer.constants.MAX_STRING_LENGTH, maxShellCommandLength = 2048; logger.debug(`Shelling out to ${command}`); // Switched to environment variables because of inconsistencies in Windows shell quoting // Leaving the CLI args for backwards compatibility env.MB_REQUEST = JSON.stringify(request); env.MB_RESPONSE = JSON.stringify(response); // Windows has a pretty low character limit to the command line. When we're in danger // of the character limit, we'll remove the command line arguments under the assumption // that backwards compatibility doesn't matter when it never would have worked to begin with let fullCommand = `${command} ${quoteForShell(request)} ${quoteForShell(response)}`; if (fullCommand.length >= maxShellCommandLength) { fullCommand = command; } return new Promise((resolve, reject) => { exec(fullCommand, { env, maxBuffer }, (error, stdout, stderr) => { if (error) { if (stderr) { logger.error(stderr); } reject(error.message); } else { logger.debug(`Shell returned '${stdout}'`); try { resolve(JSON.parse(stdout)); } catch (err) { reject(`Shell command returned invalid JSON: '${stdout}'`); } } }); }); } /** * Runs the response through a shell function, passing the JSON in as stdin and using * stdout as the new response * @param {Object} request - The request * @param {Object} response - The response * @param {string} command - The shell command to execute * @param {Object} logger - The mountebank logger, useful in debugging * @returns {Object} */ function shellTransform (request, response, command, logger) { return execShell(command, request, response, logger); } /** * Runs the response through a post-processing function provided by the user * @param {Object} originalRequest - The request object, in case post-processing depends on it * @param {Object} response - The response * @param {Function} fn - The function that performs the post-processing * @param {Object} logger - The mountebank logger, useful in debugging * @param {Object} imposterState - The user controlled state variable * @returns {Object} */ function decorate (originalRequest, response, fn, logger, imposterState) { const config = { request: helpers.clone(originalRequest), response, logger, state: imposterState }, injected = `(${fn})(config, response, logger);`; // backwards compatibility compatibility.downcastInjectionConfig(config); try { // Support functions that mutate response in place and those // that return a new response let result = eval(injected); if (!result) { result = response; } return Promise.resolve(result); } catch (error) { logger.error('injection X=> ' + error); logger.error(' full source: ' + JSON.stringify(injected)); logger.error(' config: ' + JSON.stringify(config)); return Promise.reject(exceptions.InjectionError('invalid decorator injection', { source: injected, data: error.message })); } } function getKeyIgnoringCase (obj, expectedKey) { return Object.keys(obj).find(key => { if (key.toLowerCase() === expectedKey.toLowerCase()) { return key; } else { return undefined; } }); } function getFrom (obj, from) { const isObject = helpers.isObject; if (typeof obj === 'undefined') { return undefined; } else if (isObject(from)) { const keys = Object.keys(from); return getFrom(obj[keys[0]], from[keys[0]]); } else { const result = obj[getKeyIgnoringCase(obj, from)]; // Some request fields, like query parameters, can be multi-valued if (Array.isArray(result)) { return result[0]; } else { return result; } } } function regexFlags (options) { let result = ''; if (options && options.ignoreCase) { result += 'i'; } if (options && options.multiline) { result += 'm'; } return result; } function getMatches (selectionFn, selector, logger) { const matches = selectionFn(); if (matches && matches.length > 0) { return matches; } else { logger.debug('No match for "%s"', selector); return []; } } function regexValue (from, config, logger) { const regex = new RegExp(config.using.selector, regexFlags(config.using.options)), selectionFn = () => regex.exec(from); if (!safeRegex(regex)) { logger.warn(`If mountebank becomes unresponsive, it is because of this unsafe regular expression: ${config.using.selector}`); } return getMatches(selectionFn, regex, logger); } function xpathValue (from, config, logger) { const selectionFn = () => { return xPath.select(config.using.selector, config.using.ns, from, logger); }; return getMatches(selectionFn, config.using.selector, logger); } function jsonpathValue (from, config, logger) { const selectionFn = () => { return jsonPath.select(config.using.selector, from, logger); }; return getMatches(selectionFn, config.using.selector, logger); } function globalStringReplace (str, substring, newSubstring, logger) { if (substring !== newSubstring) { logger.debug('Replacing %s with %s', JSON.stringify(substring), JSON.stringify(newSubstring)); return str.split(substring).join(newSubstring); } else { return str; } } function globalObjectReplace (obj, replacer) { const isObject = helpers.isObject, renames = {}; Object.keys(obj).forEach(key => { if (typeof obj[key] === 'string') { obj[key] = replacer(obj[key]); } else if (isObject(obj[key])) { globalObjectReplace(obj[key], replacer); } var newKey = replacer(key); if (newKey !== key) { renames[key] = newKey; } }); Object.keys(renames).forEach(key => { obj[renames[key]] = obj[key]; delete obj[key]; }); } function replaceArrayValuesIn (response, token, values, logger) { const replacer = field => { values.forEach(function (replacement, index) { // replace ${TOKEN}[1] with indexed element const indexedToken = `${token}[${index}]`; field = globalStringReplace(field, indexedToken, replacement, logger); }); if (values.length > 0) { // replace ${TOKEN} with first element field = globalStringReplace(field, token, values[0], logger); } return field; }; globalObjectReplace(response, replacer); } /** * Copies a value from the request and replaces response tokens with that value * @param {Object} originalRequest - The request object, in case post-processing depends on it * @param {Object} response - The response * @param {Function} copyConfig - The config to copy * @param {Object} logger - The mountebank logger, useful in debugging * @returns {Object} */ function copy (originalRequest, response, copyConfig, logger) { const from = getFrom(originalRequest, copyConfig.from), using = copyConfig.using || {}, fnMap = { regex: regexValue, xpath: xpathValue, jsonpath: jsonpathValue }, values = fnMap[using.method](from, copyConfig, logger); replaceArrayValuesIn(response, copyConfig.into, values, logger); return response; } function containsKey (headers, keyColumn) { const key = Object.values(headers).find(value => value === keyColumn); return helpers.defined(key); } function createRowObject (headers, rowArray) { const row = {}; rowArray.forEach(function (value, index) { row[headers[index]] = value; }); return row; } function selectRowFromCSV (csvConfig, keyValue, logger) { const delimiter = csvConfig.delimiter || ',', inputStream = fsExtra.createReadStream(csvConfig.path), parser = csvParse.parse({ delimiter: delimiter }), pipe = inputStream.pipe(parser); let headers; return new Promise(resolve => { inputStream.on('error', e => { logger.error('Cannot read ' + csvConfig.path + ': ' + e); resolve({}); }); pipe.on('data', function (rowArray) { if (!helpers.defined(headers)) { headers = rowArray; const keyOnHeader = containsKey(headers, csvConfig.keyColumn); if (!keyOnHeader) { logger.error('CSV headers "' + headers + '" with delimiter "' + delimiter + '" does not contain keyColumn:"' + csvConfig.keyColumn + '"'); resolve({}); } } else { const row = createRowObject(headers, rowArray); if (helpers.defined(row[csvConfig.keyColumn]) && row[csvConfig.keyColumn].localeCompare(keyValue) === 0) { resolve(row); } } }); pipe.on('error', e => { logger.debug('Error: ' + e); resolve({}); }); pipe.on('end', () => { resolve({}); }); }); } function lookupRow (lookupConfig, originalRequest, logger) { const from = getFrom(originalRequest, lookupConfig.key.from), fnMap = { regex: regexValue, xpath: xpathValue, jsonpath: jsonpathValue }, keyValues = fnMap[lookupConfig.key.using.method](from, lookupConfig.key, logger), index = lookupConfig.key.index || 0; if (lookupConfig.fromDataSource.csv) { return selectRowFromCSV(lookupConfig.fromDataSource.csv, keyValues[index], logger); } else { return Promise.resolve({}); } } function replaceObjectValuesIn (response, token, values, logger) { const replacer = field => { Object.keys(values).forEach(key => { // replace ${TOKEN}["key"] and ${TOKEN}['key'] and ${TOKEN}[key] ['"', "'", ''].forEach(function (quoteChar) { const quoted = `${token}[${quoteChar}${key}${quoteChar}]`; field = globalStringReplace(field, quoted, values[key], logger); }); }); return field; }; globalObjectReplace(response, replacer); } /** * Looks up request values from a data source and replaces response tokens with the resulting data * @param {Object} originalRequest - The request object * @param {Object} response - The response * @param {Function} lookupConfig - The lookup configurations * @param {Object} logger - The mountebank logger, useful in debugging * @returns {Object} */ async function lookup (originalRequest, response, lookupConfig, logger) { try { const row = await lookupRow(lookupConfig, originalRequest, logger); replaceObjectValuesIn(response, lookupConfig.into, row, logger); } catch (error) { logger.error(error); } return response; } /** * The entry point to execute all behaviors provided in the API * @param {Object} request - The request object * @param {Object} response - The response generated from the stubs * @param {Object} behaviors - The behaviors specified in the API * @param {Object} logger - The mountebank logger, useful for debugging * @param {Object} imposterState - the user-controlled state variable * @returns {Object} */ async function execute (request, response, behaviors, logger, imposterState) { const fnMap = { wait: wait, copy: copy, lookup: lookup, shellTransform: shellTransform, decorate: decorate }; let result = Promise.resolve(response); if (!behaviors || behaviors.length === 0 || request.isDryRun) { return result; } logger.debug('using stub response behavior ' + JSON.stringify(behaviors)); behaviors.forEach(behavior => { Object.keys(behavior).forEach(key => { if (fnMap[key]) { result = result.then(newResponse => fnMap[key](request, newResponse, behavior[key], logger, imposterState)); } }); }); const observeBehaviorDuration = metrics.behaviorDuration.startTimer(), transformed = await result; observeBehaviorDuration({ imposter: logger.scopePrefix }); return transformed; } module.exports = { validate, execute };