Source: models/predicates.js

  1. 'use strict';
  2. const stringify = require('safe-stable-stringify'),
  3. safeRegex = require('safe-regex'),
  4. jsonpath = require('./jsonpath.js'),
  5. helpers = require('../util/helpers.js'),
  6. xPath = require('./xpath.js'),
  7. combinators = require('../util/combinators.js'),
  8. errors = require('../util/errors.js'),
  9. compatibility = require('./compatibility.js');
  10. /**
  11. * All the predicates that determine whether a stub matches a request
  12. * @module
  13. */
  14. function sortObjects (a, b) {
  15. const isObject = helpers.isObject;
  16. if (isObject(a) && isObject(b)) {
  17. // Make best effort at sorting arrays of objects to make
  18. // deepEquals order-independent
  19. return sortObjects(stringify(a), stringify(b));
  20. }
  21. else if (a < b) {
  22. return -1;
  23. }
  24. else {
  25. return 1;
  26. }
  27. }
  28. function forceStrings (value) {
  29. const isObject = helpers.isObject;
  30. if (value === null) {
  31. return 'null';
  32. }
  33. else if (Array.isArray(value)) {
  34. return value.map(forceStrings);
  35. }
  36. else if (isObject(value)) {
  37. return Object.keys(value).reduce((accumulator, key) => {
  38. accumulator[key] = forceStrings(value[key]);
  39. return accumulator;
  40. }, {});
  41. }
  42. else if (typeof value.toString === 'function') {
  43. return value.toString();
  44. }
  45. else {
  46. return value;
  47. }
  48. }
  49. function select (type, selectFn, encoding) {
  50. if (encoding === 'base64') {
  51. throw errors.ValidationError(`the ${type} predicate parameter is not allowed in binary mode`);
  52. }
  53. const nodeValues = selectFn();
  54. // Return either a string if one match or array if multiple
  55. // This matches the behavior of node's handling of query parameters,
  56. // which allows us to maintain the same semantics between deepEquals
  57. // (all have to match, passing in an array if necessary) and the other
  58. // predicates (any can match)
  59. if (nodeValues && nodeValues.length === 1) {
  60. return nodeValues[0];
  61. }
  62. else {
  63. return nodeValues;
  64. }
  65. }
  66. function orderIndependent (possibleArray) {
  67. if (Array.isArray(possibleArray)) {
  68. return possibleArray.sort(sortObjects);
  69. }
  70. else {
  71. return possibleArray;
  72. }
  73. }
  74. function transformObject (obj, transform) {
  75. Object.keys(obj).forEach(key => {
  76. obj[key] = transform(obj[key]);
  77. });
  78. return obj;
  79. }
  80. function selectXPath (config, encoding, text) {
  81. const selectFn = combinators.curry(xPath.select, config.selector, config.ns, text);
  82. return orderIndependent(select('xpath', selectFn, encoding));
  83. }
  84. function selectTransform (config, options, logger) {
  85. const cloned = helpers.clone(config);
  86. if (config.jsonpath) {
  87. const stringTransform = options.shouldForceStrings ? forceStrings : combinators.identity;
  88. // use keyCaseSensitive instead of caseSensitive to help "matches" predicates too
  89. // see https://github.com/bbyars/mountebank/issues/361
  90. if (!cloned.keyCaseSensitive) {
  91. cloned.jsonpath.selector = cloned.jsonpath.selector.toLowerCase();
  92. }
  93. return combinators.curry(selectJSONPath, cloned.jsonpath, options.encoding, config, stringTransform, logger);
  94. }
  95. else if (config.xpath) {
  96. if (!cloned.caseSensitive) {
  97. cloned.xpath.ns = transformObject(cloned.xpath.ns || {}, lowercase);
  98. cloned.xpath.selector = cloned.xpath.selector.toLowerCase();
  99. }
  100. return combinators.curry(selectXPath, cloned.xpath, options.encoding);
  101. }
  102. else {
  103. return combinators.identity;
  104. }
  105. }
  106. function lowercase (text) {
  107. return text.toLowerCase();
  108. }
  109. function caseTransform (config) {
  110. return config.caseSensitive ? combinators.identity : lowercase;
  111. }
  112. function exceptTransform (config, logger) {
  113. const exceptRegexOptions = config.caseSensitive ? 'g' : 'gi';
  114. if (config.except) {
  115. if (!safeRegex(config.except)) {
  116. logger.warn(`If mountebank becomes unresponsive, it is because of this unsafe regular expression: ${config.except}`);
  117. }
  118. return text => text.replace(new RegExp(config.except, exceptRegexOptions), '');
  119. }
  120. else {
  121. return combinators.identity;
  122. }
  123. }
  124. function encodingTransform (encoding) {
  125. if (encoding === 'base64') {
  126. return text => Buffer.from(text, 'base64').toString();
  127. }
  128. else {
  129. return combinators.identity;
  130. }
  131. }
  132. function tryJSON (value, predicateConfig, logger) {
  133. try {
  134. const keyCaseTransform = predicateConfig.keyCaseSensitive === false ? lowercase : caseTransform(predicateConfig),
  135. valueTransforms = [exceptTransform(predicateConfig, logger), caseTransform(predicateConfig)];
  136. // We can't call normalize because we want to avoid the array sort transform,
  137. // which will mess up indexed selectors like $..title[1]
  138. return transformAll(JSON.parse(value), [keyCaseTransform], valueTransforms, []);
  139. }
  140. catch (e) {
  141. return value;
  142. }
  143. }
  144. // eslint-disable-next-line max-params
  145. function selectJSONPath (config, encoding, predicateConfig, stringTransform, logger, text) {
  146. const possibleJSON = stringTransform(tryJSON(text, predicateConfig, logger)),
  147. selectFn = combinators.curry(jsonpath.select, config.selector, possibleJSON);
  148. return orderIndependent(select('jsonpath', selectFn, encoding));
  149. }
  150. function transformAll (obj, keyTransforms, valueTransforms, arrayTransforms) {
  151. const apply = fns => combinators.compose.apply(null, fns),
  152. isObject = helpers.isObject;
  153. if (Array.isArray(obj)) {
  154. return apply(arrayTransforms)(obj.map(element => transformAll(element, keyTransforms, valueTransforms, arrayTransforms)));
  155. }
  156. else if (isObject(obj)) {
  157. return Object.keys(obj).reduce((accumulator, key) => {
  158. accumulator[apply(keyTransforms)(key)] = transformAll(obj[key], keyTransforms, valueTransforms, arrayTransforms);
  159. return accumulator;
  160. }, {});
  161. }
  162. else if (typeof obj === 'string') {
  163. return apply(valueTransforms)(obj);
  164. }
  165. else {
  166. return obj;
  167. }
  168. }
  169. function normalize (obj, config, options, logger) {
  170. // Needed to solve a tricky case conversion for "matches" predicates with jsonpath/xpath parameters
  171. if (typeof config.keyCaseSensitive === 'undefined') {
  172. config.keyCaseSensitive = config.caseSensitive;
  173. }
  174. const keyCaseTransform = config.keyCaseSensitive === false ? lowercase : caseTransform(config),
  175. sortTransform = array => array.sort(sortObjects),
  176. transforms = [];
  177. if (options.withSelectors) {
  178. transforms.push(selectTransform(config, options, logger));
  179. }
  180. transforms.push(exceptTransform(config, logger));
  181. transforms.push(caseTransform(config));
  182. transforms.push(encodingTransform(options.encoding));
  183. // sort to provide deterministic comparison for deepEquals,
  184. // where the order in the array for multi-valued querystring keys
  185. // and xpath selections isn't important
  186. return transformAll(obj, [keyCaseTransform], transforms, [sortTransform]);
  187. }
  188. function testPredicate (expected, actual, predicateConfig, predicateFn) {
  189. if (!helpers.defined(actual)) {
  190. actual = '';
  191. }
  192. if (helpers.isObject(expected)) {
  193. return predicateSatisfied(expected, actual, predicateConfig, predicateFn);
  194. }
  195. else {
  196. return predicateFn(expected, actual);
  197. }
  198. }
  199. function bothArrays (expected, actual) {
  200. return Array.isArray(actual) && Array.isArray(expected);
  201. }
  202. function allExpectedArrayValuesMatchActualArray (expectedArray, actualArray, predicateConfig, predicateFn) {
  203. return expectedArray.every(expectedValue =>
  204. actualArray.some(actualValue => testPredicate(expectedValue, actualValue, predicateConfig, predicateFn)));
  205. }
  206. function onlyActualIsArray (expected, actual) {
  207. return Array.isArray(actual) && !Array.isArray(expected);
  208. }
  209. function expectedMatchesAtLeastOneValueInActualArray (expected, actualArray, predicateConfig, predicateFn) {
  210. return actualArray.some(actual => testPredicate(expected, actual, predicateConfig, predicateFn));
  211. }
  212. function expectedLeftOffArraySyntaxButActualIsArrayOfObjects (expected, actual, fieldName) {
  213. return !Array.isArray(expected[fieldName]) && !helpers.defined(actual[fieldName]) && Array.isArray(actual);
  214. }
  215. function predicateSatisfied (expected, actual, predicateConfig, predicateFn) {
  216. if (!actual) {
  217. return false;
  218. }
  219. // Support predicates that reach into fields encoded in JSON strings (e.g. HTTP bodies)
  220. if (typeof actual === 'string') {
  221. actual = tryJSON(actual, predicateConfig);
  222. }
  223. return Object.keys(expected).every(fieldName => {
  224. const isObject = helpers.isObject;
  225. if (bothArrays(expected[fieldName], actual[fieldName])) {
  226. return allExpectedArrayValuesMatchActualArray(
  227. expected[fieldName], actual[fieldName], predicateConfig, predicateFn);
  228. }
  229. else if (onlyActualIsArray(expected[fieldName], actual[fieldName])) {
  230. if (predicateConfig.exists && expected[fieldName]) {
  231. return true;
  232. }
  233. else {
  234. return expectedMatchesAtLeastOneValueInActualArray(
  235. expected[fieldName], actual[fieldName], predicateConfig, predicateFn);
  236. }
  237. }
  238. else if (expectedLeftOffArraySyntaxButActualIsArrayOfObjects(expected, actual, fieldName)) {
  239. // This is a little confusing, but predated the ability for users to specify an
  240. // array for the expected values and is left for backwards compatibility.
  241. // The predicate might be:
  242. // { equals: { examples: { key: 'third' } } }
  243. // and the request might be
  244. // { examples: '[{ "key": "first" }, { "different": true }, { "key": "third" }]' }
  245. // We expect that the "key" field in the predicate definition matches any object key
  246. // in the actual array
  247. return expectedMatchesAtLeastOneValueInActualArray(expected, actual, predicateConfig, predicateFn);
  248. }
  249. else if (isObject(expected[fieldName])) {
  250. return predicateSatisfied(expected[fieldName], actual[fieldName], predicateConfig, predicateFn);
  251. }
  252. else {
  253. return testPredicate(expected[fieldName], actual[fieldName], predicateConfig, predicateFn);
  254. }
  255. });
  256. }
  257. function create (operator, predicateFn) {
  258. return (predicate, request, encoding, logger) => {
  259. const expected = normalize(predicate[operator], predicate, { encoding: encoding }, logger),
  260. actual = normalize(request, predicate, { encoding: encoding, withSelectors: true }, logger);
  261. return predicateSatisfied(expected, actual, predicate, predicateFn);
  262. };
  263. }
  264. function deepEquals (predicate, request, encoding, logger) {
  265. const expected = normalize(forceStrings(predicate.deepEquals), predicate, { encoding: encoding }, logger),
  266. actual = normalize(forceStrings(request), predicate, { encoding: encoding, withSelectors: true, shouldForceStrings: true }, logger),
  267. isObject = helpers.isObject;
  268. return Object.keys(expected).every(fieldName => {
  269. // Support predicates that reach into fields encoded in JSON strings (e.g. HTTP bodies)
  270. if (isObject(expected[fieldName]) && typeof actual[fieldName] === 'string') {
  271. const possibleJSON = tryJSON(actual[fieldName], predicate);
  272. actual[fieldName] = normalize(forceStrings(possibleJSON), predicate, { encoding: encoding }, logger);
  273. }
  274. return stringify(expected[fieldName]) === stringify(actual[fieldName]);
  275. });
  276. }
  277. function matches (predicate, request, encoding, logger) {
  278. // We want to avoid the lowerCase transform on values so we don't accidentally butcher
  279. // a regular expression with upper case metacharacters like \W and \S
  280. // However, we need to maintain the case transform for keys like http header names (issue #169)
  281. // eslint-disable-next-line no-unneeded-ternary
  282. const caseSensitive = predicate.caseSensitive ? true : false, // convert to boolean even if undefined
  283. clone = helpers.merge(predicate, { caseSensitive: true, keyCaseSensitive: caseSensitive }),
  284. noexcept = helpers.merge(clone, { except: '' }),
  285. expected = normalize(predicate.matches, noexcept, { encoding: encoding }, logger),
  286. actual = normalize(request, clone, { encoding: encoding, withSelectors: true }, logger),
  287. options = caseSensitive ? '' : 'i';
  288. if (encoding === 'base64') {
  289. throw errors.ValidationError('the matches predicate is not allowed in binary mode');
  290. }
  291. return predicateSatisfied(expected, actual, clone, (a, b) => {
  292. if (!safeRegex(a)) {
  293. logger.warn(`If mountebank becomes unresponsive, it is because of this unsafe regular expression: ${a}`);
  294. }
  295. return new RegExp(a, options).test(b);
  296. });
  297. }
  298. function not (predicate, request, encoding, logger, imposterState) {
  299. return !evaluate(predicate.not, request, encoding, logger, imposterState);
  300. }
  301. function evaluateFn (request, encoding, logger, imposterState) {
  302. return subPredicate => evaluate(subPredicate, request, encoding, logger, imposterState);
  303. }
  304. function or (predicate, request, encoding, logger, imposterState) {
  305. return predicate.or.some(evaluateFn(request, encoding, logger, imposterState));
  306. }
  307. function and (predicate, request, encoding, logger, imposterState) {
  308. return predicate.and.every(evaluateFn(request, encoding, logger, imposterState));
  309. }
  310. function inject (predicate, request, encoding, logger, imposterState) {
  311. if (request.isDryRun === true) {
  312. return true;
  313. }
  314. const config = {
  315. request: helpers.clone(request),
  316. state: imposterState,
  317. logger: logger
  318. };
  319. compatibility.downcastInjectionConfig(config);
  320. const injected = `(${predicate.inject})(config, logger, imposterState);`;
  321. try {
  322. return eval(injected);
  323. }
  324. catch (error) {
  325. logger.error(`injection X=> ${error}`);
  326. logger.error(` source: ${JSON.stringify(injected)}`);
  327. logger.error(` config.request: ${JSON.stringify(config.request)}`);
  328. logger.error(` config.state: ${JSON.stringify(config.state)}`);
  329. throw errors.InjectionError('invalid predicate injection', { source: injected, data: error.message });
  330. }
  331. }
  332. function toString (value) {
  333. if (value !== null && typeof value !== 'undefined' && typeof value.toString === 'function') {
  334. return value.toString();
  335. }
  336. else {
  337. return value;
  338. }
  339. }
  340. const predicates = {
  341. equals: create('equals', (expected, actual) => toString(expected) === toString(actual)),
  342. deepEquals,
  343. contains: create('contains', (expected, actual) => actual.indexOf(expected) >= 0),
  344. startsWith: create('startsWith', (expected, actual) => actual.indexOf(expected) === 0),
  345. endsWith: create('endsWith', (expected, actual) => actual.indexOf(expected, actual.length - expected.length) >= 0),
  346. matches,
  347. exists: create('exists', function (expected, actual) {
  348. return expected ? (typeof actual !== 'undefined' && actual !== '') : (typeof actual === 'undefined' || actual === '');
  349. }),
  350. not,
  351. or,
  352. and,
  353. inject
  354. };
  355. /**
  356. * Resolves all predicate keys in given predicate
  357. * @param {Object} predicate - The predicate configuration
  358. * @param {Object} request - The protocol request object
  359. * @param {string} encoding - utf8 or base64
  360. * @param {Object} logger - The logger, useful for debugging purposes
  361. * @param {Object} imposterState - The current state for the imposter
  362. * @returns {boolean}
  363. */
  364. function evaluate (predicate, request, encoding, logger, imposterState) {
  365. const predicateFn = Object.keys(predicate).find(key => Object.keys(predicates).indexOf(key) >= 0),
  366. clone = helpers.clone(predicate);
  367. if (predicateFn) {
  368. return predicates[predicateFn](clone, request, encoding, logger, imposterState);
  369. }
  370. else {
  371. throw errors.ValidationError('missing predicate', { source: predicate });
  372. }
  373. }
  374. module.exports = { evaluate };