Source: models/dryRunValidator.js

'use strict';

const exceptions = require('../util/errors.js'),
    helpers = require('../util/helpers.js'),
    responseResolver = require('./responseResolver.js'),
    inMemoryImpostersRepository = require('./inMemoryImpostersRepository.js'),
    predicates = require('./predicates.js'),
    combinators = require('../util/combinators.js'),
    behaviors = require('./behaviors.js');

/**
 * Validating a syntactically correct imposter creation statically is quite difficult.
 * This module validates dynamically by running test requests through each predicate and each stub
 * to see if it throws an error.  A valid request is one that passes the dry run error-free.
 * @module
 */

/**
 * Creates the validator
 * @param {Object} options - Configuration for the validator
 * @param {Object} options.testRequest - The protocol-specific request used for each dry run
 * @param {Object} options.testProxyResponse - The protocol-specific fake response from a proxy call
 * @param {boolean} options.allowInjection - Whether JavaScript injection is allowed or not
 * @param {function} options.additionalValidation - A function that performs protocol-specific validation
 * @returns {Object}
 */
function create (options) {
    function stubForResponse (originalStub, response, withPredicates) {
        // Each dry run only validates the first response, so we
        // explode the number of stubs to dry run each response separately
        const clonedStub = helpers.clone(originalStub),
            clonedResponse = helpers.clone(response);
        clonedStub.responses = [clonedResponse];

        // If the predicates don't match the test request, we won't dry run
        // the response (although the predicates will be dry run).  We remove
        // the predicates to account for this scenario.
        if (!withPredicates) {
            delete clonedStub.predicates;
        }

        return clonedStub;
    }

    function reposToTestFor (stub) {
        // Test with predicates (likely won't match) to make sure predicates don't blow up
        // Test without predicates (always matches) to make sure response doesn't blow up
        const stubsToValidateWithPredicates = stub.responses.map(response => stubForResponse(stub, response, true)),
            stubsToValidateWithoutPredicates = stub.responses.map(response => stubForResponse(stub, response, false)),
            stubsToValidate = stubsToValidateWithPredicates.concat(stubsToValidateWithoutPredicates),
            promises = stubsToValidate.map(async stubToValidate => {
                const stubRepository = inMemoryImpostersRepository.create().createStubsRepository();
                await stubRepository.add(stubToValidate);
                return stubRepository;
            });

        return Promise.all(promises);
    }

    // We call map before calling every so we make sure to call every
    // predicate during dry run validation rather than short-circuiting
    function trueForAll (list, predicate) {
        return list.map(predicate).every(result => result);
    }

    function findFirstMatch (stubRepository, request, encoding, logger) {
        const filter = stubPredicates => {
            return trueForAll(stubPredicates,
                predicate => predicates.evaluate(predicate, request, encoding, logger, {}));
        };

        return stubRepository.first(filter);
    }

    function resolverFor (stubRepository) {
        // We can get a better test (running behaviors on proxied result) if the protocol gives
        // us a testProxyResult
        if (options.testProxyResponse) {
            const dryRunProxy = { to: proxyTo => {
                if (proxyTo === undefined) {
                    throw exceptions.ValidationError('Missing to');
                }
                const url = new URL(proxyTo);
                if (url.protocol.indexOf('http') === 0 && url.pathname !== '/') {
                    throw exceptions.ValidationError(`proxy.to must not contain a path '${url.pathname}'`);
                }
                return Promise.resolve(options.testProxyResponse);
            } };
            return responseResolver.create(stubRepository, dryRunProxy);
        }
        else {
            return responseResolver.create(stubRepository, undefined, 'URL');
        }
    }

    async function dryRunSingleRepo (stubRepository, encoding, dryRunLogger) {
        const match = await findFirstMatch(stubRepository, options.testRequest, encoding, dryRunLogger),
            responseConfig = await match.stub.nextResponse();

        return resolverFor(stubRepository).resolve(responseConfig, options.testRequest, dryRunLogger, {});
    }

    async function dryRun (stub, encoding, logger) {
        options.testRequest = options.testRequest || {};
        options.testRequest.isDryRun = true;

        const dryRunLogger = {
                debug: combinators.noop,
                info: combinators.noop,
                warn: combinators.noop,
                error: logger.error
            },
            dryRunRepositories = await reposToTestFor(stub),
            dryRuns = dryRunRepositories.map(stubRepository => dryRunSingleRepo(stubRepository, encoding, dryRunLogger));

        return Promise.all(dryRuns);
    }

    async function addDryRunErrors (stub, encoding, errors, logger) {
        try {
            await dryRun(stub, encoding, logger);
        }
        catch (reason) {
            reason.source = reason.source || JSON.stringify(stub);
            errors.push(reason);
        }
    }

    function hasPredicateGeneratorInjection (response) {
        return response.proxy && response.proxy.predicateGenerators &&
            response.proxy.predicateGenerators.some(generator => generator.inject);
    }

    function hasBehavior (response, type, valueFilter) {
        if (typeof valueFilter === 'undefined') {
            valueFilter = () => true;
        }
        return (response.behaviors || []).some(behavior => {
            return typeof behavior[type] !== 'undefined' && valueFilter(behavior[type]);
        });
    }

    function hasStubInjection (stub) {
        const hasResponseInjections = stub.responses.some(response => {
                const hasDecorator = hasBehavior(response, 'decorate'),
                    hasWaitFunction = hasBehavior(response, 'wait', value => typeof value === 'string');

                return response.inject || hasDecorator || hasWaitFunction || hasPredicateGeneratorInjection(response);
            }),
            hasPredicateInjections = Object.keys(stub.predicates || {}).some(predicate => stub.predicates[predicate].inject),
            hasAddDecorateBehaviorInProxy = stub.responses.some(response => response.proxy && response.proxy.addDecorateBehavior);
        return hasResponseInjections || hasPredicateInjections || hasAddDecorateBehaviorInProxy;
    }

    function hasShellExecution (stub) {
        return stub.responses.some(response => hasBehavior(response, 'shellTransform'));
    }

    function addStubInjectionErrors (stub, errors) {
        if (options.allowInjection) {
            return;
        }

        if (hasStubInjection(stub)) {
            errors.push(exceptions.InjectionError(
                'JavaScript injection is not allowed unless mb is run with the --allowInjection flag', { source: stub }));
        }
        if (hasShellExecution(stub)) {
            errors.push(exceptions.InjectionError(
                'Shell execution is not allowed unless mb is run with the --allowInjection flag', { source: stub }));
        }
    }

    function addAllTo (values, additionalValues) {
        additionalValues.forEach(value => {
            values.push(value);
        });
    }

    function addRepeatErrorsTo (errors, response) {
        const repeat = response.repeat,
            type = typeof repeat,
            error = exceptions.ValidationError('"repeat" field must be an integer greater than 0', {
                source: response
            });

        if (['undefined', 'number', 'string'].indexOf(type) < 0) {
            errors.push(error);
        }
        if ((type === 'string' && parseInt(repeat) <= 0) || (type === 'number' && repeat <= 0)) {
            errors.push(error);
        }
    }

    function addBehaviorErrors (stub, errors) {
        stub.responses.forEach(response => {
            addAllTo(errors, behaviors.validate(response.behaviors));
            addRepeatErrorsTo(errors, response);
        });
    }

    async function errorsForStub (stub, encoding, logger) {
        const errors = [];

        if (!Array.isArray(stub.responses) || stub.responses.length === 0) {
            errors.push(exceptions.ValidationError("'responses' must be a non-empty array", {
                source: stub
            }));
        }
        else {
            addStubInjectionErrors(stub, errors);
            addBehaviorErrors(stub, errors);
        }

        if (errors.length === 0) {
            // no sense in dry-running if there are already problems;
            // it will just add noise to the errors
            await addDryRunErrors(stub, encoding, errors, logger);
        }

        return errors;
    }

    function errorsForRequest (request) {
        const errors = [],
            hasRequestInjection = request.endOfRequestResolver && request.endOfRequestResolver.inject;

        if (!options.allowInjection && hasRequestInjection) {
            errors.push(exceptions.InjectionError(
                'JavaScript injection is not allowed unless mb is run with the --allowInjection flag',
                { source: request.endOfRequestResolver }));
        }
        return errors;
    }

    /**
     * Validates that the imposter creation is syntactically valid
     * @memberOf module:models/dryRunValidator#
     * @param {Object} request - The request containing the imposter definition
     * @param {Object} logger - The logger
     * @returns {Object} Promise resolving to an object containing isValid and an errors array
     */
    async function validate (request, logger) {
        const stubs = request.stubs || [],
            encoding = request.mode === 'binary' ? 'base64' : 'utf8',
            validations = stubs.map(stub => errorsForStub(stub, encoding, logger));

        validations.push(Promise.resolve(errorsForRequest(request)));
        if (typeof options.additionalValidation === 'function') {
            validations.push(Promise.resolve(options.additionalValidation(request)));
        }

        const errorsForAllStubs = await Promise.all(validations),
            allErrors = errorsForAllStubs.reduce((stubErrors, accumulator) => accumulator.concat(stubErrors), []);
        return { isValid: allErrors.length === 0, errors: allErrors };
    }

    return { validate };
}

module.exports = { create };