UNPKG

@luminati-io/mountebank

Version:

Over the wire test doubles

439 lines (374 loc) 16.2 kB
'use strict'; const stringify = require('safe-stable-stringify'), safeRegex = require('safe-regex'), jsonpath = require('./jsonpath.js'), helpers = require('../util/helpers.js'), xPath = require('./xpath.js'), combinators = require('../util/combinators.js'), errors = require('../util/errors.js'), compatibility = require('./compatibility.js'); /** * All the predicates that determine whether a stub matches a request * @module */ function sortObjects (a, b) { const isObject = helpers.isObject; if (isObject(a) && isObject(b)) { // Make best effort at sorting arrays of objects to make // deepEquals order-independent return sortObjects(stringify(a), stringify(b)); } else if (a < b) { return -1; } else { return 1; } } function forceStrings (value) { const isObject = helpers.isObject; if (value === null) { return 'null'; } else if (Array.isArray(value)) { return value.map(forceStrings); } else if (isObject(value)) { return Object.keys(value).reduce((accumulator, key) => { accumulator[key] = forceStrings(value[key]); return accumulator; }, {}); } else if (typeof value.toString === 'function') { return value.toString(); } else { return value; } } function select (type, selectFn, encoding) { if (encoding === 'base64') { throw errors.ValidationError(`the ${type} predicate parameter is not allowed in binary mode`); } const nodeValues = selectFn(); // Return either a string if one match or array if multiple // This matches the behavior of node's handling of query parameters, // which allows us to maintain the same semantics between deepEquals // (all have to match, passing in an array if necessary) and the other // predicates (any can match) if (nodeValues && nodeValues.length === 1) { return nodeValues[0]; } else { return nodeValues; } } function orderIndependent (possibleArray) { if (Array.isArray(possibleArray)) { return possibleArray.sort(sortObjects); } else { return possibleArray; } } function transformObject (obj, transform) { Object.keys(obj).forEach(key => { obj[key] = transform(obj[key]); }); return obj; } function selectXPath (config, encoding, text) { const selectFn = combinators.curry(xPath.select, config.selector, config.ns, text); return orderIndependent(select('xpath', selectFn, encoding)); } function selectTransform (config, options, logger) { const cloned = helpers.clone(config); if (config.jsonpath) { const stringTransform = options.shouldForceStrings ? forceStrings : combinators.identity; // use keyCaseSensitive instead of caseSensitive to help "matches" predicates too // see https://github.com/bbyars/mountebank/issues/361 if (!cloned.keyCaseSensitive) { cloned.jsonpath.selector = cloned.jsonpath.selector.toLowerCase(); } return combinators.curry(selectJSONPath, cloned.jsonpath, options.encoding, config, stringTransform, logger); } else if (config.xpath) { if (!cloned.caseSensitive) { cloned.xpath.ns = transformObject(cloned.xpath.ns || {}, lowercase); cloned.xpath.selector = cloned.xpath.selector.toLowerCase(); } return combinators.curry(selectXPath, cloned.xpath, options.encoding); } else { return combinators.identity; } } function lowercase (text) { return text.toLowerCase(); } function caseTransform (config) { return config.caseSensitive ? combinators.identity : lowercase; } function exceptTransform (config, logger) { const exceptRegexOptions = config.caseSensitive ? 'g' : 'gi'; if (config.except) { if (!safeRegex(config.except)) { logger.warn(`If mountebank becomes unresponsive, it is because of this unsafe regular expression: ${config.except}`); } return text => text.replace(new RegExp(config.except, exceptRegexOptions), ''); } else { return combinators.identity; } } function encodingTransform (encoding) { if (encoding === 'base64') { return text => Buffer.from(text, 'base64').toString(); } else { return combinators.identity; } } function tryJSON (value, predicateConfig, logger) { try { const keyCaseTransform = predicateConfig.keyCaseSensitive === false ? lowercase : caseTransform(predicateConfig), valueTransforms = [exceptTransform(predicateConfig, logger), caseTransform(predicateConfig)]; // We can't call normalize because we want to avoid the array sort transform, // which will mess up indexed selectors like $..title[1] return transformAll(JSON.parse(value), [keyCaseTransform], valueTransforms, []); } catch (e) { return value; } } // eslint-disable-next-line max-params function selectJSONPath (config, encoding, predicateConfig, stringTransform, logger, text) { const possibleJSON = stringTransform(tryJSON(text, predicateConfig, logger)), selectFn = combinators.curry(jsonpath.select, config.selector, possibleJSON); return orderIndependent(select('jsonpath', selectFn, encoding)); } function transformAll (obj, keyTransforms, valueTransforms, arrayTransforms) { const apply = fns => combinators.compose.apply(null, fns), isObject = helpers.isObject; if (Array.isArray(obj)) { return apply(arrayTransforms)(obj.map(element => transformAll(element, keyTransforms, valueTransforms, arrayTransforms))); } else if (isObject(obj)) { return Object.keys(obj).reduce((accumulator, key) => { accumulator[apply(keyTransforms)(key)] = transformAll(obj[key], keyTransforms, valueTransforms, arrayTransforms); return accumulator; }, {}); } else if (typeof obj === 'string') { return apply(valueTransforms)(obj); } else { return obj; } } function normalize (obj, config, options, logger) { // Needed to solve a tricky case conversion for "matches" predicates with jsonpath/xpath parameters if (typeof config.keyCaseSensitive === 'undefined') { config.keyCaseSensitive = config.caseSensitive; } const keyCaseTransform = config.keyCaseSensitive === false ? lowercase : caseTransform(config), sortTransform = array => array.sort(sortObjects), transforms = []; if (options.withSelectors) { transforms.push(selectTransform(config, options, logger)); } transforms.push(exceptTransform(config, logger)); transforms.push(caseTransform(config)); transforms.push(encodingTransform(options.encoding)); // sort to provide deterministic comparison for deepEquals, // where the order in the array for multi-valued querystring keys // and xpath selections isn't important return transformAll(obj, [keyCaseTransform], transforms, [sortTransform]); } function testPredicate (expected, actual, predicateConfig, predicateFn) { if (!helpers.defined(actual)) { actual = ''; } if (helpers.isObject(expected)) { return predicateSatisfied(expected, actual, predicateConfig, predicateFn); } else { return predicateFn(expected, actual); } } function bothArrays (expected, actual) { return Array.isArray(actual) && Array.isArray(expected); } function allExpectedArrayValuesMatchActualArray (expectedArray, actualArray, predicateConfig, predicateFn) { return expectedArray.every(expectedValue => actualArray.some(actualValue => testPredicate(expectedValue, actualValue, predicateConfig, predicateFn))); } function onlyActualIsArray (expected, actual) { return Array.isArray(actual) && !Array.isArray(expected); } function expectedMatchesAtLeastOneValueInActualArray (expected, actualArray, predicateConfig, predicateFn) { return actualArray.some(actual => testPredicate(expected, actual, predicateConfig, predicateFn)); } function expectedLeftOffArraySyntaxButActualIsArrayOfObjects (expected, actual, fieldName) { return !Array.isArray(expected[fieldName]) && !helpers.defined(actual[fieldName]) && Array.isArray(actual); } function predicateSatisfied (expected, actual, predicateConfig, predicateFn) { if (!actual) { return false; } // Support predicates that reach into fields encoded in JSON strings (e.g. HTTP bodies) if (typeof actual === 'string') { actual = tryJSON(actual, predicateConfig); } return Object.keys(expected).every(fieldName => { const isObject = helpers.isObject; if (bothArrays(expected[fieldName], actual[fieldName])) { return allExpectedArrayValuesMatchActualArray( expected[fieldName], actual[fieldName], predicateConfig, predicateFn); } else if (onlyActualIsArray(expected[fieldName], actual[fieldName])) { if (predicateConfig.exists && expected[fieldName]) { return true; } else { return expectedMatchesAtLeastOneValueInActualArray( expected[fieldName], actual[fieldName], predicateConfig, predicateFn); } } else if (expectedLeftOffArraySyntaxButActualIsArrayOfObjects(expected, actual, fieldName)) { // This is a little confusing, but predated the ability for users to specify an // array for the expected values and is left for backwards compatibility. // The predicate might be: // { equals: { examples: { key: 'third' } } } // and the request might be // { examples: '[{ "key": "first" }, { "different": true }, { "key": "third" }]' } // We expect that the "key" field in the predicate definition matches any object key // in the actual array return expectedMatchesAtLeastOneValueInActualArray(expected, actual, predicateConfig, predicateFn); } else if (isObject(expected[fieldName])) { return predicateSatisfied(expected[fieldName], actual[fieldName], predicateConfig, predicateFn); } else { return testPredicate(expected[fieldName], actual[fieldName], predicateConfig, predicateFn); } }); } function create (operator, predicateFn) { return (predicate, request, encoding, logger) => { const expected = normalize(predicate[operator], predicate, { encoding: encoding }, logger), actual = normalize(request, predicate, { encoding: encoding, withSelectors: true }, logger); return predicateSatisfied(expected, actual, predicate, predicateFn); }; } function deepEquals (predicate, request, encoding, logger) { const expected = normalize(forceStrings(predicate.deepEquals), predicate, { encoding: encoding }, logger), actual = normalize(forceStrings(request), predicate, { encoding: encoding, withSelectors: true, shouldForceStrings: true }, logger), isObject = helpers.isObject; return Object.keys(expected).every(fieldName => { // Support predicates that reach into fields encoded in JSON strings (e.g. HTTP bodies) if (isObject(expected[fieldName]) && typeof actual[fieldName] === 'string') { const possibleJSON = tryJSON(actual[fieldName], predicate); actual[fieldName] = normalize(forceStrings(possibleJSON), predicate, { encoding: encoding }, logger); } return stringify(expected[fieldName]) === stringify(actual[fieldName]); }); } function matches (predicate, request, encoding, logger) { // We want to avoid the lowerCase transform on values so we don't accidentally butcher // a regular expression with upper case metacharacters like \W and \S // However, we need to maintain the case transform for keys like http header names (issue #169) // eslint-disable-next-line no-unneeded-ternary const caseSensitive = predicate.caseSensitive ? true : false, // convert to boolean even if undefined clone = helpers.merge(predicate, { caseSensitive: true, keyCaseSensitive: caseSensitive }), noexcept = helpers.merge(clone, { except: '' }), expected = normalize(predicate.matches, noexcept, { encoding: encoding }, logger), actual = normalize(request, clone, { encoding: encoding, withSelectors: true }, logger), options = caseSensitive ? '' : 'i'; if (encoding === 'base64') { throw errors.ValidationError('the matches predicate is not allowed in binary mode'); } return predicateSatisfied(expected, actual, clone, (a, b) => { if (!safeRegex(a)) { logger.warn(`If mountebank becomes unresponsive, it is because of this unsafe regular expression: ${a}`); } return new RegExp(a, options).test(b); }); } function not (predicate, request, encoding, logger, imposterState) { return !evaluate(predicate.not, request, encoding, logger, imposterState); } function evaluateFn (request, encoding, logger, imposterState) { return subPredicate => evaluate(subPredicate, request, encoding, logger, imposterState); } function or (predicate, request, encoding, logger, imposterState) { return predicate.or.some(evaluateFn(request, encoding, logger, imposterState)); } function and (predicate, request, encoding, logger, imposterState) { return predicate.and.every(evaluateFn(request, encoding, logger, imposterState)); } function inject (predicate, request, encoding, logger, imposterState) { if (request.isDryRun === true) { return true; } const config = { request: helpers.clone(request), state: imposterState, logger: logger }; compatibility.downcastInjectionConfig(config); const injected = `(${predicate.inject})(config, logger, imposterState);`; try { return eval(injected); } catch (error) { logger.error(`injection X=> ${error}`); logger.error(` source: ${JSON.stringify(injected)}`); logger.error(` config.request: ${JSON.stringify(config.request)}`); logger.error(` config.state: ${JSON.stringify(config.state)}`); throw errors.InjectionError('invalid predicate injection', { source: injected, data: error.message }); } } function toString (value) { if (value !== null && typeof value !== 'undefined' && typeof value.toString === 'function') { return value.toString(); } else { return value; } } const predicates = { equals: create('equals', (expected, actual) => toString(expected) === toString(actual)), deepEquals, contains: create('contains', (expected, actual) => actual.indexOf(expected) >= 0), startsWith: create('startsWith', (expected, actual) => actual.indexOf(expected) === 0), endsWith: create('endsWith', (expected, actual) => actual.indexOf(expected, actual.length - expected.length) >= 0), matches, exists: create('exists', function (expected, actual) { return expected ? (typeof actual !== 'undefined' && actual !== '') : (typeof actual === 'undefined' || actual === ''); }), not, or, and, inject }; /** * Resolves all predicate keys in given predicate * @param {Object} predicate - The predicate configuration * @param {Object} request - The protocol request object * @param {string} encoding - utf8 or base64 * @param {Object} logger - The logger, useful for debugging purposes * @param {Object} imposterState - The current state for the imposter * @returns {boolean} */ function evaluate (predicate, request, encoding, logger, imposterState) { const predicateFn = Object.keys(predicate).find(key => Object.keys(predicates).indexOf(key) >= 0), clone = helpers.clone(predicate); if (predicateFn) { return predicates[predicateFn](clone, request, encoding, logger, imposterState); } else { throw errors.ValidationError('missing predicate', { source: predicate }); } } module.exports = { evaluate };